Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-02-20 16:49:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-02-20 16:49:51 +0300
commit71786ddc8e28fbd3cb3fcc4b3ff15e5962a1c82e (patch)
tree6a2d93ef3fb2d353bb7739e4b57e6541f51cdd71 /app/assets/javascripts
parenta7253423e3403b8c08f8a161e5937e1488f5f407 (diff)
Add latest changes from gitlab-org/gitlab@15-9-stable-eev15.9.0-rc42
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue26
-rw-r--r--app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue68
-rw-r--r--app/assets/javascripts/abuse_reports/index.js22
-rw-r--r--app/assets/javascripts/admin/background_migrations/components/database_listbox.vue2
-rw-r--r--app/assets/javascripts/admin/topics/components/topic_select.vue91
-rw-r--r--app/assets/javascripts/airflow/dags/components/dags.vue111
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_form.vue5
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_popover.vue35
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js116
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js7
-rw-r--r--app/assets/javascripts/api/environments_api.js15
-rw-r--r--app/assets/javascripts/api/groups_api.js9
-rw-r--r--app/assets/javascripts/api/projects_api.js13
-rw-r--r--app/assets/javascripts/artifacts/components/app.vue65
-rw-r--r--app/assets/javascripts/artifacts/constants.js4
-rw-r--r--app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql8
-rw-r--r--app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql3
-rw-r--r--app/assets/javascripts/artifacts/index.js4
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue27
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue58
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue52
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue5
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js2
-rw-r--r--app/assets/javascripts/behaviors/index.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js52
-rw-r--r--app/assets/javascripts/behaviors/select2.js30
-rw-r--r--app/assets/javascripts/behaviors/shortcuts.js5
-rw-r--r--app/assets/javascripts/blob/components/table_contents.vue50
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue3
-rw-r--r--app/assets/javascripts/blob/openapi/index.js11
-rw-r--r--app/assets/javascripts/boards/boards_util.js31
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue16
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue48
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue20
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue109
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue16
-rw-r--r--app/assets/javascripts/boards/components/board_top_bar.vue44
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue38
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue4
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue9
-rw-r--r--app/assets/javascripts/boards/constants.js51
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql12
-rw-r--r--app/assets/javascripts/boards/index.js38
-rw-r--r--app/assets/javascripts/boards/stores/actions.js4
-rw-r--r--app/assets/javascripts/boards/stores/getters.js5
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js11
-rw-r--r--app/assets/javascripts/branches/branch_sort_dropdown.js6
-rw-r--r--app/assets/javascripts/branches/components/sort_dropdown.vue35
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue10
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue10
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue106
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/constants.js11
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/graphql/settings.js23
-rw-r--r--app/assets/javascripts/ci/ci_variable_list/index.js4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue15
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue25
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue2
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js7
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue62
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue23
-rw-r--r--app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue128
-rw-r--r--app/assets/javascripts/ci/reports/codequality_report/constants.js12
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue78
-rw-r--r--app/assets/javascripts/ci/runner/admin_new_runner/index.js32
-rw-r--r--app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue73
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue29
-rw-r--r--app/assets/javascripts/ci/runner/admin_runners/index.js2
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue1
-rw-r--r--app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue6
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_assigned_item.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue38
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_delete_button.vue2
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_details_tabs.vue95
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_form_fields.vue140
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue3
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_jobs_table.vue4
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list.vue13
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue27
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue76
-rw-r--r--app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue108
-rw-r--r--app/assets/javascripts/ci/runner/constants.js18
-rw-r--r--app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql9
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue12
-rw-r--r--app/assets/javascripts/ci/runner/group_runner_show/index.js2
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue19
-rw-r--r--app/assets/javascripts/ci/runner/group_runners/index.js2
-rw-r--r--app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue4
-rw-r--r--app/assets/javascripts/ci_secure_files/components/metadata/button.vue54
-rw-r--r--app/assets/javascripts/ci_secure_files/components/metadata/modal.vue129
-rw-r--r--app/assets/javascripts/ci_secure_files/components/metadata/table.vue36
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue22
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue40
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js8
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js4
-rw-r--r--app/assets/javascripts/clusters_list/constants.js6
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue48
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue113
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js1
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue125
-rw-r--r--app/assets/javascripts/contributors/index.js5
-rw-r--r--app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue6
-rw-r--r--app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue6
-rw-r--r--app/assets/javascripts/deprecated_notes.js33
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue3
-rw-r--r--app/assets/javascripts/design_management/components/image.vue22
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue10
-rw-r--r--app/assets/javascripts/design_management/graphql.js2
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue108
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js54
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue21
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue5
-rw-r--r--app/assets/javascripts/dropzone_input.js2
-rw-r--r--app/assets/javascripts/editor/schema/ci.json20
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue22
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue38
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue1
-rw-r--r--app/assets/javascripts/environments/components/stop_stale_environments_modal.vue104
-rw-r--r--app/assets/javascripts/environments/constants.js10
-rw-r--r--app/assets/javascripts/environments/edit.js1
-rw-r--r--app/assets/javascripts/environments/environment_details/components/deployment_actions.vue31
-rw-r--r--app/assets/javascripts/environments/environment_details/constants.js6
-rw-r--r--app/assets/javascripts/environments/environment_details/deployments_table.vue5
-rw-r--r--app/assets/javascripts/environments/environment_details/index.vue6
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql1
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql20
-rw-r--r--app/assets/javascripts/environments/graphql/resolvers.js1
-rw-r--r--app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js28
-rw-r--r--app/assets/javascripts/flash.js72
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue14
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue16
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue15
-rw-r--r--app/assets/javascripts/gl_form.js19
-rw-r--r--app/assets/javascripts/gpg_badges.js2
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js47
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js80
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json3
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js12
-rw-r--r--app/assets/javascripts/groups/components/app.vue3
-rw-r--r--app/assets/javascripts/groups/init_transfer_group_form.js3
-rw-r--r--app/assets/javascripts/groups_projects/components/transfer_locations.vue18
-rw-r--r--app/assets/javascripts/header.js7
-rw-r--r--app/assets/javascripts/header_search/components/app.vue4
-rw-r--r--app/assets/javascripts/header_search/constants.js2
-rw-r--r--app/assets/javascripts/header_search/index.js24
-rw-r--r--app/assets/javascripts/helpers/init_simple_app_helper.js39
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue15
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue191
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue136
-rw-r--r--app/assets/javascripts/ide/constants.js10
-rw-r--r--app/assets/javascripts/ide/index.js2
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js10
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js9
-rw-r--r--app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js20
-rw-r--r--app/assets/javascripts/ide/lib/mirror.js3
-rw-r--r--app/assets/javascripts/ide/remote/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/getters.js3
-rw-r--r--app/assets/javascripts/ide/stores/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js11
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/index.js6
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue16
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue6
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue90
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue26
-rw-r--r--app/assets/javascripts/import_entities/import_groups/constants.js3
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue8
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue79
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js40
-rw-r--r--app/assets/javascripts/import_entities/import_projects/utils.js2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue1
-rw-r--r--app/assets/javascripts/integrations/constants.js16
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue22
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_table.vue14
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue55
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue11
-rw-r--r--app/assets/javascripts/invite_members/components/project_select.vue94
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js3
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue5
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue6
-rw-r--r--app/assets/javascripts/issuable/components/issue_milestone.vue2
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue4
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue4
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_actions.js5
-rw-r--r--app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js3
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js50
-rw-r--r--app/assets/javascripts/issuable/popover/components/issue_popover.vue4
-rw-r--r--app/assets/javascripts/issues/constants.js21
-rw-r--r--app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue135
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql10
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql70
-rw-r--r--app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql56
-rw-r--r--app/assets/javascripts/issues/index.js2
-rw-r--r--app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue14
-rw-r--r--app/assets/javascripts/issues/list/components/issue_card_time_info.vue5
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue85
-rw-r--r--app/assets/javascripts/issues/list/components/new_issue_dropdown.vue127
-rw-r--r--app/assets/javascripts/issues/list/constants.js2
-rw-r--r--app/assets/javascripts/issues/list/graphql.js4
-rw-r--r--app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js18
-rw-r--r--app/assets/javascripts/issues/list/index.js2
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues.query.graphql8
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues/list/queries/search_labels.query.graphql8
-rw-r--r--app/assets/javascripts/issues/list/queries/search_projects.query.graphql3
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue23
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue297
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue20
-rw-r--r--app/assets/javascripts/issues/show/components/fields/type.vue55
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/header_actions.vue15
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js12
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue10
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue5
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql6
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue33
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue30
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue20
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue42
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js8
-rw-r--r--app/assets/javascripts/issues/show/components/task_list_item_actions.vue47
-rw-r--r--app/assets/javascripts/issues/show/graphql.js9
-rw-r--r--app/assets/javascripts/issues/show/index.js20
-rw-r--r--app/assets/javascripts/issues/show/utils.js135
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/api.js5
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue16
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue4
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue6
-rw-r--r--app/assets/javascripts/jobs/components/job/job_app.vue8
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue23
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue4
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue9
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js2
-rw-r--r--app/assets/javascripts/jobs/constants.js6
-rw-r--r--app/assets/javascripts/jobs/store/actions.js11
-rw-r--r--app/assets/javascripts/language_switcher/components/app.vue2
-rw-r--r--app/assets/javascripts/layout_nav.js6
-rw-r--r--app/assets/javascripts/lib/apollo/persist_link.js141
-rw-r--r--app/assets/javascripts/lib/apollo/persistence_mapper.js67
-rw-r--r--app/assets/javascripts/lib/graphql.js41
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js31
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js9
-rw-r--r--app/assets/javascripts/lib/utils/scroll_utils.js29
-rw-r--r--app/assets/javascripts/lib/utils/select2_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js10
-rw-r--r--app/assets/javascripts/listbox/index.js10
-rw-r--r--app/assets/javascripts/locale/index.js13
-rw-r--r--app/assets/javascripts/main.js11
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue2
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/constants.js1
-rw-r--r--app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue15
-rw-r--r--app/assets/javascripts/members/components/modals/remove_group_link_modal.vue1
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue1
-rw-r--r--app/assets/javascripts/members/components/table/member_actions.vue (renamed from app/assets/javascripts/members/components/table/member_action_buttons.vue)2
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue13
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue3
-rw-r--r--app/assets/javascripts/merge_request.js8
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_app.vue134
-rw-r--r--app/assets/javascripts/merge_requests/components/compare_dropdown.vue145
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue6
-rw-r--r--app/assets/javascripts/merge_requests/components/target_project_dropdown.vue87
-rw-r--r--app/assets/javascripts/milestones/components/delete_milestone_modal.vue4
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue48
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue17
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue132
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/constants.js22
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue85
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js17
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js3
-rw-r--r--app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js11
-rw-r--r--app/assets/javascripts/mr_notes/init.js6
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js5
-rw-r--r--app/assets/javascripts/nav/components/new_nav_toggle.vue2
-rw-r--r--app/assets/javascripts/nav/components/top_nav_app.vue2
-rw-r--r--app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue6
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_sections.vue12
-rw-r--r--app/assets/javascripts/notes/components/attachments_warning.vue18
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue17
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue5
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue52
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue8
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue4
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue7
-rw-r--r--app/assets/javascripts/notes/i18n.js3
-rw-r--r--app/assets/javascripts/notes/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/actions.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue62
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue68
-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/functional/delete_packages.vue76
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue27
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/index.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue70
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue65
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue28
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js6
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue47
-rw-r--r--app/assets/javascripts/pages/abuse_reports/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/account_and_limits.js57
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/hooks/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue37
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue (renamed from app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue)24
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/constants.js15
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue19
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js50
-rw-r--r--app/assets/javascripts/pages/admin/projects/components/namespace_select.vue145
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/runners/new/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js4
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js9
-rw-r--r--app/assets/javascripts/pages/dashboard/milestones/index/index.js13
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js16
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js7
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js10
-rw-r--r--app/assets/javascripts/pages/groups/usage_quotas/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/saved_replies/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/airflow/dags/index/index.js27
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js38
-rw-r--r--app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js28
-rw-r--r--app/assets/javascripts/pages/projects/find_file/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue33
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js38
-rw-r--r--app/assets/javascripts/pages/projects/hooks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue15
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue146
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue56
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue151
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js133
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js31
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js66
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js91
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js81
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js23
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/ml/candidates/show/index.js27
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/index/index.js24
-rw-r--r--app/assets/javascripts/pages/projects/ml/experiments/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/network/show/index.js32
-rw-r--r--app/assets/javascripts/pages/projects/project.js8
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue68
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js2
-rw-r--r--app/assets/javascripts/pages/search/show/refresh_counts.js24
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js17
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue11
-rw-r--r--app/assets/javascripts/pages/users/show/index.js16
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue2
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue21
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue17
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue108
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js10
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue7
-rw-r--r--app/assets/javascripts/pipelines/constants.js1
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js2
-rw-r--r--app/assets/javascripts/profile/components/activity_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/contributed_projects_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/followers_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/following_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/groups_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/personal_projects_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue72
-rw-r--r--app/assets/javascripts/profile/components/snippets_tab.vue17
-rw-r--r--app/assets/javascripts/profile/components/starred_projects_tab.vue17
-rw-r--r--app/assets/javascripts/profile/index.js16
-rw-r--r--app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue32
-rw-r--r--app/assets/javascripts/project_select.js128
-rw-r--r--app/assets/javascripts/project_select_combo_button.js122
-rw-r--r--app/assets/javascripts/projects/commit/components/branches_dropdown.vue84
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue13
-rw-r--r--app/assets/javascripts/projects/commit/components/projects_dropdown.vue57
-rw-r--r--app/assets/javascripts/projects/commit/store/getters.js4
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue4
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql4
-rw-r--r--app/assets/javascripts/projects/merge_requests/index.js18
-rw-r--r--app/assets/javascripts/projects/project_name_rules.js29
-rw-r--r--app/assets/javascripts/projects/project_new.js9
-rw-r--r--app/assets/javascripts/projects/project_visibility.js15
-rw-r--r--app/assets/javascripts/projects/prune_objects_button.js23
-rw-r--r--app/assets/javascripts/projects/prune_unreachable_objects_button.vue75
-rw-r--r--app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue (renamed from app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue)17
-rw-r--r--app/assets/javascripts/projects/report_abuse/index.js25
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js12
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue105
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js2
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql47
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue53
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue4
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/constants.js22
-rw-r--r--app/assets/javascripts/projects/settings/utils.js2
-rw-r--r--app/assets/javascripts/ref/components/ref_results_section.vue138
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue220
-rw-r--r--app/assets/javascripts/ref/constants.js4
-rw-r--r--app/assets/javascripts/ref/format_refs.js60
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue3
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue7
-rw-r--r--app/assets/javascripts/related_issues/constants.js55
-rw-r--r--app/assets/javascripts/related_issues/index.js5
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue5
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue6
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue4
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue5
-rw-r--r--app/assets/javascripts/releases/components/release_block_assets.vue2
-rw-r--r--app/assets/javascripts/releases/components/releases_empty_state.vue49
-rw-r--r--app/assets/javascripts/releases/release_notification_service.js23
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js2
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js2
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue36
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue9
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue2
-rw-r--r--app/assets/javascripts/repository/index.js8
-rw-r--r--app/assets/javascripts/repository/mixins/highlight_mixin.js106
-rw-r--r--app/assets/javascripts/repository/utils/ref_switcher_utils.js7
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/saved_replies/components/app.vue23
-rw-r--r--app/assets/javascripts/saved_replies/components/list.vue57
-rw-r--r--app/assets/javascripts/saved_replies/components/list_item.vue19
-rw-r--r--app/assets/javascripts/saved_replies/index.js31
-rw-r--r--app/assets/javascripts/saved_replies/pages/index.vue15
-rw-r--r--app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql19
-rw-r--r--app/assets/javascripts/saved_replies/routes.js8
-rw-r--r--app/assets/javascripts/search/index.js5
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue14
-rw-r--r--app/assets/javascripts/search/sidebar/components/checkbox_filter.vue81
-rw-r--r--app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue9
-rw-r--r--app/assets/javascripts/search/sidebar/components/language_filter.vue122
-rw-r--r--app/assets/javascripts/search/sidebar/components/results_filters.vue15
-rw-r--r--app/assets/javascripts/search/sidebar/components/scope_navigation.vue5
-rw-r--r--app/assets/javascripts/search/sidebar/components/status_filter.vue9
-rw-r--r--app/assets/javascripts/search/sidebar/constants/language_filter_data.js18
-rw-r--r--app/assets/javascripts/search/sidebar/utils.js20
-rw-r--r--app/assets/javascripts/search/store/actions.js23
-rw-r--r--app/assets/javascripts/search/store/constants.js7
-rw-r--r--app/assets/javascripts/search/store/getters.js9
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/search/store/mutations.js9
-rw-r--r--app/assets/javascripts/search/store/state.js6
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue7
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue4
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js21
-rw-r--r--app/assets/javascripts/service_ping_consent.js4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql4
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql20
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue20
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/move/move_issue_button.vue71
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue195
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue154
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue7
-rw-r--r--app/assets/javascripts/sidebar/constants.js50
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js89
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js148
-rw-r--r--app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql4
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js4
-rw-r--r--app/assets/javascripts/snippet/snippet_show.js2
-rw-r--r--app/assets/javascripts/super_sidebar/components/bottom_bar.vue24
-rw-r--r--app/assets/javascripts/super_sidebar/components/counter.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/create_menu.vue38
-rw-r--r--app/assets/javascripts/super_sidebar/components/help_center.vue178
-rw-r--r--app/assets/javascripts/super_sidebar/components/merge_request_menu.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/super_sidebar.vue11
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_bar.vue36
-rw-r--r--app/assets/javascripts/terms/components/app.vue27
-rw-r--r--app/assets/javascripts/token_access/components/inbound_token_access.vue258
-rw-r--r--app/assets/javascripts/token_access/components/opt_in_jwt.vue125
-rw-r--r--app/assets/javascripts/token_access/components/outbound_token_access.vue (renamed from app/assets/javascripts/token_access/components/token_access.vue)29
-rw-r--r--app/assets/javascripts/token_access/components/token_access_app.vue27
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue29
-rw-r--r--app/assets/javascripts/token_access/constants.js14
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql7
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql7
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql8
-rw-r--r--app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql18
-rw-r--r--app/assets/javascripts/token_access/index.js4
-rw-r--r--app/assets/javascripts/tracking/get_standard_context.js2
-rw-r--r--app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue35
-rw-r--r--app/assets/javascripts/usage_quotas/constants.js7
-rw-r--r--app/assets/javascripts/usage_quotas/index.js23
-rw-r--r--app/assets/javascripts/users/profile/components/report_abuse_button.vue21
-rw-r--r--app/assets/javascripts/users/profile/index.js7
-rw-r--r--app/assets/javascripts/users_select/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue69
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue45
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue100
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue12
-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_conflicts.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue16
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/i18n.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mappers.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js1
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/constants.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue (renamed from app/assets/javascripts/vue_shared/components/group_select/group_select.vue)140
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/group_select.vue137
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js (renamed from app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js)0
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/project_select.vue168
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/utils.js (renamed from app/assets/javascripts/vue_shared/components/group_select/utils.js)0
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js610
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/file_tree.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/group_select/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/incubation/pagination.vue62
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql18
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql21
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js46
-rw-r--r--app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue208
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue118
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue213
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue227
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js50
-rw-r--r--app/assets/javascripts/vue_shared/components/url_sync.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue1
-rw-r--r--app/assets/javascripts/vue_shared/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/constants.js12
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js57
-rw-r--r--app/assets/javascripts/webhooks/components/test_dropdown.vue69
-rw-r--r--app/assets/javascripts/webhooks/index.js20
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue6
-rw-r--r--app/assets/javascripts/whats_new/utils/notification.js2
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue1
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue2
-rw-r--r--app/assets/javascripts/work_items/components/notes/system_note.vue6
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_add_note.vue211
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue126
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue (renamed from app/assets/javascripts/work_items/components/work_item_comment_locked.vue)0
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_discussion.vue191
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note.vue159
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue47
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_body.vue15
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue53
-rw-r--r--app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue (renamed from app/assets/javascripts/work_items/components/work_item_note_signed_out.vue)0
-rw-r--r--app/assets/javascripts/work_items/components/widget_wrapper.vue80
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_comment_form.vue228
-rw-r--r--app/assets/javascripts/work_items/components/work_item_created_updated.vue115
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue34
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description_rendered.vue1
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue42
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue2
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue46
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue155
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue8
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue65
-rw-r--r--app/assets/javascripts/work_items/components/work_item_notes.vue166
-rw-r--r--app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/cache_utils.js62
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql18
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql25
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql (renamed from app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql)14
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql (renamed from app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql)2
-rw-r--r--app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql (renamed from app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql)2
-rw-r--r--app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql11
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql1
-rw-r--r--app/assets/javascripts/work_items/index.js3
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue4
-rw-r--r--app/assets/javascripts/work_items/utils.js25
689 files changed, 13286 insertions, 7027 deletions
diff --git a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
index c716afbbcf0..4a7c12e5e51 100644
--- a/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
+++ b/app/assets/javascripts/abuse_reports/components/abuse_category_selector.vue
@@ -18,14 +18,17 @@ export default {
reportAbusePath: {
default: '',
},
+ },
+ props: {
reportedUserId: {
- default: '',
+ type: Number,
+ required: true,
},
reportedFromUrl: {
+ type: String,
+ required: false,
default: '',
},
- },
- props: {
showDrawer: {
type: Boolean,
required: true,
@@ -39,8 +42,8 @@ export default {
},
categoryOptions: [
{ value: 'spam', text: s__("ReportAbuse|They're posting spam.") },
- { value: 'offensive', text: s__("ReportAbuse|They're being offsensive or abusive.") },
- { value: 'phishing', text: s__("ReportAbuse|They're phising.") },
+ { value: 'offensive', text: s__("ReportAbuse|They're being offensive or abusive.") },
+ { value: 'phishing', text: s__("ReportAbuse|They're phishing.") },
{ value: 'crypto', text: s__("ReportAbuse|They're crypto mining.") },
{
value: 'credentials',
@@ -53,13 +56,22 @@ export default {
data() {
return {
selected: '',
+ mounted: false,
};
},
computed: {
drawerOffsetTop() {
+ // avoid calculating this in advance because it causes layout thrashing
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396
+ if (!this.showDrawer) return '0';
return getContentWrapperHeight('.content-wrapper');
},
},
+ mounted() {
+ // this is required for the component to properly animate
+ // when it is shown with v-if
+ this.mounted = true;
+ },
methods: {
closeDrawer() {
this.$emit('close-drawer');
@@ -71,7 +83,7 @@ export default {
<gl-drawer
:header-height="drawerOffsetTop"
:z-index="300"
- :open="showDrawer"
+ :open="showDrawer && mounted"
@close="closeDrawer"
>
<template #title>
@@ -94,7 +106,7 @@ export default {
data-testid="input-referer"
/>
- <gl-form-group :label="$options.i18n.label">
+ <gl-form-group :label="$options.i18n.label" label-class="gl-text-black-normal">
<gl-form-radio-group
v-model="selected"
:options="$options.categoryOptions"
diff --git a/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue b/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue
new file mode 100644
index 00000000000..02fe131553c
--- /dev/null
+++ b/app/assets/javascripts/abuse_reports/components/links_to_spam_input.vue
@@ -0,0 +1,68 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'LinksToSpamInput',
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ },
+ i18n: {
+ label: s__('ReportAbuse|Link to spam'),
+ description: s__('ReportAbuse|URL of this user posting spam'),
+ addAnotherText: s__('ReportAbuse|Add another link'),
+ },
+ props: {
+ previousLinks: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ links: this.previousLinks.length > 0 ? this.previousLinks : [''],
+ };
+ },
+ methods: {
+ addAnotherInput() {
+ this.links.push('');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <template v-for="(link, index) in links">
+ <div :key="index" class="row">
+ <div class="col-lg-8">
+ <gl-form-group class="gl-mt-5">
+ <template #label>
+ <div class="gl-pb-2">
+ {{ $options.i18n.label }}
+ </div>
+ <div class="gl-font-weight-normal">
+ {{ $options.i18n.description }}
+ </div>
+ </template>
+ <gl-form-input
+ v-model.trim="links[index]"
+ type="url"
+ name="abuse_report[links_to_spam][]"
+ autocomplete="off"
+ />
+ </gl-form-group>
+ </div>
+ </div>
+ </template>
+ <div class="row">
+ <div class="col-lg-8">
+ <gl-button variant="link" icon="plus" class="gl-float-right" @click="addAnotherInput">
+ {{ $options.i18n.addAnotherText }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/abuse_reports/index.js b/app/assets/javascripts/abuse_reports/index.js
new file mode 100644
index 00000000000..fff4ad8daa0
--- /dev/null
+++ b/app/assets/javascripts/abuse_reports/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import LinksToSpamInput from './components/links_to_spam_input.vue';
+
+export const initLinkToSpam = () => {
+ const el = document.getElementById('js-links-to-spam');
+
+ if (!el) return false;
+
+ const { links } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'LinksToSpamRoot',
+ render(createElement) {
+ return createElement(LinksToSpamInput, {
+ props: {
+ previousLinks: JSON.parse(links),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue
index 7cc4a0d349d..8e335dbda32 100644
--- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue
+++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue
@@ -42,7 +42,7 @@ export default {
<gl-collapsible-listbox
v-model="selected"
:items="databases"
- right
+ placement="right"
:toggle-text="selectedDatabase"
toggle-aria-labelled-by="label"
@select="selectDatabase"
diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue
index 8bf5be1afd1..9f42aa27097 100644
--- a/app/assets/javascripts/admin/topics/components/topic_select.vue
+++ b/app/assets/javascripts/admin/topics/components/topic_select.vue
@@ -1,22 +1,14 @@
<script>
-import {
- GlAvatarLabeled,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
-} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__, n__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
GlAvatarLabeled,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
},
props: {
selectedTopic: {
@@ -48,15 +40,13 @@ export default {
return {
topics: [],
search: '',
+ selected: null,
};
},
computed: {
loading() {
return this.$apollo.queries.topics.loading;
},
- isResultEmpty() {
- return this.topics.length === 0;
- },
dropdownText() {
if (Object.keys(this.selectedTopic).length) {
return this.selectedTopic.name;
@@ -64,10 +54,35 @@ export default {
return this.$options.i18n.dropdownText;
},
+ items() {
+ return this.topics.map(({ id, title, name, avatarUrl }) => ({
+ value: id,
+ text: title,
+ secondaryText: name,
+ icon: avatarUrl,
+ }));
+ },
+ searchSummary() {
+ return n__('TopicSelect|%d topic found', 'TopicSelect|%d topics found', this.topics.length);
+ },
+ labelId() {
+ if (!this.labelText) {
+ return null;
+ }
+
+ return uniqueId('topic-listbox-label-');
+ },
},
methods: {
- selectTopic(topic) {
- this.$emit('click', topic);
+ onSelect(topicId) {
+ const topicObj = this.topics.find((topic) => topic.id === topicId);
+
+ if (!topicObj) return;
+
+ this.$emit('click', topicObj);
+ },
+ onSearch(query) {
+ this.search = query;
},
},
i18n: {
@@ -81,26 +96,34 @@ export default {
<template>
<div>
- <label v-if="labelText">{{ labelText }}</label>
- <gl-dropdown block :text="dropdownText">
- <gl-search-box-by-type
- v-model="search"
- :is-loading="loading"
- :placeholder="$options.i18n.searchPlaceholder"
- />
- <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)">
+ <label v-if="labelText" :id="labelId">{{ labelText }}</label>
+ <gl-collapsible-listbox
+ v-model="selected"
+ block
+ searchable
+ is-check-centered
+ :items="items"
+ :toggle-text="dropdownText"
+ :searching="loading"
+ :search-placeholder="$options.i18n.searchPlaceholder"
+ :no-results-text="$options.i18n.emptySearchResult"
+ :toggle-aria-labelled-by="labelId"
+ @select="onSelect"
+ @search="onSearch"
+ >
+ <template #list-item="{ item: { text, secondaryText, icon } }">
<gl-avatar-labeled
- :label="topic.title"
- :sub-label="topic.name"
- :src="topic.avatarUrl"
- :entity-name="topic.name"
+ :label="text"
+ :sub-label="secondaryText"
+ :src="icon"
+ :entity-name="secondaryText"
:size="32"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
- </gl-dropdown-item>
- <gl-dropdown-text v-if="isResultEmpty && !loading">
- <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
+ </template>
+ <template #search-summary-sr-only>
+ {{ searchSummary }}
+ </template>
+ </gl-collapsible-listbox>
</div>
</template>
diff --git a/app/assets/javascripts/airflow/dags/components/dags.vue b/app/assets/javascripts/airflow/dags/components/dags.vue
new file mode 100644
index 00000000000..88eb3fd5aba
--- /dev/null
+++ b/app/assets/javascripts/airflow/dags/components/dags.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlTableLite, GlEmptyState, GlPagination, GlTooltipDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { formatDate } from '~/lib/utils/datetime/date_format_utility';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+
+export default {
+ name: 'AirflowDags',
+ components: {
+ GlTableLite,
+ GlEmptyState,
+ IncubationAlert,
+ GlPagination,
+ TimeAgo,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ dags: {
+ type: Array,
+ required: true,
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ { key: 'dag_name', label: this.$options.i18n.dagLabel },
+ { key: 'schedule', label: this.$options.scheduleLabel },
+ { key: 'next_run', label: this.$options.nextRunLabel },
+ { key: 'is_active', label: this.$options.isActiveLabel },
+ { key: 'is_paused', label: this.$options.isPausedLabel },
+ { key: 'fileloc', label: this.$options.fileLocLabel },
+ ];
+ },
+ hasPagination() {
+ return this.dags.length > 0;
+ },
+ prevPage() {
+ return this.pagination.page > 1 ? this.pagination.page - 1 : null;
+ },
+ nextPage() {
+ return !this.pagination.isLastPage ? this.pagination.page + 1 : null;
+ },
+ emptyState() {
+ return {
+ svgPath: '/assets/illustrations/empty-state/empty-dag-md.svg',
+ };
+ },
+ },
+ methods: {
+ generateLink(page) {
+ return setUrlParams({ page });
+ },
+ formatDate(dateString) {
+ return formatDate(new Date(dateString));
+ },
+ },
+ i18n: {
+ emptyStateLabel: s__('Airflow|There are no DAGs to show'),
+ emptyStateDescription: s__(
+ 'Airflow|Either the Airflow instance does not contain DAGs or has yet to be configured',
+ ),
+ dagLabel: s__('Airflow|DAG'),
+ scheduleLabel: s__('Airflow|Schedule'),
+ nextRunLabel: s__('Airflow|Next run'),
+ isActiveLabel: s__('Airflow|Is active'),
+ isPausedLabel: s__('Airflow|Is paused'),
+ fileLocLabel: s__('Airflow|DAG file location'),
+ featureName: s__('Airflow|GitLab Airflow integration'),
+ },
+ linkToFeedbackIssue:
+ 'https://gitlab.com/gitlab-org/incubation-engineering/airflow/meta/-/issues/2',
+};
+</script>
+
+<template>
+ <div>
+ <incubation-alert
+ :feature-name="$options.i18n.featureName"
+ :link-to-feedback-issue="$options.linkToFeedbackIssue"
+ />
+ <gl-empty-state
+ v-if="!dags.length"
+ :title="$options.i18n.emptyStateLabel"
+ :description="$options.i18n.emptyStateDescription"
+ :svg-path="emptyState.svgPath"
+ />
+ <gl-table-lite v-else :items="dags" :fields="fields" class="gl-mt-0!">
+ <template #cell(next_run)="data">
+ <time-ago v-gl-tooltip.hover :time="data.value" :title="formatDate(data.value)" />
+ </template>
+ </gl-table-lite>
+ <gl-pagination
+ v-if="hasPagination"
+ :value="pagination.page"
+ :prev-page="prevPage"
+ :next-page="nextPage"
+ :total-items="pagination.totalItems"
+ :per-page="pagination.perPage"
+ :link-gen="generateLink"
+ align="center"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
index a0d5cb7f4c3..38bcdef3e04 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
@@ -119,7 +119,10 @@ export default {
</gl-form-group>
<gl-form-group class="gl-pl-0 gl-mb-5">
- <gl-form-checkbox v-model="sendEmailEnabled">
+ <gl-form-checkbox
+ v-model="sendEmailEnabled"
+ data-qa-selector="enable_email_notification_checkbox"
+ >
<span>{{ $options.i18n.sendEmail.label }}</span>
</gl-form-checkbox>
</gl-form-group>
diff --git a/app/assets/javascripts/analytics/shared/components/metric_popover.vue b/app/assets/javascripts/analytics/shared/components/metric_popover.vue
index 8d90e7b2392..373a7fac6f7 100644
--- a/app/assets/javascripts/analytics/shared/components/metric_popover.vue
+++ b/app/assets/javascripts/analytics/shared/components/metric_popover.vue
@@ -1,5 +1,6 @@
<script>
import { GlPopover, GlLink, GlIcon } from '@gitlab/ui';
+import { METRIC_POPOVER_LABEL } from '../constants';
export default {
name: 'MetricPopover',
@@ -19,34 +20,34 @@ export default {
},
},
computed: {
- metricLinks() {
- return this.metric.links?.filter((link) => !link.docs_link) || [];
+ metricLink() {
+ return this.metric.links?.find((link) => !link.docs_link);
},
docsLink() {
return this.metric.links?.find((link) => link.docs_link);
},
},
+ metricPopoverLabel: METRIC_POPOVER_LABEL,
};
</script>
<template>
- <gl-popover :target="target" placement="bottom">
+ <gl-popover :target="target" placement="top">
<template #title>
- <span class="gl-display-block gl-text-left" data-testid="metric-label">{{
- metric.label
- }}</span>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-text-right gl-py-1 gl-align-items-center"
+ >
+ <span data-testid="metric-label">{{ metric.label }}</span>
+ <gl-link
+ v-if="metricLink"
+ :href="metricLink.url"
+ class="gl-font-sm gl-font-weight-normal"
+ data-testid="metric-link"
+ >{{ $options.metricPopoverLabel }}
+ <gl-icon name="chart" />
+ </gl-link>
+ </div>
</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"
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index c62736d55a8..7ced658f483 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -1,5 +1,6 @@
import { masks } from '~/lib/dateformat';
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const DATE_RANGE_LIMIT = 180;
export const PROJECTS_PER_PAGE = 50;
@@ -12,8 +13,103 @@ export const dateFormats = {
month: 'mmmm',
};
-// Some content is duplicated due to backward compatibility.
-// It will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/350614 in 14.9
+export const METRIC_POPOVER_LABEL = s__('ValueStreamAnalytics|View details');
+
+export const KEY_METRICS = {
+ LEAD_TIME: 'lead_time',
+ CYCLE_TIME: 'cycle_time',
+ ISSUES: 'issues',
+ COMMITS: 'commits',
+ DEPLOYS: 'deploys',
+};
+
+export const DORA_METRICS = {
+ DEPLOYMENT_FREQUENCY: 'deployment_frequency',
+ LEAD_TIME_FOR_CHANGES: 'lead_time_for_changes',
+ TIME_TO_RESTORE_SERVICE: 'time_to_restore_service',
+ CHANGE_FAILURE_RATE: 'change_failure_rate',
+};
+
+export const VSA_METRICS_GROUPS = [
+ {
+ key: 'key_metrics',
+ title: s__('ValueStreamAnalytics|Key metrics'),
+ keys: Object.values(KEY_METRICS),
+ },
+ {
+ key: 'dora_metrics',
+ title: s__('ValueStreamAnalytics|DORA metrics'),
+ keys: Object.values(DORA_METRICS),
+ },
+];
+
+export const METRIC_TOOLTIPS = {
+ [DORA_METRICS.DEPLOYMENT_FREQUENCY]: {
+ description: s__(
+ 'ValueStreamAnalytics|Average number of deployments to production per day. This metric measures how often value is delivered to end users.',
+ ),
+ groupLink: '-/analytics/ci_cd?tab=deployment-frequency',
+ projectLink: '-/pipelines/charts?chart=deployment-frequency',
+ docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'deployment-frequency' }),
+ },
+ [DORA_METRICS.LEAD_TIME_FOR_CHANGES]: {
+ description: s__(
+ 'ValueStreamAnalytics|The time to successfully deliver a commit into production. This metric reflects the efficiency of CI/CD pipelines.',
+ ),
+ groupLink: '-/analytics/ci_cd?tab=lead-time',
+ projectLink: '-/pipelines/charts?chart=lead-time',
+ docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'lead-time-for-changes' }),
+ },
+ [DORA_METRICS.TIME_TO_RESTORE_SERVICE]: {
+ description: s__(
+ 'ValueStreamAnalytics|The time it takes an organization to recover from a failure in production.',
+ ),
+ groupLink: '-/analytics/ci_cd?tab=time-to-restore-service',
+ projectLink: '-/pipelines/charts?chart=time-to-restore-service',
+ docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'time-to-restore-service' }),
+ },
+ [DORA_METRICS.CHANGE_FAILURE_RATE]: {
+ description: s__(
+ 'ValueStreamAnalytics|Percentage of deployments that cause an incident in production.',
+ ),
+ groupLink: '-/analytics/ci_cd?tab=change-failure-rate',
+ projectLink: '-/pipelines/charts?chart=change-failure-rate',
+ docsLink: helpPagePath('user/analytics/dora_metrics', { anchor: 'change-failure-rate' }),
+ },
+ [KEY_METRICS.LEAD_TIME]: {
+ description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
+ groupLink: '-/analytics/value_stream_analytics',
+ projectLink: '-/value_stream_analytics',
+ docsLink: helpPagePath('user/analytics/value_stream_analytics', {
+ anchor: 'view-the-lead-time-and-cycle-time-for-issues',
+ }),
+ },
+ [KEY_METRICS.CYCLE_TIME]: {
+ description: s__(
+ "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
+ ),
+ groupLink: '-/analytics/value_stream_analytics',
+ projectLink: '-/value_stream_analytics',
+ docsLink: helpPagePath('user/analytics/value_stream_analytics', {
+ anchor: 'view-the-lead-time-and-cycle-time-for-issues',
+ }),
+ },
+ [KEY_METRICS.ISSUES]: {
+ description: s__('ValueStreamAnalytics|Number of new issues created.'),
+ groupLink: '-/issues_analytics',
+ projectLink: '-/analytics/issues_analytics',
+ docsLink: helpPagePath('user/analytics/issue_analytics'),
+ },
+ [KEY_METRICS.DEPLOYS]: {
+ description: s__('ValueStreamAnalytics|Total number of deploys to production.'),
+ groupLink: '-/analytics/productivity_analytics',
+ projectLink: '-/analytics/merge_request_analytics',
+ docsLink: helpPagePath('user/analytics/merge_request_analytics'),
+ },
+};
+
+// TODO: Remove this once the migration to METRIC_TOOLTIPS is complete
+// https://gitlab.com/gitlab-org/gitlab/-/issues/388067
export const METRICS_POPOVER_CONTENT = {
lead_time: {
description: s__('ValueStreamAnalytics|Median time from issue created to issue closed.'),
@@ -47,19 +143,3 @@ export const METRICS_POPOVER_CONTENT = {
),
},
};
-
-const KEY_METRICS_TITLE = s__('ValueStreamAnalytics|Key metrics');
-const KEY_METRICS_KEYS = ['lead_time', 'cycle_time', 'issues', 'commits', 'deploys'];
-
-const DORA_METRICS_TITLE = s__('ValueStreamAnalytics|DORA metrics');
-const DORA_METRICS_KEYS = [
- 'deployment_frequency',
- 'lead_time_for_changes',
- 'time_to_restore_service',
- 'change_failure_rate',
-];
-
-export const VSA_METRICS_GROUPS = [
- { key: 'key_metrics', title: KEY_METRICS_TITLE, keys: KEY_METRICS_KEYS },
- { key: 'dora_metrics', title: DORA_METRICS_TITLE, keys: DORA_METRICS_KEYS },
-];
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 71b719d1ed2..aafbf642766 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,5 +1,4 @@
import { flatten } from 'lodash';
-import { hideFlash } from '~/flash';
import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
@@ -74,10 +73,8 @@ export const getDataZoomOption = ({
};
export const removeFlash = (type = 'alert') => {
- const flashEl = document.querySelector(`.flash-${type}`);
- if (flashEl) {
- hideFlash(flashEl);
- }
+ // flash-warning don't have dismiss button.
+ document.querySelector(`.flash-${type} .js-close`)?.click();
};
/**
diff --git a/app/assets/javascripts/api/environments_api.js b/app/assets/javascripts/api/environments_api.js
new file mode 100644
index 00000000000..9912b1ab696
--- /dev/null
+++ b/app/assets/javascripts/api/environments_api.js
@@ -0,0 +1,15 @@
+import axios from '../lib/utils/axios_utils';
+import { buildApiUrl } from './api_utils';
+
+export const STOP_STALE_ENVIRONMENTS_PATH = '/api/:version/projects/:id/environments/stop_stale';
+
+export function stopStaleEnvironments(projectId, before, query, options) {
+ const url = buildApiUrl(STOP_STALE_ENVIRONMENTS_PATH).replace(':id', projectId);
+ const defaults = {
+ before: before.toISOString(),
+ };
+
+ return axios.post(url, null, {
+ params: Object.assign(defaults, options),
+ });
+}
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index e859160c2e7..1b216e6f721 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -4,6 +4,8 @@ import { buildApiUrl } from './api_utils';
const GROUP_PATH = '/api/:version/groups/:id';
const GROUPS_PATH = '/api/:version/groups.json';
+const GROUP_MEMBERS_PATH = '/api/:version/groups/:id/members';
+const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations';
@@ -45,3 +47,10 @@ export const getGroupTransferLocations = (groupId, params = {}) => {
return axios.get(url, { params: { ...defaultParams, ...params } });
};
+
+export const getGroupMembers = (groupId, inherited = false) => {
+ const path = inherited ? GROUP_ALL_MEMBERS_PATH : GROUP_MEMBERS_PATH;
+ const url = buildApiUrl(path).replace(':id', groupId);
+
+ return axios.get(url);
+};
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 5b5abbdf50b..5c0d101ef5b 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -3,6 +3,8 @@ import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
const PROJECTS_PATH = '/api/:version/projects.json';
+const PROJECT_MEMBERS_PATH = '/api/:version/projects/:id/members';
+const PROJECT_ALL_MEMBERS_PATH = '/api/:version/projects/:id/members/all';
const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size';
const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations';
@@ -19,6 +21,10 @@ export function getProjects(query, options, callback = () => {}) {
defaults.membership = true;
}
+ if (gon.features.fullPathProjectSearch && query?.includes('/')) {
+ defaults.search_namespaces = true;
+ }
+
return axios
.get(url, {
params: Object.assign(defaults, options),
@@ -50,3 +56,10 @@ export const getTransferLocations = (projectId, params = {}) => {
return axios.get(url, { params: { ...defaultParams, ...params } });
};
+
+export const getProjectMembers = (projectId, inherited = false) => {
+ const path = inherited ? PROJECT_ALL_MEMBERS_PATH : PROJECT_MEMBERS_PATH;
+ const url = buildApiUrl(path).replace(':id', projectId);
+
+ return axios.get(url);
+};
diff --git a/app/assets/javascripts/artifacts/components/app.vue b/app/assets/javascripts/artifacts/components/app.vue
new file mode 100644
index 00000000000..3a07be65341
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/app.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import getBuildArtifactsSizeQuery from '../graphql/queries/get_build_artifacts_size.query.graphql';
+import { PAGE_TITLE, TOTAL_ARTIFACTS_SIZE, SIZE_UNKNOWN } from '../constants';
+import JobArtifactsTable from './job_artifacts_table.vue';
+
+export default {
+ name: 'ArtifactsApp',
+ components: {
+ GlSkeletonLoader,
+ JobArtifactsTable,
+ },
+ inject: ['projectPath'],
+ apollo: {
+ buildArtifactsSize: {
+ query: getBuildArtifactsSizeQuery,
+ variables() {
+ return { projectPath: this.projectPath };
+ },
+ update({
+ project: {
+ statistics: { buildArtifactsSize },
+ },
+ }) {
+ return buildArtifactsSize;
+ },
+ },
+ },
+ data() {
+ return {
+ buildArtifactsSize: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.buildArtifactsSize.loading;
+ },
+ humanReadableArtifactsSize() {
+ return numberToHumanSize(this.buildArtifactsSize);
+ },
+ },
+ i18n: {
+ PAGE_TITLE,
+ TOTAL_ARTIFACTS_SIZE,
+ SIZE_UNKNOWN,
+ },
+};
+</script>
+<template>
+ <div>
+ <h1 class="page-title gl-font-size-h-display gl-mb-0" data-testid="artifacts-page-title">
+ {{ $options.i18n.PAGE_TITLE }}
+ </h1>
+ <div class="gl-mb-6" data-testid="build-artifacts-size">
+ <gl-skeleton-loader v-if="isLoading" :lines="1" />
+ <template v-else>
+ <strong>{{ $options.i18n.TOTAL_ARTIFACTS_SIZE }}</strong>
+ <span v-if="buildArtifactsSize !== null">{{ humanReadableArtifactsSize }}</span>
+ <span v-else>{{ $options.i18n.SIZE_UNKNOWN }}</span>
+ </template>
+ </div>
+ <job-artifacts-table />
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js
index 28fd81fa641..da562b03bf8 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/artifacts/constants.js
@@ -1,5 +1,9 @@
import { __, s__, n__, sprintf } from '~/locale';
+export const PAGE_TITLE = s__('Artifacts|Artifacts');
+export const TOTAL_ARTIFACTS_SIZE = s__('Artifacts|Total artifacts size');
+export const SIZE_UNKNOWN = __('Unknown');
+
export const JOB_STATUS_GROUP_SUCCESS = 'success';
export const STATUS_BADGE_VARIANTS = {
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
new file mode 100644
index 00000000000..23da65ad0bb
--- /dev/null
+++ b/app/assets/javascripts/artifacts/graphql/queries/get_build_artifacts_size.query.graphql
@@ -0,0 +1,8 @@
+query getBuildArtifactsSize($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ statistics {
+ buildArtifactsSize
+ }
+ }
+}
diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
index 89a24d7891e..5737f9f8e8d 100644
--- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
+++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql
@@ -10,13 +10,12 @@ query getJobArtifacts(
project(fullPath: $projectPath) {
id
jobs(
- statuses: [SUCCESS, FAILED]
+ withArtifacts: true
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
before: $prevPageCursor
) {
- count
nodes {
id
name
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js
index e0b2ab2bf47..a62b3daa961 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/artifacts/index.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
-import JobArtifactsTable from './components/job_artifacts_table.vue';
+import App from './components/app.vue';
Vue.use(VueApollo);
@@ -27,6 +27,6 @@ export const initArtifactsTable = () => {
canDestroyArtifacts: parseBoolean(canDestroyArtifacts),
artifactsManagementFeedbackImagePath,
},
- render: (createElement) => createElement(JobArtifactsTable),
+ render: (createElement) => createElement(App),
});
};
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 5bb310afac7..cc524c71c1e 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,22 +1,17 @@
<script>
-import { GlButton, GlBadge } from '@gitlab/ui';
+import { GlBadge } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NoteableNote from '~/notes/components/noteable_note.vue';
-import PublishButton from './publish_button.vue';
export default {
components: {
NoteableNote,
- PublishButton,
- GlButton,
GlBadge,
},
directives: {
SafeHtml,
},
- mixins: [glFeatureFlagMixin()],
props: {
draft: {
type: Object,
@@ -89,8 +84,7 @@ export default {
:note="draft"
:line="line"
:discussion-root="true"
- :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }"
- class="draft-note-component draft-note"
+ class="draft-note-component draft-note gl-mb-0!"
@handleEdit="handleEditing"
@cancelForm="handleNotEditing"
@updateSuccess="handleNotEditing"
@@ -109,23 +103,6 @@ export default {
v-safe-html:[$options.safeHtmlConfig]="draftCommands"
class="referenced-commands draft-note-commands"
></div>
-
- <p v-if="!glFeatures.mrReviewSubmitComment" class="draft-note-actions d-flex">
- <publish-button
- :show-count="true"
- :should-publish="false"
- category="secondary"
- :disabled="isPublishingDraft(draft.id)"
- />
- <gl-button
- :disabled="isPublishing"
- :loading="isPublishingDraft(draft.id)"
- class="gl-ml-3"
- @click="publishNow"
- >
- {{ __('Add comment now') }}
- </gl-button>
- </p>
</template>
</noteable-note>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index ba5cc0d1a76..4ac0c8c4894 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -1,31 +1,37 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
+import { GlIcon, GlDisclosureDropdown, GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import PreviewItem from './preview_item.vue';
import DraftsCount from './drafts_count.vue';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
GlIcon,
PreviewItem,
DraftsCount,
+ GlDisclosureDropdown,
+ GlButton,
},
- mixins: [glFeatureFlagMixin()],
computed: {
...mapState('diffs', ['viewDiffsFileByFile']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
...mapGetters(['getNoteableData']),
+ listItems() {
+ const sortedDraftCount = this.sortedDrafts.length - 1;
+ return this.sortedDrafts.map((item, index) => ({
+ text: item.id.toString(),
+ action: () => {
+ this.onClickDraft(item);
+ },
+ last: index === sortedDraftCount,
+ ...item,
+ }));
+ },
},
methods: {
...mapActions('diffs', ['setCurrentFileHash']),
...mapActions('batchComments', ['scrollToDraft']),
- isLast(index) {
- return index === this.sortedDrafts.length - 1;
- },
isOnLatestDiff(draft) {
return draft.position?.head_sha === this.getNoteableData.diff_head_sha;
},
@@ -47,23 +53,23 @@ export default {
</script>
<template>
- <gl-dropdown
- :header-text="n__('%d pending comment', '%d pending comments', draftsCount)"
- dropup
- data-qa-selector="review_preview_dropdown"
- >
- <template #button-content>
- {{ __('Pending comments') }}
- <drafts-count v-if="glFeatures.mrReviewSubmitComment" variant="neutral" />
- <gl-icon class="dropdown-chevron" name="chevron-up" />
+ <gl-disclosure-dropdown :items="listItems" dropup data-qa-selector="review_preview_dropdown">
+ <template #toggle>
+ <gl-button
+ >{{ __('Pending comments') }} <drafts-count variant="neutral" /><gl-icon
+ class="dropdown-chevron"
+ name="chevron-up"
+ /></gl-button>
+ </template>
+
+ <template #header>
+ <p class="gl-dropdown-header-top">
+ {{ n__('%d pending comment', '%d pending comments', draftsCount) }}
+ </p>
+ </template>
+
+ <template #list-item="{ item }">
+ <preview-item :draft="item" :is-last="item.last" @click="onClickDraft(item)" />
</template>
- <gl-dropdown-item
- v-for="(draft, index) in sortedDrafts"
- :key="draft.id"
- data-testid="preview-item"
- @click="onClickDraft(draft)"
- >
- <preview-item :draft="draft" :is-last="isLast(index)" />
- </gl-dropdown-item>
- </gl-dropdown>
+ </gl-disclosure-dropdown>
</template>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
deleted file mode 100644
index d4fc4ad744a..00000000000
--- a/app/assets/javascripts/batch_comments/components/publish_button.vue
+++ /dev/null
@@ -1,52 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import DraftsCount from './drafts_count.vue';
-
-export default {
- components: {
- GlButton,
- DraftsCount,
- },
- props: {
- showCount: {
- type: Boolean,
- required: false,
- default: false,
- },
- category: {
- type: String,
- required: false,
- default: 'primary',
- },
- variant: {
- type: String,
- required: false,
- default: 'confirm',
- },
- },
- computed: {
- ...mapState('batchComments', ['isPublishing']),
- },
- methods: {
- ...mapActions('batchComments', ['publishReview']),
- onClick() {
- this.publishReview();
- },
- },
-};
-</script>
-
-<template>
- <gl-button
- :loading="isPublishing"
- class="js-publish-draft-button"
- data-qa-selector="submit_review_button"
- :category="category"
- :variant="variant"
- @click="onClick"
- >
- {{ __('Submit review') }}
- <drafts-count v-if="showCount" />
- </gl-button>
-</template>
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 3cd1a2525e9..798ab301c90 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -3,13 +3,11 @@ import { mapActions, mapGetters } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants';
import PreviewDropdown from './preview_dropdown.vue';
-import PublishButton from './publish_button.vue';
import SubmitDropdown from './submit_dropdown.vue';
export default {
components: {
PreviewDropdown,
- PublishButton,
SubmitDropdown,
},
mixins: [glFeatureFlagMixin()],
@@ -42,8 +40,7 @@ export default {
data-qa-selector="review_bar_content"
>
<preview-dropdown />
- <publish-button v-if="!glFeatures.mrReviewSubmitComment" class="gl-ml-3" show-count />
- <submit-dropdown v-else />
+ <submit-dropdown />
</div>
</nav>
</div>
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
index 0c81ae63f21..970864eef74 100644
--- a/app/assets/javascripts/behaviors/copy_code.js
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -8,7 +8,7 @@ class CopyCodeButton extends HTMLElement {
this.for = uniqueId('code-');
const target = this.parentNode.querySelector('pre');
- if (!target) return;
+ if (!target || this.closest('.suggestions')) return;
target.setAttribute('id', this.for);
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
index 220064e6673..1d36661ee63 100644
--- a/app/assets/javascripts/behaviors/index.js
+++ b/app/assets/javascripts/behaviors/index.js
@@ -7,7 +7,6 @@ import { loadStartupCSS } from './load_startup_css';
import initCopyAsGFM from './markdown/copy_as_gfm';
import './quick_submit';
import './requires_input';
-import initSelect2Dropdowns from './select2';
import initPageShortcuts from './shortcuts';
import './toggler_behavior';
import './preview_markdown';
@@ -21,7 +20,6 @@ initCopyToClipboard();
initPageShortcuts();
initCollapseSidebarOnWindowResize();
-initSelect2Dropdowns();
window.requestIdleCallback(
() => {
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index 7852a909160..b1bf6ebcb13 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -1,17 +1,19 @@
+import { escape } from 'lodash';
import { spriteIcon } from '~/lib/utils/common_utils';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
import { unrestrictedPages } from './constants';
-// Renders math using KaTeX in any element with the
-// `js-render-math` class
+// Renders math using KaTeX in an element.
//
-// ### Example Markup
-//
-// <code class="js-render-math"></div>
+// Typically for elements with the `js-render-math` class such as
+// <code class="js-render-math"></code>
//
+// See app/assets/javascripts/behaviors/markdown/render_gfm.js
const MAX_MATH_CHARS = 1000;
+const MAX_MACRO_EXPANSIONS = 1000;
+const MAX_USER_SPECIFIED_EMS = 20;
const MAX_RENDER_TIME_MS = 2000;
// Wait for the browser to reflow the layout. Reflowing SVG takes time.
@@ -69,17 +71,28 @@ class SafeMathRenderer {
codeElement.className = 'code';
codeElement.textContent = el.textContent;
+ codeElement.dataset.mathStyle = el.dataset.mathStyle;
const { parentNode } = el;
parentNode.replaceChild(wrapperElement, el);
+ let message;
+ if (text.length > MAX_MATH_CHARS) {
+ message = sprintf(
+ s__(
+ 'math|This math block exceeds %{maxMathChars} characters, and may cause performance issues on this page.',
+ ),
+ { maxMathChars: MAX_MATH_CHARS },
+ );
+ } else {
+ message = s__('math|Displaying this math block may cause performance issues on this page.');
+ }
+
const html = `
<div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-math-container js-lazy-render-math-container fade show" role="alert">
- ${spriteIcon('warning', 'text-warning-600 s16 gl-alert-icon')}
+ ${spriteIcon('warning', 'gl-text-orange-600 s16 gl-alert-icon')}
<div class="display-flex gl-alert-content">
- <div>${s__(
- 'math|Displaying this math block may cause performance issues on this page',
- )}</div>
+ <div>${message}</div>
<div class="gl-alert-actions">
<button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button>
</div>
@@ -116,8 +129,10 @@ class SafeMathRenderer {
displayContainer.innerHTML = this.katex.renderToString(text, {
displayMode: el.dataset.mathStyle === 'display',
throwOnError: true,
- maxSize: 20,
- maxExpand: 20,
+ maxSize: MAX_USER_SPECIFIED_EMS,
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/111107 for
+ // reasoning behind this value
+ maxExpand: MAX_MACRO_EXPANSIONS,
trust: (context) =>
// this config option restores the KaTeX pre-v0.11.0
// behavior of allowing certain commands and protocols
@@ -127,8 +142,17 @@ class SafeMathRenderer {
});
} catch (e) {
// Don't show a flash for now because it would override an existing flash message
- el.textContent = s__('math|There was an error rendering this math block');
- // el.style.color = '#d00';
+ if (e.message.match(/Too many expansions/)) {
+ // this is controlled by the maxExpand parameter
+ el.textContent = s__('math|Too many expansions. Consider using multiple math blocks.');
+ } else {
+ // According to https://katex.org/docs/error.html, we need to ensure that
+ // the error message is escaped.
+ el.textContent = sprintf(
+ s__('math|There was an error rendering this math block. %{katexMessage}'),
+ { katexMessage: escape(e.message) },
+ );
+ }
el.className = 'katex-error';
}
diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js
deleted file mode 100644
index 1f222d8c1f6..00000000000
--- a/app/assets/javascripts/behaviors/select2.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import $ from 'jquery';
-import { loadCSSFile } from '../lib/utils/css_utils';
-
-export default () => {
- const $select2Elements = $('select.select2');
- if ($select2Elements.length) {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- // eslint-disable-next-line promise/no-nesting
- loadCSSFile(gon.select2_css_path)
- .then(() => {
- $select2Elements.select2({
- width: 'resolve',
- minimumResultsForSearch: 10,
- dropdownAutoWidth: true,
- });
-
- // Close select2 on escape
- $('.js-select2').on('select2-close', () => {
- requestAnimationFrame(() => {
- $('.select2-container-active').removeClass('select2-container-active');
- $(':focus').blur();
- });
- });
- })
- .catch(() => {});
- })
- .catch(() => {});
- }
-};
diff --git a/app/assets/javascripts/behaviors/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts.js
index 7352be0dbd5..12fdb2e2981 100644
--- a/app/assets/javascripts/behaviors/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts.js
@@ -28,7 +28,10 @@ export default function initPageShortcuts() {
// TODO: replace this whitelist with something more automated/maintainable
if (page && !pagesWithCustomShortcuts.includes(page)) {
import(/* webpackChunkName: 'shortcutsBundle' */ './shortcuts/shortcuts')
- .then(({ default: Shortcuts }) => new Shortcuts())
+ .then(({ default: Shortcuts }) => {
+ const shortcuts = new Shortcuts();
+ window.toggleShortcutsHelp = shortcuts.onToggleHelp;
+ })
.catch(() => {});
}
return false;
diff --git a/app/assets/javascripts/blob/components/table_contents.vue b/app/assets/javascripts/blob/components/table_contents.vue
index b3410b94b98..28e81b83713 100644
--- a/app/assets/javascripts/blob/components/table_contents.vue
+++ b/app/assets/javascripts/blob/components/table_contents.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
function getHeaderNumber(el) {
return parseInt(el.tagName.match(/\d+/)[0], 10);
@@ -7,8 +7,7 @@ function getHeaderNumber(el) {
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
},
data() {
return {
@@ -43,33 +42,40 @@ export default {
}
},
methods: {
+ close() {
+ this.$refs.disclosureDropdown?.close();
+ },
generateHeaders() {
+ const BASE_PADDING = 16;
const headers = [...this.blobViewer.querySelectorAll('h1,h2,h3,h4,h5,h6')];
- if (headers.length) {
- const firstHeader = getHeaderNumber(headers[0]);
-
- this.items = headers.map((el) => ({
- text: el.textContent.trim(),
- anchor: el.querySelector('a').getAttribute('id'),
- spacing: Math.max((getHeaderNumber(el) - firstHeader) * 8, 0),
- }));
+ if (headers.length === 0) {
+ return;
}
+
+ const firstHeader = getHeaderNumber(headers[0]);
+
+ this.items = headers.map((el) => ({
+ text: el.textContent.trim(),
+ href: `#${el.querySelector('a').getAttribute('id')}`,
+ extraAttrs: {
+ style: {
+ paddingLeft: `${BASE_PADDING + Math.max((getHeaderNumber(el) - firstHeader) * 8, 0)}px`,
+ },
+ },
+ }));
},
},
};
</script>
<template>
- <gl-dropdown v-if="!isHidden && items.length" icon="list-bulleted" class="gl-mr-2" lazy>
- <gl-dropdown-item v-for="(item, index) in items" :key="index" :href="`#${item.anchor}`">
- <span
- :style="{ 'padding-left': `${item.spacing}px` }"
- class="gl-display-block"
- data-testid="tableContentsLink"
- >
- {{ item.text }}
- </span>
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-disclosure-dropdown
+ v-if="!isHidden && items.length"
+ ref="disclosureDropdown"
+ icon="list-bulleted"
+ class="gl-mr-2"
+ :items="items"
+ @action="close"
+ />
</template>
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index dc1a9cb865a..ade92f2562b 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import NotebookLab from '~/notebook/index.vue';
export default {
@@ -51,7 +52,7 @@ export default {
this.loading = false;
})
.catch((e) => {
- if (e.status !== 200) {
+ if (e.status !== HTTP_STATUS_OK) {
this.loadError = true;
}
this.error = true;
diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js
index 2386508aef5..d81aa05c44e 100644
--- a/app/assets/javascripts/blob/openapi/index.js
+++ b/app/assets/javascripts/blob/openapi/index.js
@@ -1,10 +1,19 @@
import { setAttributes } from '~/lib/utils/dom_utils';
import axios from '~/lib/utils/axios_utils';
+import { getBaseURL, relativePathToAbsolute, joinPaths } from '~/lib/utils/url_utility';
+
+const SANDBOX_FRAME_PATH = '/-/sandbox/swagger';
+
+const getSandboxFrameSrc = () => {
+ const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH);
+ return relativePathToAbsolute(path, getBaseURL());
+};
const createSandbox = () => {
const iframeEl = document.createElement('iframe');
+
setAttributes(iframeEl, {
- src: '/-/sandbox/swagger',
+ src: getSandboxFrameSrc(),
sandbox: 'allow-scripts allow-popups allow-forms',
frameBorder: 0,
width: '100%',
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 8062460f052..3a22b06c72e 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,7 +1,18 @@
import { sortBy, cloneDeep } from 'lodash';
-import { TYPE_BOARD, TYPE_ITERATION, TYPE_MILESTONE, TYPE_USER } from '~/graphql_shared/constants';
+import {
+ TYPENAME_BOARD,
+ TYPENAME_ITERATION,
+ TYPENAME_MILESTONE,
+ TYPENAME_USER,
+} from '~/graphql_shared/constants';
import { isGid, convertToGraphQLId } from '~/graphql_shared/utils';
-import { ListType, MilestoneIDs, AssigneeFilterType, MilestoneFilterType } from './constants';
+import {
+ ListType,
+ MilestoneIDs,
+ AssigneeFilterType,
+ MilestoneFilterType,
+ boardQuery,
+} from './constants';
export function getMilestone() {
return null;
@@ -40,9 +51,7 @@ export function formatListIssues(listIssues) {
const boardItems = {};
const listData = listIssues.nodes.reduce((map, list) => {
- let sortedIssues = list.issues.edges.map((issueNode) => ({
- ...issueNode.node,
- }));
+ let sortedIssues = list.issues.nodes;
if (list.listType !== ListType.closed) {
sortedIssues = sortBy(sortedIssues, 'relativePosition');
}
@@ -82,19 +91,19 @@ export function fullBoardId(boardId) {
if (!boardId) {
return null;
}
- return convertToGraphQLId(TYPE_BOARD, boardId);
+ return convertToGraphQLId(TYPENAME_BOARD, boardId);
}
export function fullIterationId(id) {
- return convertToGraphQLId(TYPE_ITERATION, id);
+ return convertToGraphQLId(TYPENAME_ITERATION, id);
}
export function fullUserId(id) {
- return convertToGraphQLId(TYPE_USER, id);
+ return convertToGraphQLId(TYPENAME_USER, id);
}
export function fullMilestoneId(id) {
- return convertToGraphQLId(TYPE_MILESTONE, id);
+ return convertToGraphQLId(TYPENAME_MILESTONE, id);
}
export function fullLabelId(label) {
@@ -305,6 +314,10 @@ export function transformBoardConfig() {
return '';
}
+export function getBoardQuery(boardType) {
+ return boardQuery[boardType].query;
+}
+
export default {
getMilestone,
formatIssue,
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 970e3509d20..d41fc1e9300 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -11,7 +11,12 @@ export default {
BoardSettingsSidebar,
BoardTopBar,
},
- inject: ['fullBoardId'],
+ inject: ['initialBoardId'],
+ data() {
+ return {
+ boardId: this.initialBoardId,
+ };
+ },
computed: {
...mapGetters(['isSidebarOpen']),
},
@@ -21,13 +26,18 @@ export default {
destroyed() {
window.removeEventListener('popstate', refreshCurrentPage);
},
+ methods: {
+ switchBoard(id) {
+ this.boardId = id;
+ },
+ },
};
</script>
<template>
<div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
- <board-top-bar />
- <board-content :board-id="fullBoardId" />
+ <board-top-bar :board-id="boardId" @switchBoard="switchBoard" />
+ <board-content :board-id="boardId" />
<board-settings-sidebar />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 0c64cbad5b1..3071c1f334e 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -9,7 +9,7 @@ export default {
BoardCardInner,
},
mixins: [Tracking.mixin()],
- inject: ['disabled'],
+ inject: ['disabled', 'isApolloBoard'],
props: {
list: {
type: Object,
@@ -63,6 +63,15 @@ export default {
colorClass() {
return this.isColorful ? 'gl-pl-4 gl-border-l-solid gl-border-4' : '';
},
+ formattedItem() {
+ return this.isApolloBoard
+ ? {
+ ...this.item,
+ assignees: this.item.assignees?.nodes || [],
+ labels: this.item.labels?.nodes || [],
+ }
+ : this.item;
+ },
},
methods: {
...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
@@ -106,7 +115,7 @@ export default {
>
<board-card-inner
:list="list"
- :item="item"
+ :item="formattedItem"
:update-filters="true"
:index="index"
:show-work-item-type-icon="showWorkItemTypeIcon"
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 77df111afc1..88f51c71e06 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -214,7 +214,9 @@ export default {
<template>
<div>
<div class="gl-display-flex" dir="auto">
- <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word">
+ <h4
+ class="board-card-title gl-min-w-0 gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word"
+ >
<issuable-blocked-icon
v-if="item.blocked"
:item="item"
diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
index 706b453e868..f58f7838576 100644
--- a/app/assets/javascripts/boards/components/board_card_move_to_position.vue
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -1,19 +1,17 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDisclosureDropdown } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
-
import Tracking from '~/tracking';
+import {
+ BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS,
+ BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION,
+} from '../constants';
export default {
- i18n: {
- moveToStartText: s__('Boards|Move to start of list'),
- moveToEndText: s__('Boards|Move to end of list'),
- },
+ BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS,
name: 'BoardCardMoveToPosition',
components: {
- GlDropdown,
- GlDropdownItem,
+ GlDisclosureDropdown,
},
mixins: [Tracking.mixin()],
props: {
@@ -96,30 +94,30 @@ export default {
allItemsLoadedInList: !this.listHasNextPage,
});
},
+ selectMoveAction({ text }) {
+ if (text === BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION) {
+ this.moveToStart();
+ } else {
+ this.moveToEnd();
+ }
+ },
},
};
</script>
<template>
- <gl-dropdown
+ <gl-disclosure-dropdown
ref="dropdown"
:key="itemIdentifier"
- icon="ellipsis_v"
- :text="s__('Boards|Move card')"
- :text-sr-only="true"
- class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3"
+ class="move-to-position gl-display-block gl-mb-2 gl-ml-auto gl-mt-n3 gl-mr-n3 js-no-trigger"
category="tertiary"
+ :items="$options.BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS"
+ icon="ellipsis_v"
:tabindex="index"
+ :toggle-text="s__('Boards|Move card')"
+ :text-sr-only="true"
no-caret
- @keydown.esc.native="$emit('hide')"
- >
- <div>
- <gl-dropdown-item @click.stop="moveToStart">
- {{ $options.i18n.moveToStartText }}
- </gl-dropdown-item>
- <gl-dropdown-item @click.stop="moveToEnd">
- {{ $options.i18n.moveToEndText }}
- </gl-dropdown-item>
- </div>
- </gl-dropdown>
+ data-testid="board-move-to-position"
+ @action="selectMoveAction"
+ />
</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index b728b8dd22a..708e1539c6e 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -9,17 +9,17 @@ export default {
BoardListHeader,
BoardList,
},
- inject: {
- boardId: {
- default: '',
- },
- },
+ inject: ['isApolloBoard'],
props: {
list: {
type: Object,
default: () => ({}),
required: false,
},
+ boardId: {
+ type: String,
+ required: true,
+ },
},
computed: {
...mapState(['filterParams', 'highlightedLists']),
@@ -28,7 +28,7 @@ export default {
return this.highlightedLists.includes(this.list.id);
},
listItems() {
- return this.getBoardItemsByList(this.list.id);
+ return this.isApolloBoard ? [] : this.getBoardItemsByList(this.list.id);
},
isListDraggable() {
return isListDraggable(this.list);
@@ -84,7 +84,13 @@ export default {
:class="{ 'board-column-highlighted': highlighted }"
>
<board-list-header :list="list" />
- <board-list ref="board-list" :board-items="listItems" :list="list" />
+ <board-list
+ ref="board-list"
+ :board-id="boardId"
+ :board-items="listItems"
+ :list="list"
+ :filter-params="filterParams"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 92f79e61f14..8a37719eae8 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -116,6 +116,8 @@ export default {
value: this.boardListsToUse,
delay: 100,
delayOnTouchOnly: true,
+ filter: 'input',
+ preventOnFilter: false,
};
return this.canDragColumns ? options : {};
@@ -172,6 +174,7 @@ export default {
v-for="(list, index) in boardListsToUse"
:key="index"
ref="board"
+ :board-id="boardId"
:list="list"
:data-draggable-item-type="$options.draggableItemTypes.list"
:class="{ 'gl-xs-display-none!': addColumnFormVisible }"
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index e6d1e558c37..6227f185eda 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -6,12 +6,13 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow
import { __, sprintf } from '~/locale';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
-import { BoardType, ISSUABLE, INCIDENT, issuableTypes } from '~/boards/constants';
+import { BoardType, ISSUABLE, INCIDENT } from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/issues/constants';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
-import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue';
+import SidebarSeverityWidget from '~/sidebar/components/severity/sidebar_severity_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 '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue';
@@ -30,7 +31,7 @@ export default {
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
SidebarTodoWidget,
- SidebarSeverity,
+ SidebarSeverityWidget,
MountingPortal,
SidebarHealthStatusWidget: () =>
import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue'),
@@ -66,7 +67,7 @@ export default {
default: false,
},
issuableType: {
- default: issuableTypes.issue,
+ default: TYPE_ISSUE,
},
isGroupBoard: {
default: false,
@@ -174,7 +175,7 @@ export default {
/>
</template>
<template #default>
- <board-sidebar-title />
+ <board-sidebar-title data-testid="sidebar-title" />
<sidebar-assignees-widget
:iid="activeBoardItem.iid"
:full-path="fullPath"
@@ -237,7 +238,7 @@ export default {
>
{{ __('None') }}
</sidebar-labels-widget>
- <sidebar-severity
+ <sidebar-severity-widget
v-if="isIncidentSidebar"
:iid="activeBoardItem.iid"
:project-path="fullPath"
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
index ce86a4d3123..1bc5d910561 100644
--- a/app/assets/javascripts/boards/components/board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -327,7 +327,6 @@ export default {
if (Array.isArray(value)) {
return value.map((valueItem) => encodeURIComponent(valueItem));
}
-
return encodeURIComponent(value);
}
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 060a708a22f..6f2b35f5191 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -8,7 +8,13 @@ import { sortableStart, sortableEnd } from '~/sortable/utils';
import Tracking from '~/tracking';
import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
-import { toggleFormEventPrefix, DraggableItemTypes } from '../constants';
+import {
+ DEFAULT_BOARD_LIST_ITEMS_SIZE,
+ toggleFormEventPrefix,
+ DraggableItemTypes,
+ listIssuablesQueries,
+ ListType,
+} from 'ee_else_ce/boards/constants';
import eventHub from '../eventhub';
import BoardCard from './board_card.vue';
import BoardNewIssue from './board_new_issue.vue';
@@ -31,12 +37,24 @@ export default {
BoardCardMoveToPosition,
},
mixins: [Tracking.mixin()],
- inject: ['isEpicBoard', 'disabled'],
+ inject: [
+ 'isEpicBoard',
+ 'isGroupBoard',
+ 'disabled',
+ 'fullPath',
+ 'boardType',
+ 'issuableType',
+ 'isApolloBoard',
+ ],
props: {
list: {
type: Object,
required: true,
},
+ boardId: {
+ type: String,
+ required: true,
+ },
boardItems: {
type: Array,
required: true,
@@ -48,6 +66,8 @@ export default {
showCount: false,
showIssueForm: false,
showEpicForm: false,
+ currentList: null,
+ isLoadingMore: false,
};
},
apollo: {
@@ -66,15 +86,50 @@ export default {
return this.isEpicBoard;
},
},
+ currentList: {
+ query() {
+ return listIssuablesQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ id: this.list.id,
+ ...this.listQueryVariables,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard || this.list.collapsed;
+ },
+ update(data) {
+ return data[this.boardType].board.lists.nodes[0];
+ },
+ context: {
+ isSingleRequest: true,
+ },
+ },
},
computed: {
...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']),
+ boardListItems() {
+ return this.isApolloBoard
+ ? this.currentList?.[`${this.issuableType}s`].nodes || []
+ : this.boardItems;
+ },
+ listQueryVariables() {
+ return {
+ fullPath: this.fullPath,
+ boardId: this.boardId,
+ filters: this.filterParams,
+ isGroup: this.isGroupBoard,
+ isProject: !this.isGroupBoard,
+ first: DEFAULT_BOARD_LIST_ITEMS_SIZE,
+ };
+ },
listItemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
},
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} %{issuableType}'), {
- pageSize: this.boardItems.length,
+ pageSize: this.boardListItems.length,
total: this.listItemsCount,
issuableType: this.isEpicBoard ? 'epics' : 'issues',
});
@@ -86,13 +141,17 @@ export default {
return this.list.maxIssueCount > 0 && this.listItemsCount > this.list.maxIssueCount;
},
hasNextPage() {
- return this.pageInfoByListId[this.list.id]?.hasNextPage;
+ return this.isApolloBoard
+ ? this.currentList?.[`${this.issuableType}s`].pageInfo?.hasNextPage
+ : this.pageInfoByListId[this.list.id]?.hasNextPage;
},
loading() {
- return this.listsFlags[this.list.id]?.isLoading;
+ return this.isApolloBoard
+ ? this.$apollo.queries.currentList.loading && !this.isLoadingMore
+ : this.listsFlags[this.list.id]?.isLoading;
},
loadingMore() {
- return this.listsFlags[this.list.id]?.isLoadingMore;
+ return this.isApolloBoard ? this.isLoadingMore : this.listsFlags[this.list.id]?.isLoadingMore;
},
epicCreateFormVisible() {
return this.isEpicBoard && this.list.listType !== 'closed' && this.showEpicForm;
@@ -105,7 +164,7 @@ export default {
return this.canMoveIssue ? this.$refs.list.$el : this.$refs.list;
},
showingAllItems() {
- return this.boardItems.length === this.listItemsCount;
+ return this.boardListItems.length === this.listItemsCount;
},
showingAllItemsText() {
return this.isEpicBoard
@@ -128,7 +187,7 @@ export default {
tag: 'ul',
'ghost-class': 'board-card-drag-active',
'data-list-id': this.list.id,
- value: this.boardItems,
+ value: this.boardListItems,
delay: 100,
delayOnTouchOnly: true,
};
@@ -138,9 +197,12 @@ export default {
disableScrollingWhenMutationInProgress() {
return this.hasNextPage && this.isUpdateIssueOrderInProgress;
},
+ showMoveToPosition() {
+ return !this.disabled && this.list.listType !== ListType.closed;
+ },
},
watch: {
- boardItems() {
+ boardListItems() {
this.$nextTick(() => {
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
});
@@ -165,10 +227,10 @@ export default {
methods: {
...mapActions(['fetchItemsForList', 'moveItem']),
listHeight() {
- return this.listRef.getBoundingClientRect().height;
+ return this.listRef?.getBoundingClientRect()?.height || 0;
},
scrollHeight() {
- return this.listRef.scrollHeight;
+ return this.listRef?.scrollHeight || 0;
},
scrollTop() {
return this.listRef.scrollTop + this.listHeight();
@@ -176,8 +238,20 @@ export default {
scrollToTop() {
this.listRef.scrollTop = 0;
},
- loadNextPage() {
- this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
+ async loadNextPage() {
+ if (this.isApolloBoard) {
+ this.isLoadingMore = true;
+ await this.$apollo.queries.currentList.fetchMore({
+ variables: {
+ ...this.listQueryVariables,
+ id: this.list.id,
+ after: this.currentList?.[`${this.issuableType}s`].pageInfo.endCursor,
+ },
+ });
+ this.isLoadingMore = false;
+ } else {
+ this.fetchItemsForList({ listId: this.list.id, fetchNext: true });
+ }
},
toggleForm() {
if (this.isEpicBoard) {
@@ -292,7 +366,7 @@ export default {
:data-board="list.id"
:data-board-type="list.listType"
:class="{
- 'bg-danger-100': boardItemsSizeExceedsMax,
+ 'gl-bg-red-100 gl-rounded-bottom-left-base gl-rounded-bottom-right-base': boardItemsSizeExceedsMax,
'gl-overflow-hidden': disableScrollingWhenMutationInProgress,
'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress,
}"
@@ -303,7 +377,7 @@ export default {
@end="handleDragOnEnd"
>
<board-card
- v-for="(item, index) in boardItems"
+ v-for="(item, index) in boardListItems"
ref="issue"
:key="item.id"
:index="index"
@@ -312,13 +386,12 @@ export default {
:data-draggable-item-type="$options.draggableItemTypes.card"
:show-work-item-type-icon="!isEpicBoard"
>
- <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved -->
<board-card-move-to-position
- v-if="!isEpicBoard && !disabled"
+ v-if="showMoveToPosition"
:item="item"
:index="index"
:list="list"
- :list-items-length="boardItems.length"
+ :list-items-length="boardListItems.length"
/>
</board-card>
<gl-intersection-observer @appear="onReachingListBottom">
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 14dff8de70f..749fae0c426 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -125,7 +125,7 @@ export default {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
},
chevronIcon() {
- return this.list.collapsed ? 'chevron-right' : 'chevron-down';
+ return this.list.collapsed ? 'chevron-lg-right' : 'chevron-lg-down';
},
isNewIssueShown() {
return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard;
@@ -135,7 +135,9 @@ export default {
},
isSettingsShown() {
return (
- this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
+ this.listType !== ListType.backlog &&
+ this.listType !== ListType.closed &&
+ !this.list.collapsed
);
},
uniqueKey() {
@@ -321,6 +323,7 @@ export default {
v-if="listType !== 'label'"
v-gl-tooltip.hover
:class="{
+ 'gl-text-gray-500': list.collapsed,
'gl-display-block': list.collapsed || listType === 'milestone',
}"
:title="listTitle"
@@ -376,7 +379,7 @@ export default {
<!-- EE end -->
<div
- class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary"
+ class="gl-font-sm issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary"
data-testid="issue-count-badge"
:class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader,
@@ -386,7 +389,7 @@ export default {
<span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }">
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
- <gl-icon class="gl-mr-2" :name="countIcon" :size="16" />
+ <gl-icon class="gl-mr-2" :name="countIcon" :size="14" />
<item-count
v-if="!isLoading"
:items-size="isEpicBoard ? list.epicsCount : boardList.issuesCount"
@@ -397,7 +400,7 @@ export default {
<template v-if="canShowTotalWeight">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
<span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3" data-testid="weight">
- <gl-icon class="gl-mr-2" name="weight" />
+ <gl-icon class="gl-mr-2" name="weight" :size="14" />
{{ totalWeight }}
</span>
</template>
@@ -413,6 +416,7 @@ export default {
:aria-label="$options.i18n.newIssue"
:title="$options.i18n.newIssue"
class="no-drag"
+ size="small"
icon="plus"
@click="showNewIssueForm"
/>
@@ -424,6 +428,7 @@ export default {
:aria-label="$options.i18n.newEpic"
:title="$options.i18n.newEpic"
class="no-drag"
+ size="small"
icon="plus"
@click="showNewEpicForm"
/>
@@ -434,6 +439,7 @@ export default {
v-gl-tooltip.hover
:aria-label="$options.i18n.listSettings"
class="no-drag"
+ size="small"
:title="$options.i18n.listSettings"
icon="settings"
@click="openSidebarSettings"
diff --git a/app/assets/javascripts/boards/components/board_top_bar.vue b/app/assets/javascripts/boards/components/board_top_bar.vue
index 368feba9a44..2e20ed70bb0 100644
--- a/app/assets/javascripts/boards/components/board_top_bar.vue
+++ b/app/assets/javascripts/boards/components/board_top_bar.vue
@@ -2,6 +2,7 @@
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
import IssueBoardFilteredSearch from 'ee_else_ce/boards/components/issue_board_filtered_search.vue';
+import { getBoardQuery } from 'ee_else_ce/boards/boards_util';
import ConfigToggle from './config_toggle.vue';
import NewBoardButton from './new_board_button.vue';
import ToggleFocus from './toggle_focus.vue';
@@ -19,7 +20,46 @@ export default {
EpicBoardFilteredSearch: () =>
import('ee_component/boards/components/epic_filtered_search.vue'),
},
- inject: ['swimlanesFeatureAvailable', 'canAdminList', 'isSignedIn', 'isIssueBoard'],
+ inject: [
+ 'swimlanesFeatureAvailable',
+ 'canAdminList',
+ 'isSignedIn',
+ 'isIssueBoard',
+ 'fullPath',
+ 'boardType',
+ 'isEpicBoard',
+ 'isApolloBoard',
+ ],
+ props: {
+ boardId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ board: {},
+ };
+ },
+ apollo: {
+ board: {
+ query() {
+ return getBoardQuery(this.boardType, this.isEpicBoard);
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ boardId: this.boardId,
+ };
+ },
+ skip() {
+ return !this.isApolloBoard;
+ },
+ update(data) {
+ return data.workspace.board;
+ },
+ },
+ },
};
</script>
@@ -31,7 +71,7 @@ export default {
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-flex-grow-1 gl-lg-mb-0 gl-mb-3 gl-w-full"
>
- <boards-selector />
+ <boards-selector :board-apollo="board" @switchBoard="$emit('switchBoard', $event)" />
<new-board-button />
<issue-board-filtered-search v-if="isIssueBoard" />
<epic-board-filtered-search v-else />
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index d26aeb69dd5..a1a49386b37 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -51,6 +51,7 @@ export default {
'weights',
'boardType',
'isGroupBoard',
+ 'isApolloBoard',
],
props: {
throttleDuration: {
@@ -58,15 +59,20 @@ export default {
default: 200,
required: false,
},
+ boardApollo: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
hasScrollFade: false,
- loadingBoards: 0,
- loadingRecentBoards: false,
scrollFadeInitialized: false,
boards: [],
recentBoards: [],
+ loadingBoards: false,
+ loadingRecentBoards: false,
throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
contentClientHeight: 0,
maxPosition: 0,
@@ -77,11 +83,14 @@ export default {
computed: {
...mapState(['board', 'isBoardLoading']),
+ boardToUse() {
+ return this.isApolloBoard ? this.boardApollo : this.board;
+ },
parentType() {
return this.boardType;
},
loading() {
- return this.loadingRecentBoards || Boolean(this.loadingBoards);
+ return this.loadingRecentBoards || this.loadingBoards;
},
filteredBoards() {
return this.boards.filter((board) =>
@@ -94,6 +103,9 @@ export default {
showDelete() {
return this.boards.length > 1;
},
+ showDropdown() {
+ return this.showCreate || this.hasMissingBoards;
+ },
scrollFadeClass() {
return {
'fade-out': !this.hasScrollFade,
@@ -116,7 +128,7 @@ export default {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
- board(newBoard) {
+ boardToUse(newBoard) {
document.title = newBoard.name;
},
},
@@ -159,8 +171,10 @@ export default {
return { fullPath: this.fullPath };
},
query: this.boardQuery,
- loadingKey: 'loadingBoards',
update: (data) => this.boardUpdate(data, 'boards'),
+ watchLoading: (isLoading) => {
+ this.loadingBoards = isLoading;
+ },
});
this.loadRecentBoards();
@@ -171,8 +185,10 @@ export default {
return { fullPath: this.fullPath };
},
query: this.recentBoardsQuery,
- loadingKey: 'loadingRecentBoards',
update: (data) => this.boardUpdate(data, 'recentIssueBoards'),
+ watchLoading: (isLoading) => {
+ this.loadingRecentBoards = isLoading;
+ },
});
},
isScrolledUp() {
@@ -210,9 +226,14 @@ export default {
boardType: this.boardType,
});
},
+ fullBoardId(boardId) {
+ return fullBoardId(boardId);
+ },
async switchBoard(boardId, e) {
if (isMetaKey(e)) {
window.open(`${this.boardBaseUrl}/${boardId}`, '_blank');
+ } else if (this.isApolloBoard) {
+ this.$emit('switchBoard', this.fullBoardId(boardId));
} else {
this.unsetActiveId();
this.fetchCurrentBoard(boardId);
@@ -230,12 +251,13 @@ export default {
<div class="boards-switcher gl-mr-3" data-testid="boards-selector">
<span class="boards-selector-wrapper">
<gl-dropdown
+ v-if="showDropdown"
data-testid="boards-dropdown"
data-qa-selector="boards_dropdown"
toggle-class="dropdown-menu-toggle"
menu-class="flex-column dropdown-extended-height"
:loading="isBoardLoading"
- :text="board.name"
+ :text="boardToUse.name"
@show="loadBoards"
>
<p class="gl-dropdown-header-top" @mousedown.prevent>
@@ -333,7 +355,7 @@ export default {
:can-admin-board="canAdminBoard"
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
- :current-board="board"
+ :current-board="boardToUse"
: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 38a171e8889..7749391ec6f 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -7,7 +7,7 @@ import BoardFilteredSearch from 'ee_else_ce/boards/components/board_filtered_sea
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
import issueBoardFilters from '~/boards/issue_board_filters';
-import { TYPE_USER } from '~/graphql_shared/constants';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import {
@@ -181,7 +181,7 @@ export default {
return gon?.current_user_id
? [
{
- id: convertToGraphQLId(TYPE_USER, gon.current_user_id),
+ id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id),
name: gon.current_user_fullname,
username: gon.current_username,
avatarUrl: gon.current_user_avatar_url,
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index b09b1d48ca5..c3f7c7d3ca2 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -102,7 +102,7 @@ export default {
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span>
<br />
- <span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
+ <span :class="{ 'gl-text-red-300': isPastDue }">{{ title }}</span>
</gl-tooltip>
</span>
</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 53e574e9942..43a2b13b81c 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { GlAlert, GlButton, GlForm, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { joinPaths } from '~/lib/utils/url_utility';
@@ -13,6 +13,7 @@ export default {
GlButton,
GlFormGroup,
GlFormInput,
+ GlLink,
BoardEditableItem,
},
directives: {
@@ -130,7 +131,11 @@ export default {
@off-click="handleOffClick"
>
<template #title>
- <span data-testid="item-title">{{ item.title }}</span>
+ <span data-testid="item-title">
+ <gl-link class="gl-reset-color gl-hover-text-blue-800" :href="item.webUrl">
+ {{ item.title }}
+ </gl-link>
+ </span>
</template>
<template #collapsed>
<span class="gl-text-gray-800">{{ item.referencePath }}</span>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 91b7f5004ad..712e3e1ac4a 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,5 +1,6 @@
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
-import { __ } from '~/locale';
+import { TYPE_ISSUE } from '~/issues/constants';
+import { s__, __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
@@ -7,6 +8,9 @@ import updateBoardListMutation from './graphql/board_list_update.mutation.graphq
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
+import groupBoardQuery from './graphql/group_board.query.graphql';
+import projectBoardQuery from './graphql/project_board.query.graphql';
+import listIssuesQuery from './graphql/lists_issues.query.graphql';
/* eslint-disable-next-line @gitlab/require-i18n-strings */
export const AssigneeIdParamValues = ['Any', 'None'];
@@ -59,26 +63,35 @@ export const INCIDENT = 'INCIDENT';
export const flashAnimationDuration = 2000;
+export const boardQuery = {
+ [BoardType.group]: {
+ query: groupBoardQuery,
+ },
+ [BoardType.project]: {
+ query: projectBoardQuery,
+ },
+};
+
export const listsQuery = {
- [issuableTypes.issue]: {
+ [TYPE_ISSUE]: {
query: boardListsQuery,
},
};
export const updateListQueries = {
- [issuableTypes.issue]: {
+ [TYPE_ISSUE]: {
mutation: updateBoardListMutation,
},
};
export const deleteListQueries = {
- [issuableTypes.issue]: {
+ [TYPE_ISSUE]: {
mutation: destroyBoardListMutation,
},
};
export const titleQueries = {
- [issuableTypes.issue]: {
+ [TYPE_ISSUE]: {
mutation: issueSetTitleMutation,
},
[issuableTypes.epic]: {
@@ -87,7 +100,7 @@ export const titleQueries = {
};
export const subscriptionQueries = {
- [issuableTypes.issue]: {
+ [TYPE_ISSUE]: {
mutation: issueSetSubscriptionMutation,
},
[issuableTypes.epic]: {
@@ -95,8 +108,14 @@ export const subscriptionQueries = {
},
};
+export const listIssuablesQueries = {
+ [TYPE_ISSUE]: {
+ query: listIssuesQuery,
+ },
+};
+
export const FilterFields = {
- [issuableTypes.issue]: [
+ [TYPE_ISSUE]: [
'assigneeUsername',
'assigneeWildcardId',
'authorUsername',
@@ -141,3 +160,21 @@ export default {
};
export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10;
+
+export const BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION = s__('Boards|Move to start of list');
+export const BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION = s__('Boards|Move to end of list');
+
+/**
+ * Actions are stubbed in order to pass validation
+ * for GlDisclosureDropdown items property
+ */
+export const BOARD_CARD_MOVE_TO_POSITIONS_OPTIONS = [
+ {
+ text: BOARD_CARD_MOVE_TO_POSITIONS_START_OPTION,
+ action: () => {},
+ },
+ {
+ text: BOARD_CARD_MOVE_TO_POSITIONS_END_OPTION,
+ action: () => {},
+ },
+];
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index ae6394f9a2f..0b9e416d408 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -19,10 +19,8 @@ query BoardListsEE(
id
listType
issues(first: $first, filters: $filters, after: $after) {
- edges {
- node {
- ...Issue
- }
+ nodes {
+ ...Issue
}
pageInfo {
endCursor
@@ -42,10 +40,8 @@ query BoardListsEE(
id
listType
issues(first: $first, filters: $filters, after: $after) {
- edges {
- node {
- ...Issue
- }
+ nodes {
+ ...Issue
}
pageInfo {
endCursor
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 968832a092d..4c6f341828c 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -3,8 +3,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
-import { BoardType, issuableTypes } from '~/boards/constants';
+import { BoardType } from '~/boards/constants';
import store from '~/boards/stores';
+import { TYPE_ISSUE } from '~/issues/constants';
import {
NavigationType,
isLoggedIn,
@@ -24,6 +25,7 @@ const apolloProvider = new VueApollo({
function mountBoardApp(el) {
const { boardId, groupId, fullPath, rootPath } = el.dataset;
+ const isApolloBoard = window.gon?.features?.apolloBoards;
const rawFilterParams = queryToObject(window.location.search, { gatherArrays: true });
@@ -33,20 +35,22 @@ function mountBoardApp(el) {
const boardType = el.dataset.parent;
- store.dispatch('fetchBoard', {
- fullPath,
- fullBoardId: fullBoardId(boardId),
- boardType,
- });
+ if (!isApolloBoard) {
+ store.dispatch('fetchBoard', {
+ fullPath,
+ fullBoardId: fullBoardId(boardId),
+ boardType,
+ });
- store.dispatch('setInitialBoardData', {
- boardId,
- fullBoardId: fullBoardId(boardId),
- fullPath,
- boardType,
- disabled: parseBoolean(el.dataset.disabled) || true,
- issuableType: issuableTypes.issue,
- });
+ store.dispatch('setInitialBoardData', {
+ boardId,
+ fullBoardId: fullBoardId(boardId),
+ fullPath,
+ boardType,
+ disabled: parseBoolean(el.dataset.disabled) || true,
+ issuableType: TYPE_ISSUE,
+ });
+ }
// eslint-disable-next-line no-new
new Vue({
@@ -55,8 +59,8 @@ function mountBoardApp(el) {
store,
apolloProvider,
provide: {
- isApolloBoard: window.gon?.features?.apolloBoards,
- fullBoardId: fullBoardId(boardId),
+ isApolloBoard,
+ initialBoardId: fullBoardId(boardId),
disabled: parseBoolean(el.dataset.disabled),
groupId: Number(groupId),
rootPath,
@@ -72,7 +76,7 @@ function mountBoardApp(el) {
labelsFilterBasePath: el.dataset.labelsFilterBasePath,
releasesFetchPath: el.dataset.releasesFetchPath,
timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours),
- issuableType: issuableTypes.issue,
+ issuableType: TYPE_ISSUE,
emailsDisabled: parseBoolean(el.dataset.emailsDisabled),
hasMissingBoards: parseBoolean(el.dataset.hasMissingBoards),
weights: el.dataset.weights ? JSON.parse(el.dataset.weights) : [],
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 07b127d86e2..1b4e6334723 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -11,7 +11,6 @@ import {
deleteListQueries,
listsQuery,
updateListQueries,
- issuableTypes,
FilterFields,
ListTypeTitles,
DraggableItemTypes,
@@ -35,6 +34,7 @@ import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_defe
import { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
+import { TYPE_ISSUE } from '~/issues/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -138,7 +138,7 @@ export default {
fullPath,
boardId: fullBoardId,
filters: filterParams,
- ...(issuableType === issuableTypes.issue && {
+ ...(issuableType === TYPE_ISSUE && {
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
}),
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index 9e746f1a1b8..0ad71165996 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,5 +1,6 @@
import { find } from 'lodash';
-import { inactiveId, issuableTypes } from '../constants';
+import { TYPE_ISSUE } from '~/issues/constants';
+import { inactiveId } from '../constants';
export default {
isSidebarOpen: (state) => state.activeId !== inactiveId,
@@ -43,7 +44,7 @@ export default {
},
isIssueBoard: (state) => {
- return state.issuableType === issuableTypes.issue;
+ return state.issuableType === TYPE_ISSUE;
},
isEpicBoard: () => {
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 44abb2030c7..fef5862f319 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -15,9 +15,11 @@ const updateListItemsCount = ({ state, listId, value }) => {
}
};
-export const removeItemFromList = ({ state, listId, itemId }) => {
+export const removeItemFromList = ({ state, listId, itemId, reordering = false }) => {
Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId));
- updateListItemsCount({ state, listId, value: -1 });
+ if (!reordering) {
+ updateListItemsCount({ state, listId, value: -1 });
+ }
};
export const addItemToList = ({
@@ -28,6 +30,7 @@ export const addItemToList = ({
moveAfterId,
atIndex,
positionInList,
+ reordering = false,
}) => {
const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
@@ -41,7 +44,9 @@ export const addItemToList = ({
}
listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues);
- updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 });
+ if (!reordering) {
+ updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 });
+ }
};
export default {
diff --git a/app/assets/javascripts/branches/branch_sort_dropdown.js b/app/assets/javascripts/branches/branch_sort_dropdown.js
index 9914ce05a95..9ea1331d563 100644
--- a/app/assets/javascripts/branches/branch_sort_dropdown.js
+++ b/app/assets/javascripts/branches/branch_sort_dropdown.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import SortDropdown from './components/sort_dropdown.vue';
const mountDropdownApp = (el) => {
- const { mode, projectBranchesFilteredPath, sortOptions } = el.dataset;
+ const { projectBranchesFilteredPath, sortOptions, showDropdown, sortedBy } = el.dataset;
return new Vue({
el,
@@ -11,9 +12,10 @@ const mountDropdownApp = (el) => {
SortDropdown,
},
provide: {
- mode,
projectBranchesFilteredPath,
sortOptions: JSON.parse(sortOptions),
+ showDropdown: parseBoolean(showDropdown),
+ sortedBy,
},
render: (createElement) => createElement(SortDropdown),
});
diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue
index 263efcaa788..99c82fc9a5a 100644
--- a/app/assets/javascripts/branches/components/sort_dropdown.vue
+++ b/app/assets/javascripts/branches/components/sort_dropdown.vue
@@ -1,10 +1,8 @@
<script>
import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui';
-import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility';
+import { mergeUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-const OVERVIEW_MODE = 'overview';
-
export default {
i18n: {
searchPlaceholder: s__('Branches|Filter by branch name'),
@@ -13,17 +11,20 @@ export default {
GlCollapsibleListbox,
GlSearchBoxByClick,
},
- inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'],
+ // external parameters
+ inject: [
+ 'projectBranchesFilteredPath',
+ 'sortOptions', // dropdown choices (value, text) pairs
+ 'showDropdown', // if not set, only text filter is shown
+ 'sortedBy', // (required) value of choice to sort by
+ ],
+ // own attributes, also in created()
data() {
return {
- selectedKey: 'updated_desc',
searchTerm: '',
};
},
computed: {
- shouldShowDropdown() {
- return this.mode !== OVERVIEW_MODE;
- },
selectedSortMethodName() {
return this.sortOptions[this.selectedKey];
},
@@ -31,26 +32,16 @@ export default {
return Object.entries(this.sortOptions).map(([value, text]) => ({ value, text }));
},
},
+ // contructor or initialization function
created() {
- const sortValue = getParameterValues('sort');
- const searchValue = getParameterValues('search');
-
- if (sortValue.length > 0) {
- [this.selectedKey] = sortValue;
- }
-
- if (searchValue.length > 0) {
- [this.searchTerm] = searchValue;
- }
+ this.selectedKey = this.sortedBy;
},
methods: {
visitUrlFromOption(sortKey) {
this.selectedKey = sortKey;
const urlParams = {};
- if (this.mode !== OVERVIEW_MODE) {
- urlParams.sort = sortKey;
- }
+ urlParams.sort = sortKey;
urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null;
@@ -71,7 +62,7 @@ export default {
/>
<gl-collapsible-listbox
- v-if="shouldShowDropdown"
+ v-if="showDropdown"
v-model="selectedKey"
:items="listboxItems"
:toggle-text="selectedSortMethodName"
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
index 4466a6a8081..9c79adffdae 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_group_variables.vue
@@ -1,12 +1,8 @@
<script>
+import { TYPENAME_GROUP } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import {
- ADD_MUTATION_ACTION,
- DELETE_MUTATION_ACTION,
- GRAPHQL_GROUP_TYPE,
- UPDATE_MUTATION_ACTION,
-} from '../constants';
+import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
@@ -24,7 +20,7 @@ export default {
return this.glFeatures.groupScopedCiVariables;
},
graphqlId() {
- return convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId);
+ return convertToGraphQLId(TYPENAME_GROUP, this.groupId);
},
},
mutationData: {
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
index 6326940148a..43938e9b88f 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_project_variables.vue
@@ -1,12 +1,8 @@
<script>
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import {
- ADD_MUTATION_ACTION,
- DELETE_MUTATION_ACTION,
- GRAPHQL_PROJECT_TYPE,
- UPDATE_MUTATION_ACTION,
-} from '../constants';
+import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, UPDATE_MUTATION_ACTION } from '../constants';
import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql';
@@ -22,7 +18,7 @@ export default {
inject: ['projectFullPath', 'projectId'],
computed: {
graphqlId() {
- return convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId);
+ return convertToGraphQLId(TYPENAME_PROJECT, this.projectId);
},
},
mutationData: {
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
index 967125c7b0a..16034cce381 100644
--- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue
@@ -14,9 +14,11 @@ import {
GlModal,
GlSprintf,
} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { getCookie, setCookie } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
allEnvironments,
@@ -31,6 +33,7 @@ import {
EVENT_ACTION,
EXPANDED_VARIABLES_NOTE,
EDIT_VARIABLE_ACTION,
+ FLAG_LINK_TITLE,
VARIABLE_ACTIONS,
variableOptions,
} from '../constants';
@@ -41,13 +44,6 @@ import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
export default {
- modalId: ADD_CI_VARIABLE_MODAL_ID,
- tokens: awsTokens,
- tokenList: awsTokenList,
- awsTipMessage: AWS_TIP_MESSAGE,
- containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
- environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
- expandedVariablesNote: EXPANDED_VARIABLES_NOTE,
components: {
CiEnvironmentsDropdown,
GlAlert,
@@ -64,7 +60,7 @@ export default {
GlModal,
GlSprintf,
},
- mixins: [trackingMixin],
+ mixins: [glFeatureFlagsMixin(), trackingMixin],
inject: [
'awsLogoSvgPath',
'awsTipCommandsLink',
@@ -74,8 +70,8 @@ export default {
'environmentScopeLink',
'isProtectedByDefault',
'maskedEnvironmentVariablesLink',
+ 'maskableRawRegex',
'maskableRegex',
- 'protectedEnvironmentVariablesLink',
],
props: {
areScopedVariablesAvailable: {
@@ -121,7 +117,7 @@ export default {
},
computed: {
canMask() {
- const regex = RegExp(this.maskableRegex);
+ const regex = RegExp(this.useRawMaskableRegexp ? this.maskableRawRegex : this.maskableRegex);
return regex.test(this.variable.value);
},
canSubmit() {
@@ -138,7 +134,10 @@ export default {
return this.mode === EDIT_VARIABLE_ACTION;
},
isExpanded() {
- return !this.variable.raw;
+ return !this.isRaw;
+ },
+ isRaw() {
+ return this.variable.raw;
},
isTipVisible() {
return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
@@ -174,6 +173,9 @@ export default {
return true;
},
+ useRawMaskableRegexp() {
+ return this.isRaw;
+ },
variableValidationFeedback() {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
@@ -273,7 +275,20 @@ export default {
this.validationErrorEventProperty = '';
},
},
- defaultScope: allEnvironments.text,
+ i18n: {
+ awsTipMessage: AWS_TIP_MESSAGE,
+ containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
+ defaultScope: allEnvironments.text,
+ environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
+ expandedVariablesNote: EXPANDED_VARIABLES_NOTE,
+ flagsLinkTitle: FLAG_LINK_TITLE,
+ },
+ flagLink: helpPagePath('ci/variables/index', {
+ anchor: 'define-a-cicd-variable-in-the-ui',
+ }),
+ modalId: ADD_CI_VARIABLE_MODAL_ID,
+ tokens: awsTokens,
+ tokenList: awsTokenList,
variableOptions,
};
</script>
@@ -315,11 +330,7 @@ export default {
class="gl-font-monospace!"
spellcheck="false"
/>
- <p
- v-if="variable.raw"
- class="gl-mt-2 gl-mb-0 text-secondary"
- data-testid="raw-variable-tip"
- >
+ <p v-if="isRaw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip">
{{ __('Variable value will be evaluated as raw string.') }}
</p>
</gl-form-group>
@@ -340,15 +351,20 @@ export default {
data-testid="environment-scope"
>
<template #label>
- {{ __('Environment scope') }}
- <gl-link
- :title="$options.environmentScopeLinkTitle"
- :href="environmentScopeLink"
- target="_blank"
- data-testid="environment-scope-link"
- >
- <gl-icon name="question" :size="12" />
- </gl-link>
+ <div class="gl-display-flex gl-align-items-center">
+ <span class="gl-mr-2">
+ {{ __('Environment scope') }}
+ </span>
+ <gl-link
+ class="gl-display-flex"
+ :title="$options.i18n.environmentScopeLinkTitle"
+ :href="environmentScopeLink"
+ target="_blank"
+ data-testid="environment-scope-link"
+ >
+ <gl-icon name="question-o" :size="14" />
+ </gl-link>
+ </div>
</template>
<ci-environments-dropdown
v-if="areScopedVariablesAvailable"
@@ -358,12 +374,27 @@ export default {
@create-environment-scope="createEnvironmentScope"
/>
- <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" readonly />
+ <gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly />
</gl-form-group>
</template>
</div>
- <gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
+ <gl-form-group>
+ <template #label>
+ <div class="gl-display-flex gl-align-items-center">
+ <span class="gl-mr-2">
+ {{ __('Flags') }}
+ </span>
+ <gl-link
+ class="gl-display-flex"
+ :title="$options.i18n.flagsLinkTitle"
+ :href="$options.flagLink"
+ target="_blank"
+ >
+ <gl-icon name="question-o" :size="14" />
+ </gl-link>
+ </div>
+ </template>
<gl-form-checkbox
v-model="variable.protected"
class="gl-mb-0"
@@ -371,9 +402,6 @@ export default {
:data-is-protected-checked="variable.protected"
>
{{ __('Protect variable') }}
- <gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
- <gl-icon name="question" :size="12" />
- </gl-link>
<p class="gl-mt-2 text-secondary">
{{ __('Export variable to pipelines running on protected branches and tags only.') }}
</p>
@@ -384,9 +412,6 @@ export default {
data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
- <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">
- <gl-icon name="question" :size="12" />
- </gl-link>
<p class="gl-mt-2 text-secondary">
{{ __('Variable will be masked in job logs.') }}
<span
@@ -397,7 +422,7 @@ export default {
{{ __('Requires values to meet regular expression requirements.') }}</span
>
<gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{
- __('More information')
+ __('Learn more.')
}}</gl-link>
</p>
</gl-form-checkbox>
@@ -408,11 +433,8 @@ export default {
@change="setVariableRaw"
>
{{ __('Expand variable reference') }}
- <gl-link target="_blank" :href="containsVariableReferenceLink">
- <gl-icon name="question" :size="12" />
- </gl-link>
<p class="gl-mt-2 gl-mb-0 gl-text-secondary">
- <gl-sprintf :message="$options.expandedVariablesNote">
+ <gl-sprintf :message="$options.i18n.expandedVariablesNote">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
@@ -428,10 +450,10 @@ export default {
data-testid="aws-guidance-tip"
@dismiss="dismissTip"
>
- <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap">
+ <div class="gl-display-flex gl-flex-direction-row gl-md-flex-wrap-nowraps gl-gap-3">
<div>
<p>
- <gl-sprintf :message="$options.awsTipMessage">
+ <gl-sprintf :message="$options.i18n.awsTipMessage">
<template #deployLink="{ content }">
<gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link>
</template>
@@ -467,7 +489,7 @@ export default {
variant="warning"
data-testid="contains-variable-reference"
>
- <gl-sprintf :message="$options.containsVariableReferenceMessage">
+ <gl-sprintf :message="$options.i18n.containsVariableReferenceMessage">
<template #code="{ content }">
<code>{{ content }}</code>
</template>
diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js
index 828d0724d93..627ace1b28e 100644
--- a/app/assets/javascripts/ci/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci/ci_variable_list/constants.js
@@ -72,14 +72,14 @@ export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_S
export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __(
'Unselect "Expand variable reference" if you want to use the variable value as a raw string.',
);
-
+export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__(
+ 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.',
+);
export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more');
export const EXCEEDS_VARIABLE_LIMIT_TEXT = s__(
'CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables.',
);
-export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__(
- 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.',
-);
+export const FLAG_LINK_TITLE = s__('CiVariable|Define a CI/CD variable in the UI');
export const MAXIMUM_VARIABLE_LIMIT_REACHED = s__(
'CiVariables|Maximum number of variables reached.',
);
@@ -88,9 +88,6 @@ export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE';
export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE';
export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION];
-export const GRAPHQL_PROJECT_TYPE = 'Project';
-export const GRAPHQL_GROUP_TYPE = 'Group';
-
export const ADD_MUTATION_ACTION = 'add';
export const UPDATE_MUTATION_ACTION = 'update';
export const DELETE_MUTATION_ACTION = 'delete';
diff --git a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
index 10203383ba0..cafe3df35d0 100644
--- a/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
+++ b/app/assets/javascripts/ci/ci_variable_list/graphql/settings.js
@@ -3,14 +3,9 @@ import {
convertObjectPropsToCamelCase,
convertObjectPropsToSnakeCase,
} from '~/lib/utils/common_utils';
+import { TYPENAME_CI_VARIABLE, TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import {
- GRAPHQL_GROUP_TYPE,
- GRAPHQL_PROJECT_TYPE,
- groupString,
- instanceString,
- projectString,
-} from '../constants';
+import { groupString, instanceString, projectString } from '../constants';
import getProjectVariables from './queries/project_variables.query.graphql';
import getGroupVariables from './queries/group_variables.query.graphql';
import getAdminVariables from './queries/variables.query.graphql';
@@ -30,7 +25,7 @@ const mapVariableTypes = (variables = [], kind) => {
return {
__typename: `Ci${kind}Variable`,
...convertObjectPropsToCamelCase(ciVar),
- id: convertToGraphQLId('Ci::Variable', ciVar.id),
+ id: convertToGraphQLId(TYPENAME_CI_VARIABLE, ciVar.id),
variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType,
};
});
@@ -40,10 +35,10 @@ const prepareProjectGraphQLResponse = ({ data, id, errors = [] }) => {
return {
errors,
project: {
- __typename: GRAPHQL_PROJECT_TYPE,
- id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, id),
+ __typename: TYPENAME_PROJECT,
+ id: convertToGraphQLId(TYPENAME_PROJECT, id),
ciVariables: {
- __typename: `Ci${GRAPHQL_PROJECT_TYPE}VariableConnection`,
+ __typename: 'CiProjectVariableConnection',
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
@@ -61,10 +56,10 @@ const prepareGroupGraphQLResponse = ({ data, id, errors = [] }) => {
return {
errors,
group: {
- __typename: GRAPHQL_GROUP_TYPE,
- id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, id),
+ __typename: TYPENAME_GROUP,
+ id: convertToGraphQLId(TYPENAME_GROUP, id),
ciVariables: {
- __typename: `Ci${GRAPHQL_GROUP_TYPE}VariableConnection`,
+ __typename: `CiGroupVariableConnection`,
pageInfo: {
__typename: 'PageInfo',
hasNextPage: false,
diff --git a/app/assets/javascripts/ci/ci_variable_list/index.js b/app/assets/javascripts/ci/ci_variable_list/index.js
index 174a59aba42..4270c3c67fc 100644
--- a/app/assets/javascripts/ci/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci/ci_variable_list/index.js
@@ -21,11 +21,11 @@ const mountCiVariableListApp = (containerEl) => {
isGroup,
isProject,
maskedEnvironmentVariablesLink,
+ maskableRawRegex,
maskableRegex,
projectFullPath,
projectId,
protectedByDefault,
- protectedEnvironmentVariablesLink,
} = containerEl.dataset;
const parsedIsProject = parseBoolean(isProject);
@@ -63,10 +63,10 @@ const mountCiVariableListApp = (containerEl) => {
isProject: parsedIsProject,
isProtectedByDefault,
maskedEnvironmentVariablesLink,
+ maskableRawRegex,
maskableRegex,
projectFullPath,
projectId,
- protectedEnvironmentVariablesLink,
},
render(createElement) {
return createElement(component);
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
index 375db7f3054..ea7201efcd9 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -1,6 +1,8 @@
<script>
import { GlDrawer } from '@gitlab/ui';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { __ } from '~/locale';
+import { DRAWER_CONTAINER_CLASS } from '../job_assistant_drawer/constants';
import FirstPipelineCard from './cards/first_pipeline_card.vue';
import GettingStartedCard from './cards/getting_started_card.vue';
import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue';
@@ -26,14 +28,15 @@ export default {
required: false,
default: false,
},
+ zIndex: {
+ type: Number,
+ required: false,
+ default: 200,
+ },
},
computed: {
- drawerCardStyles() {
- return '';
- },
drawerHeightOffset() {
- const wrapperEl = document.querySelector('.content-wrapper');
- return wrapperEl ? `${wrapperEl.offsetTop}px` : '';
+ return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
},
},
methods: {
@@ -47,7 +50,7 @@ export default {
<gl-drawer
:header-height="drawerHeightOffset"
:open="isVisible"
- :z-index="200"
+ :z-index="zIndex"
@close="closeDrawer"
>
<template #title>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
index 201fba837e2..b78224e93b0 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue
@@ -1,24 +1,30 @@
<script>
import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL } from '../../constants';
export default {
i18n: {
browseTemplates: __('Browse templates'),
help: __('Help'),
+ jobAssistant: s__('JobAssistant|Job assistant'),
},
TEMPLATE_REPOSITORY_URL,
components: {
GlButton,
},
- mixins: [Tracking.mixin()],
+ mixins: [glFeatureFlagMixin(), Tracking.mixin()],
props: {
showDrawer: {
type: Boolean,
required: true,
},
+ showJobAssistantDrawer: {
+ type: Boolean,
+ required: true,
+ },
},
methods: {
toggleDrawer() {
@@ -29,6 +35,11 @@ export default {
this.trackHelpDrawerClick();
}
},
+ toggleJobAssistantDrawer() {
+ this.$emit(
+ this.showJobAssistantDrawer ? 'close-job-assistant-drawer' : 'open-job-assistant-drawer',
+ );
+ },
trackHelpDrawerClick() {
const { label, actions } = pipelineEditorTrackingOptions;
this.track(actions.openHelpDrawer, { label });
@@ -64,5 +75,15 @@ export default {
>
{{ $options.i18n.help }}
</gl-button>
+ <gl-button
+ v-if="glFeatures.ciJobAssistantDrawer"
+ icon="bulb"
+ size="small"
+ data-testid="job-assistant-drawer-toggle"
+ data-qa-selector="job_assistant_drawer_toggle"
+ @click="toggleJobAssistantDrawer"
+ >
+ {{ $options.i18n.jobAssistant }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
index 84c29e48114..7368d1a3a91 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -52,7 +52,7 @@ export default {
};
</script>
<template>
- <div class="gl-mb-4">
+ <div class="gl-mb-4 gl-display-flex gl-flex-wrap gl-gap-3">
<gl-button
v-if="showFileTreeToggle"
id="file-tree-toggle"
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
index feadc60a22a..a4dfb401f4c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -1,5 +1,6 @@
<script>
import { __ } from '~/locale';
+import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
import { PIPELINE_FAILURE } from '../../constants';
@@ -43,7 +44,8 @@ export default {
},
computed: {
downstreamPipelines() {
- return this.linkedPipelines?.downstream?.nodes || [];
+ const downstream = this.linkedPipelines?.downstream?.nodes;
+ return keepLatestDownstreamPipelines(downstream);
},
hasPipelineStages() {
return this.pipelineStages.length > 0;
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
new file mode 100644
index 00000000000..1c122fd5e38
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
@@ -0,0 +1,7 @@
+import { s__ } from '~/locale';
+
+export const DRAWER_CONTAINER_CLASS = '.content-wrapper';
+
+export const i18n = {
+ ADD_JOB: s__('JobAssistant|Add job'),
+};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
new file mode 100644
index 00000000000..65c87df21cb
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlDrawer, GlButton } from '@gitlab/ui';
+import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
+import { DRAWER_CONTAINER_CLASS, i18n } from './constants';
+
+export default {
+ i18n,
+ components: {
+ GlDrawer,
+ GlButton,
+ },
+ props: {
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ zIndex: {
+ type: Number,
+ required: false,
+ default: 200,
+ },
+ },
+ computed: {
+ drawerHeightOffset() {
+ return getContentWrapperHeight(DRAWER_CONTAINER_CLASS);
+ },
+ },
+ methods: {
+ closeDrawer() {
+ this.$emit('close-job-assistant-drawer');
+ },
+ },
+};
+</script>
+<template>
+ <gl-drawer
+ class="job-assistant-drawer"
+ :header-height="drawerHeightOffset"
+ :open="isVisible"
+ :z-index="zIndex"
+ @close="closeDrawer"
+ >
+ <template #title>
+ <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.ADD_JOB }}</h2>
+ </template>
+ <template #footer>
+ <div class="gl-display-flex gl-justify-content-end">
+ <gl-button
+ category="primary"
+ class="gl-mr-3"
+ data-testid="cancel-button"
+ @click="closeDrawer"
+ >{{ __('Cancel') }}</gl-button
+ >
+ <gl-button category="primary" variant="confirm" data-testid="confirm-button">{{
+ __('Add')
+ }}</gl-button>
+ </div>
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
index ed5466ff99c..fd6547468d9 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -95,6 +95,10 @@ export default {
type: Boolean,
required: true,
},
+ showJobAssistantDrawer: {
+ type: Boolean,
+ required: true,
+ },
},
apollo: {
appStatus: {
@@ -187,7 +191,11 @@ export default {
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
<walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" />
- <ci-editor-header :show-drawer="showDrawer" v-on="$listeners" />
+ <ci-editor-header
+ :show-drawer="showDrawer"
+ :show-job-assistant-drawer="showJobAssistantDrawer"
+ v-on="$listeners"
+ />
<text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<editor-tab
diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
index 1972125ed56..59863edbe0b 100644
--- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue
@@ -3,6 +3,7 @@ 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 JobAssistantDrawer from './components/job_assistant_drawer/job_assistant_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorFileTree from './components/file_tree/container.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
@@ -28,6 +29,7 @@ export default {
CommitSection,
GlModal,
PipelineEditorDrawer,
+ JobAssistantDrawer,
PipelineEditorFileNav,
PipelineEditorFileTree,
PipelineEditorHeader,
@@ -63,6 +65,9 @@ export default {
scrollToCommitForm: false,
shouldLoadNewBranch: false,
showDrawer: false,
+ showJobAssistantDrawer: false,
+ drawerIndex: 200,
+ jobAssistantIndex: 200,
showFileTree: false,
showSwitchBranchModal: false,
};
@@ -85,11 +90,19 @@ export default {
closeDrawer() {
this.showDrawer = false;
},
+ closeJobAssistantDrawer() {
+ this.showJobAssistantDrawer = false;
+ },
handleConfirmSwitchBranch() {
this.showSwitchBranchModal = true;
},
openDrawer() {
this.showDrawer = true;
+ this.drawerIndex = this.jobAssistantIndex + 1;
+ },
+ openJobAssistantDrawer() {
+ this.showJobAssistantDrawer = true;
+ this.jobAssistantIndex = this.drawerIndex + 1;
},
toggleFileTree() {
this.showFileTree = !this.showFileTree;
@@ -153,9 +166,12 @@ export default {
:current-tab="currentTab"
:is-new-ci-config-file="isNewCiConfigFile"
:show-drawer="showDrawer"
+ :show-job-assistant-drawer="showJobAssistantDrawer"
v-on="$listeners"
@open-drawer="openDrawer"
@close-drawer="closeDrawer"
+ @open-job-assistant-drawer="openJobAssistantDrawer"
+ @close-job-assistant-drawer="closeJobAssistantDrawer"
@set-current-tab="setCurrentTab"
@walkthrough-popover-cta-clicked="setScrollToCommitForm"
/>
@@ -174,8 +190,15 @@ export default {
/>
<pipeline-editor-drawer
:is-visible="showDrawer"
+ :z-index="drawerIndex"
v-on="$listeners"
@close-drawer="closeDrawer"
/>
+ <job-assistant-drawer
+ :is-visible="showJobAssistantDrawer"
+ :z-index="jobAssistantIndex"
+ v-on="$listeners"
+ @close-job-assistant-drawer="closeJobAssistantDrawer"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
index 5692627abef..8837b7a1917 100644
--- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue
@@ -16,18 +16,29 @@ import {
import * as Sentry from '@sentry/browser';
import { uniqueId } from 'lodash';
import Vue from 'vue';
+import { fetchPolicies } from '~/lib/graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale';
-import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants';
+import {
+ CC_VALIDATION_REQUIRED_ERROR,
+ CONFIG_VARIABLES_TIMEOUT,
+ FILE_TYPE,
+ VARIABLE_TYPE,
+} from '../constants';
import createPipelineMutation from '../graphql/mutations/create_pipeline.mutation.graphql';
import ciConfigVariablesQuery from '../graphql/queries/ci_config_variables.graphql';
import filterVariables from '../utils/filter_variables';
import RefsDropdown from './refs_dropdown.vue';
+let pollTimeout;
+export const POLLING_INTERVAL = 2000;
const i18n = {
variablesDescription: s__(
- 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
+ 'Pipeline|Specify variable values to be used in this run. The variables specified in the configuration file as well as %{linkStart}CI/CD settings%{linkEnd} are used by default.',
+ ),
+ overrideNoteText: s__(
+ 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}',
),
defaultError: __('Something went wrong on our end. Please try again.'),
refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
@@ -115,10 +126,11 @@ export default {
// https://gitlab.com/gitlab-org/gitlab/-/issues/287815
fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined,
},
+ configVariablesWithDescription: {},
form: {},
errorTitle: null,
error: null,
- predefinedValueOptions: {},
+ predefinedVariables: null,
warnings: [],
totalWarnings: 0,
isWarningDismissed: false,
@@ -128,6 +140,7 @@ export default {
},
apollo: {
ciConfigVariables: {
+ fetchPolicy: fetchPolicies.NO_CACHE,
query: ciConfigVariablesQuery,
// Skip when variables already cached in `form`
skip() {
@@ -140,46 +153,40 @@ export default {
};
},
update({ project }) {
- return project?.ciConfigVariables || [];
+ return project?.ciConfigVariables;
},
result({ data }) {
- const predefinedVars = data?.project?.ciConfigVariables || [];
- const params = {};
- const descriptions = {};
-
- predefinedVars.forEach(({ description, key, value, valueOptions }) => {
- if (description) {
- params[key] = value;
- descriptions[key] = description;
- this.predefinedValueOptions[key] = valueOptions;
- }
- });
-
- Vue.set(this.form, this.refFullName, { descriptions, variables: [] });
+ this.predefinedVariables = data?.project?.ciConfigVariables;
- // Add default variables from yml
- this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
-
- // Add/update variables, e.g. from query string
- if (this.variableParams) {
- this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
+ // API cache is empty when predefinedVariables === null, so we need to
+ // poll while cache values are being populated in the backend.
+ // After CONFIG_VARIABLES_TIMEOUT ms have passed, we stop polling
+ // and populate the form regardless.
+ if (this.isFetchingCiConfigVariables && !pollTimeout) {
+ pollTimeout = setTimeout(() => {
+ this.predefinedVariables = [];
+ this.clearPolling();
+ this.populateForm();
+ }, CONFIG_VARIABLES_TIMEOUT);
}
- if (this.fileParams) {
- this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
+ if (!this.isFetchingCiConfigVariables) {
+ this.clearPolling();
+ this.populateForm();
}
-
- // Adds empty var at the end of the form
- this.addEmptyVariable(this.refFullName);
},
error(error) {
Sentry.captureException(error);
},
+ pollInterval: POLLING_INTERVAL,
},
},
computed: {
+ isFetchingCiConfigVariables() {
+ return this.predefinedVariables === null;
+ },
isLoading() {
- return this.$apollo.queries.ciConfigVariables.loading;
+ return this.$apollo.queries.ciConfigVariables.loading || this.isFetchingCiConfigVariables;
},
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
@@ -228,6 +235,48 @@ export default {
value: '',
});
},
+ clearPolling() {
+ clearTimeout(pollTimeout);
+ this.$apollo.queries.ciConfigVariables.stopPolling();
+ },
+ populateForm() {
+ this.configVariablesWithDescription = this.predefinedVariables.reduce(
+ (accumulator, { description, key, value, valueOptions }) => {
+ if (description) {
+ accumulator.descriptions[key] = description;
+ accumulator.values[key] = value;
+ accumulator.options[key] = valueOptions;
+ }
+
+ return accumulator;
+ },
+ { descriptions: {}, values: {}, options: {} },
+ );
+
+ Vue.set(this.form, this.refFullName, {
+ descriptions: this.configVariablesWithDescription.descriptions,
+ variables: [],
+ });
+
+ // Add default variables from yml
+ this.setVariableParams(
+ this.refFullName,
+ VARIABLE_TYPE,
+ this.configVariablesWithDescription.values,
+ );
+
+ // Add/update variables, e.g. from query string
+ if (this.variableParams) {
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
+ }
+
+ if (this.fileParams) {
+ this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
+ }
+
+ // Adds empty var at the end of the form
+ this.addEmptyVariable(this.refFullName);
+ },
setVariable(refValue, type, key, value) {
const { variables } = this.form[refValue];
@@ -255,7 +304,7 @@ export default {
});
},
shouldShowValuesDropdown(key) {
- return this.predefinedValueOptions[key]?.length > 1;
+ return this.configVariablesWithDescription.options[key]?.length > 1;
},
removeVariable(index) {
this.variables.splice(index, 1);
@@ -362,7 +411,7 @@ export default {
<gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
- <gl-form-group v-else :label="s__('Pipeline|Variables')">
+ <gl-form-group v-else class="gl-mb-3" :label="s__('Pipeline|Variables')">
<div
v-for="(variable, index) in variables"
:key="variable.uniqueId"
@@ -403,13 +452,13 @@ export default {
data-qa-selector="ci_variable_value_dropdown"
>
<gl-dropdown-item
- v-for="value in predefinedValueOptions[variable.key]"
- :key="value"
+ v-for="option in configVariablesWithDescription.options[variable.key]"
+ :key="option"
data-testid="pipeline-form-ci-variable-value-dropdown-items"
data-qa-selector="ci_variable_value_dropdown_item"
- @click="setVariableAttribute(variable.key, 'value', value)"
+ @click="setVariableAttribute(variable.key, 'value', option)"
>
- {{ value }}
+ {{ option }}
</gl-dropdown-item>
</gl-dropdown>
<gl-form-textarea
@@ -457,6 +506,15 @@ export default {
</gl-sprintf></template
>
</gl-form-group>
+ <div class="gl-mb-4 gl-text-gray-500">
+ <gl-sprintf :message="$options.i18n.overrideNoteText">
+ <template #bold="{ content }">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </div>
<div class="gl-pt-5 gl-display-flex">
<gl-button
type="submit"
diff --git a/app/assets/javascripts/ci/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js
index 5e81245037f..e1486649dbb 100644
--- a/app/assets/javascripts/ci/reports/codequality_report/constants.js
+++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js
@@ -1,10 +1,10 @@
export const SEVERITY_CLASSES = {
- info: 'text-primary-400',
- minor: 'text-warning-200',
- major: 'text-warning-400',
- critical: 'text-danger-600',
- blocker: 'text-danger-800',
- unknown: 'text-secondary-400',
+ info: 'gl-text-blue-400',
+ minor: 'gl-text-orange-200',
+ major: 'gl-text-orange-400',
+ critical: 'gl-text-red-600',
+ blocker: 'gl-text-red-800',
+ unknown: 'gl-text-gray-400',
};
export const SEVERITY_ICONS = {
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
new file mode 100644
index 00000000000..5401c7c1c28
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlSprintf, GlLink, GlModalDirective } from '@gitlab/ui';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue';
+import RunnerFormFields from '~/ci/runner/components/runner_form_fields.vue';
+import { DEFAULT_PLATFORM, DEFAULT_ACCESS_LEVEL } from '../constants';
+
+export default {
+ name: 'AdminNewRunnerApp',
+ components: {
+ GlLink,
+ GlSprintf,
+ RunnerInstructionsModal,
+ RunnerPlatformsRadioGroup,
+ RunnerFormFields,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ legacyRegistrationToken: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ platform: DEFAULT_PLATFORM,
+ runner: {
+ description: '',
+ maintenanceNote: '',
+ paused: false,
+ accessLevel: DEFAULT_ACCESS_LEVEL,
+ runUntagged: false,
+ tagList: '',
+ maximumTimeout: ' ',
+ },
+ };
+ },
+ modalId: 'runners-legacy-registration-instructions-modal',
+};
+</script>
+
+<template>
+ <div>
+ <h1 class="gl-font-size-h2">{{ s__('Runners|New instance runner') }}</h1>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Create an instance runner to generate a command that registers the runner with all its configurations. %{linkStart}Prefer to use a registration token to create a runner?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link v-gl-modal="$options.modalId" data-testid="legacy-instructions-link">{{
+ content
+ }}</gl-link>
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="legacyRegistrationToken"
+ />
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Platform') }}
+ </h2>
+ <runner-platforms-radio-group v-model="platform" />
+
+ <hr aria-hidden="true" />
+
+ <runner-form-fields v-model="runner" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/index.js b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
new file mode 100644
index 00000000000..502d9d33b4d
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/admin_new_runner/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AdminNewRunnerApp from './admin_new_runner_app.vue';
+
+Vue.use(VueApollo);
+
+export const initAdminNewRunner = (selector = '#js-admin-new-runner') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { legacyRegistrationToken } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(AdminNewRunnerApp, {
+ props: {
+ legacyRegistrationToken,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
index 66d790acb00..8d4303778af 100644
--- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue
@@ -1,53 +1,29 @@
<script>
-import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
-import VueRouter from 'vue-router';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
+import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
-import { formatJobCount } from '../utils';
+
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
-import RunnerDetails from '../components/runner_details.vue';
-import RunnerJobs from '../components/runner_jobs.vue';
-import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants';
+import RunnerDetailsTabs from '../components/runner_details_tabs.vue';
+
+import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
-const ROUTE_DETAILS = 'details';
-const ROUTE_JOBS = 'jobs';
-
-const routes = [
- {
- path: '/',
- name: ROUTE_DETAILS,
- component: RunnerDetails,
- },
- {
- path: '/jobs',
- name: ROUTE_JOBS,
- component: RunnerJobs,
- },
- { path: '*', redirect: { name: ROUTE_DETAILS } },
-];
-
export default {
name: 'AdminRunnerShowApp',
components: {
- GlBadge,
- GlTabs,
- GlTab,
RunnerDeleteButton,
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
+ RunnerDetailsTabs,
},
- router: new VueRouter({
- routes,
- }),
props: {
runnerId: {
type: String,
@@ -68,7 +44,7 @@ export default {
query: runnerQuery,
variables() {
return {
- id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
+ id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId),
};
},
error(error) {
@@ -85,20 +61,11 @@ export default {
canDelete() {
return this.runner.userPermissions?.deleteRunner;
},
- jobCount() {
- return formatJobCount(this.runner?.jobCount);
- },
- tabIndex() {
- return routes.findIndex(({ name }) => name === this.$route.name);
- },
},
errorCaptured(error) {
this.reportToSentry(error);
},
methods: {
- goTo(name) {
- this.$router.push({ name });
- },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -107,10 +74,6 @@ export default {
redirectTo(this.runnersPath);
},
},
- ROUTE_DETAILS,
- ROUTE_JOBS,
- I18N_DETAILS,
- I18N_JOBS,
};
</script>
<template>
@@ -122,26 +85,6 @@ export default {
<runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
</template>
</runner-header>
-
- <gl-tabs :value="tabIndex">
- <gl-tab @click="goTo($options.ROUTE_DETAILS)">
- <template #title>{{ $options.I18N_DETAILS }}</template>
- </gl-tab>
- <gl-tab @click="goTo($options.ROUTE_JOBS)">
- <template #title>
- {{ $options.I18N_JOBS }}
- <gl-badge
- v-if="jobCount"
- data-testid="job-count-badge"
- class="gl-tab-counter-badge"
- size="sm"
- >
- {{ jobCount }}
- </gl-badge>
- </template>
- </gl-tab>
-
- <router-view v-if="runner" :runner="runner" />
- </gl-tabs>
+ <runner-details-tabs v-if="runner" :runner="runner" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
index 3bd20dff9cc..ce2c511ddd4 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
@@ -33,12 +33,14 @@ import {
INSTANCE_TYPE,
I18N_FETCH_ERROR,
FILTER_CSS_CLASSES,
+ JOBS_ROUTE_PATH,
} from '../constants';
import { captureException } from '../sentry_utils';
export default {
name: 'AdminRunnersApp',
components: {
+ GlButton,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
@@ -54,6 +56,10 @@ export default {
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
+ newRunnerPath: {
+ type: String,
+ required: true,
+ },
registrationToken: {
type: String,
required: true,
@@ -121,6 +127,10 @@ export default {
isSearchFiltered() {
return isSearchFiltered(this.search);
},
+ shouldShowCreateRunnerWorkflow() {
+ // create_runner_workflow feature flag
+ return this.glFeatures.createRunnerWorkflow;
+ },
},
watch: {
search: {
@@ -141,7 +151,7 @@ export default {
methods: {
jobsUrl(runner) {
const url = new URL(runner.adminUrl);
- url.hash = '#/jobs';
+ url.hash = `#${JOBS_ROUTE_PATH}`;
return url.href;
},
@@ -183,7 +193,11 @@ export default {
nav-class="gl-border-none!"
/>
+ <gl-button v-if="shouldShowCreateRunnerWorkflow" :href="newRunnerPath" variant="confirm">
+ {{ s__('Runners|New instance runner') }}
+ </gl-button>
<registration-dropdown
+ v-else
class="gl-w-full gl-sm-w-auto gl-mr-auto"
:registration-token="registrationToken"
:type="$options.INSTANCE_TYPE"
@@ -204,6 +218,7 @@ export default {
v-if="noRunnersFound"
:registration-token="registrationToken"
:is-search-filtered="isSearchFiltered"
+ :new-runner-path="newRunnerPath"
:svg-path="emptyStateSvgPath"
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
@@ -214,17 +229,17 @@ export default {
:checkable="true"
@deleted="onDeleted"
>
- <template #runner-name="{ runner }">
- <gl-link :href="runner.adminUrl">
- <runner-name :runner="runner" />
- </gl-link>
- </template>
<template #runner-job-status-badge="{ runner }">
<runner-job-status-badge
:href="jobsUrl(runner)"
:job-status="runner.jobExecutionStatus"
/>
</template>
+ <template #runner-name="{ runner }">
+ <gl-link :href="runner.adminUrl">
+ <runner-name :runner="runner" />
+ </gl-link>
+ </template>
<template #runner-actions-cell="{ runner }">
<runner-actions-cell
:runner="runner"
diff --git a/app/assets/javascripts/ci/runner/admin_runners/index.js b/app/assets/javascripts/ci/runner/admin_runners/index.js
index c6db7148eb1..881dc3613e9 100644
--- a/app/assets/javascripts/ci/runner/admin_runners/index.js
+++ b/app/assets/javascripts/ci/runner/admin_runners/index.js
@@ -31,6 +31,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
const {
runnerInstallHelpPage,
+ newRunnerPath,
registrationToken,
onlineContactTimeoutSecs,
staleTimeoutSecs,
@@ -58,6 +59,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
render(h) {
return h(AdminRunnersApp, {
props: {
+ newRunnerPath,
registrationToken,
},
});
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
index cfbe37f5ba2..4d04b5d4b14 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue
@@ -36,5 +36,6 @@ export default {
v-if="paused"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
+ <slot :runner="runner" name="runner-job-status-badge"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
index 4a72023b6a0..97dfbe1a051 100644
--- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue
@@ -6,7 +6,6 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import RunnerName from '../runner_name.vue';
import RunnerTags from '../runner_tags.vue';
import RunnerTypeBadge from '../runner_type_badge.vue';
-import RunnerJobStatusBadge from '../runner_job_status_badge.vue';
import { formatJobCount } from '../../utils';
import {
@@ -27,7 +26,6 @@ export default {
RunnerName,
RunnerTags,
RunnerTypeBadge,
- RunnerJobStatusBadge,
RunnerUpgradeStatusIcon: () =>
import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'),
TooltipOnTruncate,
@@ -90,10 +88,6 @@ export default {
</div>
<div>
- <slot :runner="runner" name="runner-job-status-badge">
- <runner-job-status-badge :job-status="runner.jobExecutionStatus" />
- </slot>
-
<runner-summary-field icon="clock">
<gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL">
<template #timeAgo>
diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
index 6740065e860..ac2793654c8 100644
--- a/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
+++ b/app/assets/javascripts/ci/runner/components/registration/registration_token_reset_dropdown_item.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
+import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import runnersRegistrationTokenResetMutation from '~/ci/runner/graphql/list/runners_registration_token_reset.mutation.graphql';
@@ -58,12 +58,12 @@ export default {
};
case GROUP_TYPE:
return {
- id: convertToGraphQLId(TYPE_GROUP, this.groupId),
+ id: convertToGraphQLId(TYPENAME_GROUP, this.groupId),
type: this.type,
};
case PROJECT_TYPE:
return {
- id: convertToGraphQLId(TYPE_PROJECT, this.projectId),
+ id: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
type: this.type,
};
default:
diff --git a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue
index 2fa87bdd776..5e61e4d7377 100644
--- a/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_assigned_item.vue
@@ -55,7 +55,7 @@ export default {
<div>
<div class="gl-mb-1">
<gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
- <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge>
+ <gl-badge v-if="isOwner" variant="info">{{ s__('Runners|Owner') }}</gl-badge>
</div>
<div v-if="description">{{ description }}</div>
</div>
diff --git a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
index 1ec3f8da7c3..8dde3ac4e19 100644
--- a/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_bulk_delete.vue
@@ -162,22 +162,28 @@ export default {
</script>
<template>
- <div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
- <div class="gl-display-flex gl-align-items-center">
- <div>
- <gl-sprintf :message="bannerMessage">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <div class="gl-ml-auto">
- <gl-button variant="default" @click="onClearChecked">{{
- s__('Runners|Clear selection')
- }}</gl-button>
- <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{
- s__('Runners|Delete selected')
- }}</gl-button>
+ <div>
+ <div
+ v-if="checkedCount"
+ data-testid="runner-bulk-delete-banner"
+ class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <div>
+ <gl-sprintf :message="bannerMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-ml-auto">
+ <gl-button variant="default" @click="onClearChecked">{{
+ s__('Runners|Clear selection')
+ }}</gl-button>
+ <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{
+ s__('Runners|Delete selected')
+ }}</gl-button>
+ </div>
</div>
</div>
<gl-modal
diff --git a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
index 32d4076b00f..f02e6bce5c3 100644
--- a/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_delete_button.vue
@@ -122,7 +122,7 @@ export default {
onError(error) {
this.deleting = false;
const { message } = error;
- const title = sprintf(s__('Runner|Runner %{runnerName} failed to delete'), {
+ const title = sprintf(s__('Runners|Runner %{runnerName} failed to delete'), {
runnerName: this.runnerName,
});
diff --git a/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue
new file mode 100644
index 00000000000..e4190a4dffd
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_details_tabs.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
+import VueRouter from 'vue-router';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { JOBS_ROUTE_PATH, I18N_DETAILS, I18N_JOBS } from '../constants';
+import { formatJobCount } from '../utils';
+import RunnerDetails from './runner_details.vue';
+import RunnerJobs from './runner_jobs.vue';
+
+const ROUTE_DETAILS = 'details';
+const ROUTE_JOBS = 'jobs';
+
+const routes = [
+ {
+ path: '/',
+ name: ROUTE_DETAILS,
+ component: RunnerDetails,
+ },
+ {
+ path: JOBS_ROUTE_PATH,
+ name: ROUTE_JOBS,
+ component: RunnerJobs,
+ },
+ { path: '*', redirect: { name: ROUTE_DETAILS } },
+];
+
+export default {
+ name: 'RunnerDetailsTabs',
+ components: {
+ GlBadge,
+ GlTabs,
+ GlTab,
+ HelpPopover,
+ },
+ router: new VueRouter({
+ routes,
+ }),
+ props: {
+ runner: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ showAccessHelp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ jobCount() {
+ return formatJobCount(this.runner?.jobCount);
+ },
+ tabIndex() {
+ return routes.findIndex(({ name }) => name === this.$route.name);
+ },
+ },
+ methods: {
+ goTo(name) {
+ if (this.$route.name !== name) {
+ this.$router.push({ name });
+ }
+ },
+ },
+ ROUTE_DETAILS,
+ ROUTE_JOBS,
+ I18N_DETAILS,
+ I18N_JOBS,
+};
+</script>
+<template>
+ <gl-tabs :value="tabIndex">
+ <gl-tab @click="goTo($options.ROUTE_DETAILS)">
+ <template #title>{{ $options.I18N_DETAILS }}</template>
+ </gl-tab>
+ <gl-tab @click="goTo($options.ROUTE_JOBS)">
+ <template #title>
+ {{ $options.I18N_JOBS }}
+ <gl-badge
+ v-if="jobCount"
+ data-testid="job-count-badge"
+ class="gl-tab-counter-badge"
+ size="sm"
+ >
+ {{ jobCount }}
+ </gl-badge>
+ <help-popover v-if="showAccessHelp" class="gl-ml-3">
+ {{ s__('Runners|Jobs in projects you have access to.') }}
+ </help-popover>
+ </template>
+ </gl-tab>
+
+ <router-view v-if="runner" :runner="runner" />
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue
new file mode 100644
index 00000000000..e37ac5e6e26
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue
@@ -0,0 +1,140 @@
+<script>
+import { GlFormGroup, GlFormCheckbox, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED } from '../constants';
+
+export default {
+ name: 'RunnerFormFields',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
+ RunnerMaintenanceNoteField: () =>
+ import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'),
+ },
+ props: {
+ value: {
+ type: Object,
+ default: null,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ model: {
+ ...this.value,
+ },
+ };
+ },
+ watch: {
+ model: {
+ handler() {
+ this.$emit('input', this.model);
+ },
+ deep: true,
+ },
+ },
+ HELP_LABELS_PAGE_PATH: helpPagePath('ci/runners/configure_runners', {
+ anchor: 'use-tags-to-control-which-jobs-a-runner-can-run',
+ }),
+ ACCESS_LEVEL_NOT_PROTECTED,
+ ACCESS_LEVEL_REF_PROTECTED,
+};
+</script>
+<template>
+ <div>
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Details') }}
+ {{ __('(optional)') }}
+ </h2>
+ <gl-form-group :label="s__('Runners|Runner description')" label-for="runner-description">
+ <gl-form-input id="runner-description" v-model="model.description" name="description" />
+ </gl-form-group>
+
+ <runner-maintenance-note-field v-model="model.maintenanceNote" class="gl-mt-5" />
+
+ <hr aria-hidden="true" />
+
+ <h2 class="gl-font-weight-normal gl-font-lg gl-my-5">
+ {{ s__('Runners|Configuration') }}
+ {{ __('(optional)') }}
+ </h2>
+
+ <div class="gl-mb-5">
+ <gl-form-checkbox v-model="model.paused" name="paused">
+ {{ __('Paused') }}
+ <template #help>
+ {{ s__('Runners|Stop the runner from accepting new jobs.') }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox
+ v-model="model.accessLevel"
+ name="protected"
+ :value="$options.ACCESS_LEVEL_REF_PROTECTED"
+ :unchecked-value="$options.ACCESS_LEVEL_NOT_PROTECTED"
+ >
+ {{ __('Protected') }}
+ <template #help>
+ {{ s__('Runners|Use the runner on pipelines for protected branches only.') }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox v-model="model.runUntagged" name="run-untagged">
+ {{ __('Run untagged jobs') }}
+ <template #help>
+ {{ s__('Runners|Use the runner for jobs without tags in addition to tagged jobs.') }}
+ </template>
+ </gl-form-checkbox>
+ </div>
+
+ <gl-form-group :label="__('Tags')" label-for="runner-tags">
+ <template #description>
+ <gl-sprintf
+ :message="
+ s__('Runners|Multiple tags must be separated by a comma. For example, %{example}.')
+ "
+ >
+ <template #example>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>macos, shared</code>
+ </template>
+ </gl-sprintf>
+ </template>
+ <template #label-description>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Add tags for the types of jobs the runner processes to ensure that the runner only runs jobs that you intend it to. %{helpLinkStart}Learn more.%{helpLinkEnd}',
+ )
+ "
+ >
+ <template #helpLink="{ content }">
+ <gl-link :href="$options.HELP_LABELS_PAGE_PATH" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-form-input id="runner-tags" v-model="model.tagList" name="tags" />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Maximum job timeout')"
+ :label-description="
+ s__(
+ 'Runners|Maximum amount of time the runner can run before it terminates. If a project has a shorter job timeout period, the job timeout period of the instance runner is used instead.',
+ )
+ "
+ label-for="runner-max-timeout"
+ :description="s__('Runners|Enter the number of seconds.')"
+ >
+ <gl-form-input
+ id="runner-max-timeout"
+ v-model.number="model.maximumTimeout"
+ name="max-timeout"
+ type="number"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
index 1e52acecfb8..bed592e3f30 100644
--- a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue
@@ -45,8 +45,7 @@ export default {
<gl-badge
v-if="badge"
v-bind="$attrs"
- size="sm"
- class="gl-mr-3 gl-bg-transparent!"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate gl-bg-transparent!"
variant="muted"
:class="badge.classes"
>
diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
index e359344ab77..ebcda4f0ac3 100644
--- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue
@@ -37,10 +37,10 @@ export default {
return job.detailedStatus?.detailsPath;
},
projectName(job) {
- return job.pipeline?.project?.name;
+ return job.project?.name;
},
projectWebUrl(job) {
- return job.pipeline?.project?.webUrl;
+ return job.project?.webUrl;
},
commitShortSha(job) {
return job.shortSha;
diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue
index b2aad0aac4f..ec04701db2c 100644
--- a/app/assets/javascripts/ci/runner/components/runner_list.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list.vue
@@ -150,16 +150,17 @@ export default {
</template>
<template #cell(status)="{ item }">
- <runner-status-cell :runner="item" />
+ <runner-status-cell :runner="item">
+ <template #runner-job-status-badge="{ runner }">
+ <slot name="runner-job-status-badge" :runner="runner"></slot>
+ </template>
+ </runner-status-cell>
</template>
- <template #cell(summary)="{ item, index }">
+ <template #cell(summary)="{ item }">
<runner-summary-cell :runner="item">
<template #runner-name="{ runner }">
- <slot name="runner-name" :runner="runner" :index="index"></slot>
- </template>
- <template #runner-job-status-badge="{ runner }">
- <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot>
+ <slot name="runner-name" :runner="runner"></slot>
</template>
</runner-summary-cell>
</template>
diff --git a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
index e6576c83e69..d2f7912fabb 100644
--- a/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
+++ b/app/assets/javascripts/ci/runner/components/runner_list_empty_state.vue
@@ -1,5 +1,6 @@
<script>
import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
export default {
@@ -12,6 +13,7 @@ export default {
directives: {
GlModal: GlModalDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
isSearchFiltered: {
type: Boolean,
@@ -33,6 +35,17 @@ export default {
required: false,
default: null,
},
+ newRunnerPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ shouldShowCreateRunnerWorkflow() {
+ // create_runner_workflow feature flag
+ return this.newRunnerPath && this.glFeatures?.createRunnerWorkflow;
+ },
},
modalId: 'runners-empty-state-instructions-modal',
svgHeight: 145,
@@ -61,15 +74,17 @@ export default {
)
"
>
- <template #link="{ content }">
+ <template v-if="shouldShowCreateRunnerWorkflow" #link="{ content }">
+ <gl-link :href="newRunnerPath">{{ content }}</gl-link>
+ </template>
+ <template v-else #link="{ content }">
<gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link>
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="registrationToken"
+ />
</template>
</gl-sprintf>
-
- <runner-instructions-modal
- :modal-id="$options.modalId"
- :registration-token="registrationToken"
- />
</template>
<template v-else #description>
{{
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
new file mode 100644
index 00000000000..d70c51e83f9
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlFormRadio } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormRadio,
+ },
+ model: {
+ event: 'input',
+ prop: 'checked',
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ checked: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ isChecked() {
+ return this.value && this.value === this.checked;
+ },
+ },
+ methods: {
+ onInput($event) {
+ if (!$event) {
+ return;
+ }
+ this.$emit('input', $event);
+ },
+ onChange($event) {
+ this.$emit('change', $event);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="runner-platforms-radio gl-display-flex gl-border gl-rounded-base gl-px-5 gl-py-6"
+ :class="{ 'gl-bg-blue-50 gl-border-blue-500': isChecked, 'gl-cursor-pointer': value }"
+ @click="onInput(value)"
+ >
+ <gl-form-radio
+ v-if="value"
+ class="gl-min-h-5"
+ :checked="checked"
+ :value="value"
+ @input="onInput($event)"
+ @change="onChange($event)"
+ >
+ <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <span class="gl-font-weight-bold"><slot></slot></span>
+ </gl-form-radio>
+ <div v-else class="gl-h-5">
+ <img v-if="image" :src="image" aria-hidden="true" class="gl-h-5 gl-mr-2" />
+ <span class="gl-font-weight-bold"><slot></slot></span>
+ </div>
+ </div>
+</template>
+
+<style>
+.runner-platforms-radio {
+ min-width: 173px;
+}
+</style>
diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
new file mode 100644
index 00000000000..273226141d2
--- /dev/null
+++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue
@@ -0,0 +1,108 @@
+<script>
+import AWS_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/aws.svg?url';
+import DOCKER_LOGO_URL from '@gitlab/svgs/dist/illustrations/third-party-logos/ci_cd-template-logos/docker.png';
+import KUBERNETES_LOGO_URL from '@gitlab/svgs/dist/illustrations/logos/kubernetes.svg?url';
+import { GlFormRadioGroup, GlIcon, GlLink } from '@gitlab/ui';
+
+import {
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+ AWS_PLATFORM,
+ DOCKER_HELP_URL,
+ KUBERNETES_HELP_URL,
+} from '../constants';
+
+import RunnerPlatformsRadio from './runner_platforms_radio.vue';
+
+export default {
+ components: {
+ GlFormRadioGroup,
+ GlLink,
+ GlIcon,
+ RunnerPlatformsRadio,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ model: this.value,
+ };
+ },
+ watch: {
+ model() {
+ this.$emit('input', this.model);
+ },
+ },
+ LINUX_PLATFORM,
+ MACOS_PLATFORM,
+ WINDOWS_PLATFORM,
+
+ AWS_PLATFORM,
+ AWS_LOGO_URL,
+ DOCKER_HELP_URL,
+ DOCKER_LOGO_URL,
+ KUBERNETES_HELP_URL,
+ KUBERNETES_LOGO_URL,
+};
+</script>
+
+<template>
+ <gl-form-radio-group v-model="model">
+ <div class="gl-mt-3 gl-mb-6">
+ <label>{{ s__('Runners|Operating systems') }}</label>
+
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <runner-platforms-radio v-model="model" :value="$options.LINUX_PLATFORM">
+ Linux
+ </runner-platforms-radio>
+ <runner-platforms-radio v-model="model" :value="$options.MACOS_PLATFORM">
+ macOS
+ </runner-platforms-radio>
+ <runner-platforms-radio v-model="model" :value="$options.WINDOWS_PLATFORM">
+ Windows
+ </runner-platforms-radio>
+ </div>
+ </div>
+
+ <div class="gl-mt-3 gl-mb-6">
+ <label>{{ s__('Runners|Cloud templates') }}</label>
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <runner-platforms-radio
+ v-model="model"
+ :image="$options.AWS_LOGO_URL"
+ :value="$options.AWS_PLATFORM"
+ >
+ AWS
+ </runner-platforms-radio>
+ </div>
+ </div>
+
+ <div class="gl-mt-3 gl-mb-6">
+ <label>{{ s__('Runners|Containers') }}</label>
+
+ <div class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <runner-platforms-radio :image="$options.DOCKER_LOGO_URL">
+ <gl-link :href="$options.DOCKER_HELP_URL" target="_blank">
+ Docker
+ <gl-icon name="external-link" />
+ </gl-link>
+ </runner-platforms-radio>
+ <runner-platforms-radio :image="$options.KUBERNETES_LOGO_URL">
+ <gl-link :href="$options.KUBERNETES_HELP_URL" target="_blank">
+ Kubernetes
+ <gl-icon name="external-link" />
+ </gl-link>
+ </runner-platforms-radio>
+ </div>
+ </div>
+ </gl-form-radio-group>
+</template>
diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js
index 31900a1fe89..318eb7e74bd 100644
--- a/app/assets/javascripts/ci/runner/constants.js
+++ b/app/assets/javascripts/ci/runner/constants.js
@@ -98,6 +98,8 @@ export const I18N_ADMIN = s__('Runners|Administrator');
// Runner details
+export const JOBS_ROUTE_PATH = '/jobs'; // vue-router route path
+
export const I18N_DETAILS = s__('Runners|Details');
export const I18N_JOBS = s__('Runners|Jobs');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
@@ -150,6 +152,8 @@ export const JOB_STATUS_IDLE = 'IDLE';
export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED';
export const ACCESS_LEVEL_REF_PROTECTED = 'REF_PROTECTED';
+export const DEFAULT_ACCESS_LEVEL = ACCESS_LEVEL_NOT_PROTECTED;
+
// CiRunnerSort
export const CREATED_DESC = 'CREATED_DESC';
@@ -170,3 +174,17 @@ export const DEFAULT_MEMBERSHIP = MEMBERSHIP_DESCENDANTS;
export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners';
export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
+
+// Platforms
+
+export const LINUX_PLATFORM = 'linux';
+export const MACOS_PLATFORM = 'osx';
+export const WINDOWS_PLATFORM = 'windows';
+export const AWS_PLATFORM = 'aws';
+
+export const DEFAULT_PLATFORM = LINUX_PLATFORM;
+
+// Runner docs are in a separate repository and are not shipped with GitLab
+// they are rendered as external URLs.
+export const DOCKER_HELP_URL = 'https://docs.gitlab.com/runner/install/docker.html';
+export const KUBERNETES_HELP_URL = 'https://docs.gitlab.com/runner/install/kubernetes.html';
diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
index 075dbb06190..b6d6996a857 100644
--- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
+++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql
@@ -15,13 +15,10 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String,
icon
text
}
- pipeline {
+ project {
id
- project {
- id
- name
- webUrl
- }
+ name
+ webUrl
}
shortSha
commitPath
diff --git a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
index 75138b1bd81..273a9aa823c 100644
--- a/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runner_show/group_runner_show_app.vue
@@ -1,13 +1,15 @@
<script>
import { createAlert, VARIANT_SUCCESS } from '~/flash';
-import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
+import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
+
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
-import RunnerDetails from '../components/runner_details.vue';
+import RunnerDetailsTabs from '../components/runner_details_tabs.vue';
+
import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
@@ -20,7 +22,7 @@ export default {
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
- RunnerDetails,
+ RunnerDetailsTabs,
},
props: {
runnerId: {
@@ -47,7 +49,7 @@ export default {
query: runnerQuery,
variables() {
return {
- id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
+ id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId),
};
},
error(error) {
@@ -89,6 +91,6 @@ export default {
</template>
</runner-header>
- <runner-details v-if="runner" :runner="runner" />
+ <runner-details-tabs :runner="runner" :show-access-help="true" />
</div>
</template>
diff --git a/app/assets/javascripts/ci/runner/group_runner_show/index.js b/app/assets/javascripts/ci/runner/group_runner_show/index.js
index e75f337b38e..a6c1ee1d232 100644
--- a/app/assets/javascripts/ci/runner/group_runner_show/index.js
+++ b/app/assets/javascripts/ci/runner/group_runner_show/index.js
@@ -1,10 +1,12 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import GroupRunnerShowApp from './group_runner_show_app.vue';
Vue.use(VueApollo);
+Vue.use(VueRouter);
export const initGroupRunnerShow = (selector = '#js-group-runner-show') => {
showAlertFromLocalStorage();
diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
index 57ceaa24b6e..e66a1c7b1aa 100644
--- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue
@@ -24,6 +24,7 @@ import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import RunnerMembershipToggle from '../components/runner_membership_toggle.vue';
+import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue';
import { pausedTokenConfig } from '../components/search_tokens/paused_token_config';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
@@ -34,6 +35,7 @@ import {
PROJECT_TYPE,
I18N_FETCH_ERROR,
FILTER_CSS_CLASSES,
+ JOBS_ROUTE_PATH,
} from '../constants';
import { captureException } from '../sentry_utils';
@@ -51,6 +53,7 @@ export default {
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
+ RunnerJobStatusBadge,
},
mixins: [glFeatureFlagMixin()],
inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
@@ -64,10 +67,6 @@ export default {
type: String,
required: true,
},
- groupRunnersLimitedCount: {
- type: Number,
- required: true,
- },
},
data() {
return {
@@ -175,6 +174,12 @@ export default {
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
+ jobsUrl(runner) {
+ const url = new URL(this.webUrl(runner));
+ url.hash = `#${JOBS_ROUTE_PATH}`;
+
+ return url.href;
+ },
refetchCounts() {
this.$apollo.getClient().refetchQueries({ include: [groupRunnersCountQuery] });
},
@@ -255,6 +260,12 @@ export default {
:loading="runnersLoading"
@deleted="onDeleted"
>
+ <template #runner-job-status-badge="{ runner }">
+ <runner-job-status-badge
+ :href="jobsUrl(runner)"
+ :job-status="runner.jobExecutionStatus"
+ />
+ </template>
<template #runner-name="{ runner }">
<gl-link :href="webUrl(runner)">
<runner-name :runner="runner" />
diff --git a/app/assets/javascripts/ci/runner/group_runners/index.js b/app/assets/javascripts/ci/runner/group_runners/index.js
index 0e7efd2b8a1..46514d5afe8 100644
--- a/app/assets/javascripts/ci/runner/group_runners/index.js
+++ b/app/assets/javascripts/ci/runner/group_runners/index.js
@@ -20,7 +20,6 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
runnerInstallHelpPage,
groupId,
groupFullPath,
- groupRunnersLimitedCount,
onlineContactTimeoutSecs,
staleTimeoutSecs,
emptyStateSvgPath,
@@ -50,7 +49,6 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
props: {
registrationToken,
groupFullPath,
- groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10),
},
});
},
diff --git a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
index 879162916a9..4593c9ae52b 100644
--- a/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
+++ b/app/assets/javascripts/ci/runner/runner_edit/runner_edit_app.vue
@@ -1,6 +1,6 @@
<script>
import { createAlert } from '~/flash';
-import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
+import { TYPENAME_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import RunnerHeader from '../components/runner_header.vue';
import RunnerUpdateForm from '../components/runner_update_form.vue';
@@ -35,7 +35,7 @@ export default {
query: runnerFormQuery,
variables() {
return {
- id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
+ id: convertToGraphQLId(TYPENAME_CI_RUNNER, this.runnerId),
};
},
error(error) {
diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/button.vue b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue
new file mode 100644
index 00000000000..799c6ec79d4
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/components/metadata/button.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
+ },
+ props: {
+ secureFile: {
+ type: Object,
+ required: true,
+ },
+ admin: {
+ type: Boolean,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ metadataLabel: __('View File Metadata'),
+ },
+ metadataModalId: 'metadataModalId',
+ methods: {
+ selectSecureFile() {
+ this.$emit('selectSecureFile', this.secureFile);
+ },
+ hasMetadata() {
+ return this.secureFile.metadata !== null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-if="admin && hasMetadata()"
+ v-gl-modal="modalId"
+ v-gl-tooltip.hover.top="$options.i18n.metadataLabel"
+ category="secondary"
+ variant="info"
+ icon="doc-text"
+ :aria-label="$options.i18n.metadataLabel"
+ data-testid="metadata-button"
+ @click="selectSecureFile()"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue
new file mode 100644
index 00000000000..a459b721394
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/components/metadata/modal.vue
@@ -0,0 +1,129 @@
+<script>
+import { GlModal, GlSprintf, GlModalDirective } from '@gitlab/ui';
+import { __, s__, createDateTimeFormat } from '~/locale';
+import Tracking from '~/tracking';
+import MetadataTable from './table.vue';
+
+const dateFormat = createDateTimeFormat({
+ dateStyle: 'long',
+ timeStyle: 'long',
+});
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ MetadataTable,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fileExtension: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ metadata: {
+ type: Object,
+ required: false,
+ default: Object.new,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ metadataLabel: __('View File Metadata'),
+ metadataModalTitle: s__('SecureFiles|%{name} Metadata'),
+ },
+ metadataModalId: 'metadataModalId',
+ methods: {
+ teamName() {
+ return `${this.metadata.subject.O} (${this.metadata.subject.OU})`;
+ },
+ issuerName() {
+ return [this.metadata.issuer.CN, '-', this.metadata.issuer.OU].join(' ');
+ },
+ expiresAt() {
+ return dateFormat.format(new Date(this.metadata.expires_at));
+ },
+ mobileprovisionTeamName() {
+ return `${this.metadata.team_name} (${this.metadata.team_id.join(', ')})`;
+ },
+ platformNames() {
+ return this.metadata.platforms.join(', ');
+ },
+ appName() {
+ return [this.metadata.app_name, '-', this.metadata.app_id].join(' ');
+ },
+ certificates() {
+ return this.metadata.certificate_ids.join(', ');
+ },
+ cerItems() {
+ return [
+ { name: s__('SecureFiles|Name'), data: this.metadata.subject.CN },
+ { name: s__('SecureFiles|Serial'), data: this.metadata.id },
+ { name: s__('SecureFiles|Team'), data: this.teamName() },
+ { name: s__('SecureFiles|Issuer'), data: this.issuerName() },
+ { name: s__('SecureFiles|Expires at'), data: this.expiresAt() },
+ ];
+ },
+ p12Items() {
+ return [
+ { name: s__('SecureFiles|Name'), data: this.metadata.subject.CN },
+ { name: s__('SecureFiles|Serial'), data: this.metadata.id },
+ { name: s__('SecureFiles|Team'), data: this.teamName() },
+ { name: s__('SecureFiles|Issuer'), data: this.issuerName() },
+ { name: s__('SecureFiles|Expires at'), data: this.expiresAt() },
+ ];
+ },
+ mobileprovisionItems() {
+ return [
+ { name: s__('SecureFiles|UUID'), data: this.metadata.id },
+ { name: s__('SecureFiles|Platforms'), data: this.platformNames() },
+ { name: s__('SecureFiles|Team'), data: this.mobileprovisionTeamName() },
+ { name: s__('SecureFiles|App'), data: this.appName() },
+ { name: s__('SecureFiles|Certificates'), data: this.certificates() },
+ { name: s__('SecureFiles|Expires at'), data: this.expiresAt() },
+ ];
+ },
+ items() {
+ if (this.metadata) {
+ if (this.fileExtension === 'cer') {
+ this.track('load_secure_file_metadata_cer');
+ return this.cerItems();
+ } else if (this.fileExtension === 'p12') {
+ this.track('load_secure_file_metadata_p12');
+ return this.p12Items();
+ } else if (this.fileExtension === 'mobileprovision') {
+ this.track('load_secure_file_metadata_mobileprovision');
+ return this.mobileprovisionItems(this.metadata);
+ }
+ }
+
+ return [];
+ },
+ },
+};
+</script>
+``
+
+<template>
+ <gl-modal :ref="modalId" :modal-id="modalId" title-tag="h4" category="primary" hide-footer>
+ <template #modal-title>
+ <gl-sprintf :message="$options.i18n.metadataModalTitle">
+ <template #name>{{ name }}</template>
+ </gl-sprintf>
+ </template>
+
+ <metadata-table :items="items()" />
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/ci_secure_files/components/metadata/table.vue b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue
new file mode 100644
index 00000000000..92043ff0a31
--- /dev/null
+++ b/app/assets/javascripts/ci_secure_files/components/metadata/table.vue
@@ -0,0 +1,36 @@
+<script>
+import { GlTableLite } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTableLite,
+ },
+ props: {
+ items: {
+ required: true,
+ type: Array,
+ },
+ },
+ fields: [
+ {
+ key: 'item_name',
+ thClass: 'hidden',
+ },
+ {
+ key: 'item_data',
+ thClass: 'hidden',
+ },
+ ],
+};
+</script>
+
+<template>
+ <gl-table-lite :items="items" :fields="$options.fields">
+ <template #cell(item_name)="{ item }">
+ <strong>{{ item.name }}</strong>
+ </template>
+ <template #cell(item_data)="{ item }">
+ {{ item.data }}
+ </template>
+ </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
index 661389f4059..dd80698ec1a 100644
--- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -17,6 +17,8 @@ import { HTTP_STATUS_PAYLOAD_TOO_LARGE } from '~/lib/utils/http_status';
import { __, s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import MetadataButton from './metadata/button.vue';
+import MetadataModal from './metadata/modal.vue';
export default {
components: {
@@ -29,6 +31,8 @@ export default {
GlSprintf,
GlTable,
TimeagoTooltip,
+ MetadataButton,
+ MetadataModal,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -57,6 +61,7 @@ export default {
deleteModalButton: s__('SecureFiles|Delete secure file'),
},
deleteModalId: 'deleteModalId',
+ metadataModalId: 'metadataModalId',
data() {
return {
page: 1,
@@ -68,6 +73,7 @@ export default {
projectSecureFiles: [],
deleteModalFileId: null,
deleteModalFileName: null,
+ metadataSecureFile: {},
};
},
fields: [
@@ -162,6 +168,9 @@ export default {
this.deleteModalFileId = secureFile.id;
this.deleteModalFileName = secureFile.name;
},
+ updateMetadataSecureFile(secureFile) {
+ this.metadataSecureFile = secureFile;
+ },
uploadFormData(file) {
const formData = new FormData();
formData.append('name', file.name);
@@ -208,6 +217,12 @@ export default {
</template>
<template #cell(actions)="{ item }">
+ <metadata-button
+ :secure-file="item"
+ :admin="admin"
+ modal-id="$options.metadataModalId"
+ @selectSecureFile="updateMetadataSecureFile"
+ />
<gl-button
v-if="admin"
v-gl-modal="$options.deleteModalId"
@@ -272,5 +287,12 @@ export default {
<template #name>{{ deleteModalFileName }}</template>
</gl-sprintf>
</gl-modal>
+
+ <metadata-modal
+ :name="metadataSecureFile.name"
+ :file-extension="metadataSecureFile.file_extension"
+ :metadata="metadataSecureFile.metadata"
+ modal-id="$options.metadataModalId"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 1f8096da94d..a1b264cfe54 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -8,7 +8,8 @@ import {
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
+import { thWidthPercent } from '~/lib/utils/table_utility';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
@@ -43,43 +44,70 @@ export default {
default: () => [],
},
},
+ data() {
+ return {
+ areValuesHidden: true,
+ };
+ },
fields: [
{
key: 'token',
label: s__('Pipelines|Token'),
+ thClass: thWidthPercent(70),
},
{
key: 'description',
label: s__('Pipelines|Description'),
+ thClass: thWidthPercent(15),
},
{
key: 'owner',
label: s__('Pipelines|Owner'),
+ thClass: thWidthPercent(5),
},
{
key: 'lastUsed',
label: s__('Pipelines|Last Used'),
+ thClass: thWidthPercent(5),
},
{
key: 'actions',
label: '',
tdClass: 'gl-text-right gl-white-space-nowrap',
+ thClass: thWidthPercent(5),
},
],
+ computed: {
+ valuesButtonText() {
+ return this.areValuesHidden ? __('Reveal values') : __('Hide values');
+ },
+ hasTriggers() {
+ return this.triggers.length;
+ },
+ maskedToken() {
+ return '*'.repeat(47);
+ },
+ },
+ methods: {
+ toggleHiddenState() {
+ this.areValuesHidden = !this.areValuesHidden;
+ },
+ },
};
</script>
<template>
<div>
<gl-table
- v-if="triggers.length"
+ v-if="hasTriggers"
:fields="$options.fields"
:items="triggers"
class="triggers-list"
responsive
>
<template #cell(token)="{ item }">
- {{ item.token }}
+ <span v-if="!areValuesHidden">{{ item.token }}</span>
+ <span v-else>{{ maskedToken }}</span>
<clipboard-button
v-if="item.hasTokenExposed"
:text="item.token"
@@ -157,5 +185,11 @@ export default {
>
{{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }}
</gl-alert>
+ <gl-button
+ v-if="hasTriggers"
+ data-testid="reveal-hide-values-button"
+ @click="toggleHiddenState"
+ >{{ valuesButtonText }}</gl-button
+ >
</div>
</template>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 76af552181f..e97d6500260 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -22,7 +22,7 @@ export const EVENT_DETAILS = {
body: s__('ClusterAgents|Agent %{strongStart}connected%{strongEnd}'),
titleIcon: {
name: 'status-success',
- class: 'text-success-500',
+ class: 'gl-text-green-500',
},
},
agent_disconnected: {
@@ -31,7 +31,7 @@ export const EVENT_DETAILS = {
body: s__('ClusterAgents|Agent %{strongStart}disconnected%{strongEnd}'),
titleIcon: {
name: 'severity-critical',
- class: 'text-danger-800',
+ class: 'gl-text-red-800',
},
},
};
@@ -50,12 +50,12 @@ export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}';
export const INTEGRATION_STATUS_VALID_TOKEN = {
icon: 'status-success',
- iconClass: 'text-success-500',
+ iconClass: 'gl-text-green-500',
text: s__('ClusterAgents|Valid access token'),
};
export const INTEGRATION_STATUS_NO_TOKEN = {
icon: 'status-alert',
- iconClass: 'text-danger-500',
+ iconClass: 'gl-text-red-500',
text: s__('ClusterAgents|No agent access token'),
};
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 21524c5b29e..a788703fd08 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -3,11 +3,11 @@ import Visibility from 'visibilityjs';
import Vue from 'vue';
import { createAlert } from '~/flash';
import AccessorUtilities from '~/lib/utils/accessor';
-import initProjectSelectDropdown from '~/project_select';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
import PersistentUserCallout from '~/persistent_user_callout';
import initSettingsPanels from '~/settings_panels';
+import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects';
import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
@@ -62,7 +62,7 @@ export default class Clusters {
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
this.tokenField = document.querySelector('.js-cluster-token');
- initProjectSelectDropdown();
+ initProjectSelects();
Clusters.initDismissableCallout();
initSettingsPanels();
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 615754459d6..fe3fa22fea3 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -144,7 +144,7 @@ export const AGENT_STATUSES = {
active: {
name: s__('ClusterAgents|Connected'),
icon: 'status-success',
- class: 'text-success-500',
+ class: 'gl-text-green-500',
tooltip: {
title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')),
},
@@ -152,7 +152,7 @@ export const AGENT_STATUSES = {
inactive: {
name: s__('ClusterAgents|Not connected'),
icon: 'status-alert',
- class: 'text-danger-500',
+ class: 'gl-text-red-500',
tooltip: {
title: s__('ClusterAgents|Agent might not be connected to GitLab'),
body: sprintf(
@@ -165,7 +165,7 @@ export const AGENT_STATUSES = {
unused: {
name: s__('ClusterAgents|Never connected'),
icon: 'status-neutral',
- class: 'text-secondary-500',
+ class: 'gl-text-gray-500',
tooltip: {
title: s__('ClusterAgents|Agent never connected to GitLab'),
body: s__('ClusterAgents|Make sure you are using a valid token.'),
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index 9cb7cd9607f..c937e65abe3 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -1,11 +1,10 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
},
props: {
projects: {
@@ -19,32 +18,37 @@ export default {
},
},
computed: {
- dropdownText() {
- if (Object.keys(this.selectedProject).length) {
- return this.selectedProject.name;
- }
-
- return __('Select private project');
+ selectedProjectValue() {
+ return this.selectedProject?.id && String(this.selectedProject.id);
+ },
+ toggleText() {
+ return this.selectedProject?.name || __('Select private project');
+ },
+ listboxItems() {
+ return this.projects.map(({ id, name }) => {
+ return {
+ value: String(id),
+ text: name,
+ };
+ });
},
},
methods: {
- selectProject(project) {
- this.$emit('click', project);
+ selectProject(projectId) {
+ const project = this.projects.find(({ id }) => String(id) === projectId);
+ this.$emit('select', project);
},
},
};
</script>
<template>
- <gl-dropdown block icon="lock" :text="dropdownText">
- <gl-dropdown-item
- v-for="project in projects"
- :key="project.id"
- is-check-item
- :is-checked="project.id === selectedProject.id"
- @click="selectProject(project)"
- >
- {{ project.name }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ icon="lock"
+ :items="listboxItems"
+ :selected="selectedProjectValue"
+ :toggle-text="toggleText"
+ block
+ @select="selectProject"
+ />
</template>
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index e95424eef4d..196f5537a90 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -114,7 +114,7 @@ export default {
v-if="projects.length"
:projects="projects"
:selected-project="selectedProject"
- @click="selectProject"
+ @select="selectProject"
/>
<p class="gl-text-gray-600 gl-mt-1 gl-mb-0">
<template v-if="projects.length">
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index 93b31ea7d20..ca17443081c 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -1,18 +1,63 @@
<script>
-import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { GlTooltip, GlDisclosureDropdown } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+import { __ } from '~/locale';
export default {
components: {
- GlDropdown,
- GlDropdownItem,
- },
- directives: {
+ GlDisclosureDropdown,
GlTooltip,
},
inject: ['tiptapEditor'],
data() {
return {
- isActive: {},
+ toggleId: uniqueId('dropdown-toggle-btn-'),
+ items: [
+ {
+ text: __('Comment'),
+ action: () => this.insert('comment'),
+ },
+ {
+ text: __('Code block'),
+ action: () => this.insert('codeBlock'),
+ },
+ {
+ text: __('Details block'),
+ action: () => this.insertList('details', 'detailsContent'),
+ },
+ {
+ text: __('Bullet list'),
+ action: () => this.insertList('bulletList', 'listItem'),
+ wrapperClass: 'gl-sm-display-none!',
+ },
+ {
+ text: __('Ordered list'),
+ action: () => this.insertList('orderedList', 'listItem'),
+ wrapperClass: 'gl-sm-display-none!',
+ },
+ {
+ text: __('Task list'),
+ action: () => this.insertList('taskList', 'taskItem'),
+ wrapperClass: 'gl-sm-display-none!',
+ },
+ {
+ text: __('Horizontal rule'),
+ action: () => this.execute('setHorizontalRule', 'horizontalRule'),
+ },
+ {
+ text: __('Mermaid diagram'),
+ action: () => this.insert('diagram', { language: 'mermaid' }),
+ },
+ {
+ text: __('PlantUML diagram'),
+ action: () => this.insert('diagram', { language: 'plantuml' }),
+ },
+ {
+ text: __('Table of contents'),
+ action: () => this.execute('insertTableOfContents', 'tableOfContents'),
+ },
+ ],
};
},
methods: {
@@ -46,47 +91,17 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
- size="small"
- category="tertiary"
- icon="plus"
- :text="__('More')"
- :title="__('More')"
- text-sr-only
- class="content-editor-dropdown"
- right
- lazy
- >
- <gl-dropdown-item @click="insert('comment')">
- {{ __('Comment') }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="insert('codeBlock')">
- {{ __('Code block') }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="insertList('details', 'detailsContent')">
- {{ __('Details block') }}
- </gl-dropdown-item>
- <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('bulletList', 'listItem')">
- {{ __('Bullet list') }}
- </gl-dropdown-item>
- <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('orderedList', 'listItem')">
- {{ __('Ordered list') }}
- </gl-dropdown-item>
- <gl-dropdown-item class="gl-sm-display-none!" @click="insertList('taskList', 'taskItem')">
- {{ __('Task list') }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="execute('setHorizontalRule', 'horizontalRule')">
- {{ __('Horizontal rule') }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="insert('diagram', { language: 'mermaid' })">
- {{ __('Mermaid diagram') }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })">
- {{ __('PlantUML diagram') }}
- </gl-dropdown-item>
- <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')">
- {{ __('Table of contents') }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <div class="gl-display-inline-flex gl-vertical-align-middle">
+ <gl-disclosure-dropdown
+ :items="items"
+ :toggle-id="toggleId"
+ size="small"
+ category="tertiary"
+ icon="plus"
+ :toggle-text="__('More options')"
+ text-sr-only
+ right
+ />
+ <gl-tooltip :target="toggleId" placement="top">{{ __('More options') }}</gl-tooltip>
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 131c79357bf..540815f57c9 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -514,6 +514,7 @@ export const code = {
open: generateCodeTag(),
close: generateCodeTag(closeTag),
mixable: true,
+ escape: false,
expelEnclosingWhitespace: true,
};
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 4e4c21328ca..17e6cc87ff8 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -1,19 +1,33 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { debounce, uniq } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
+import { visitUrl } from '~/lib/utils/url_utility';
import { getDatesInRange } from '~/lib/utils/datetime_utility';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { __ } from '~/locale';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { xAxisLabelFormatter, dateFormatter } from '../utils';
+const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g;
+
export default {
+ i18n: {
+ history: __('History'),
+ refSelectorTranslations: {
+ dropdownHeader: __('Switch branch/tag'),
+ searchPlaceholder: __('Search branches and tags'),
+ },
+ },
components: {
GlAreaChart,
+ GlButton,
GlLoadingIcon,
ResizableChartContainer,
+ RefSelector,
},
props: {
endpoint: {
@@ -24,7 +38,16 @@ export default {
type: String,
required: true,
},
+ projectId: {
+ type: String,
+ required: true,
+ },
+ commitsPath: {
+ type: String,
+ required: true,
+ },
},
+ refTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS],
data() {
return {
masterChart: null,
@@ -32,6 +55,7 @@ export default {
svgs: {},
masterChartHeight: 264,
individualChartHeight: 216,
+ selectedBranch: this.branch,
};
},
computed: {
@@ -190,6 +214,11 @@ export default {
),
);
},
+ visitBranch(selected) {
+ const graphsPathPrefix = this.endpoint.match(GRAPHS_PATH_REGEX)?.[0];
+
+ visitUrl(`${graphsPathPrefix}/${selected}`);
+ },
},
};
</script>
@@ -197,48 +226,66 @@ export default {
<template>
<div>
<div v-if="loading" class="gl-text-center gl-pt-13">
- <gl-loading-icon :inline="true" size="xl" />
+ <gl-loading-icon :inline="true" size="xl" data-testid="loading-app-icon" />
</div>
- <div v-else-if="showChart" class="contributors-charts">
- <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
- <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- class="gl-mb-5"
- :width="width"
- :data="masterChartData"
- :option="masterChartOptions"
- :height="masterChartHeight"
- @created="onMasterChartCreated"
- />
- </template>
- </resizable-chart-container>
+ <template v-else-if="showChart">
+ <div class="gl-border-b gl-border-gray-100 gl-mb-6 gl-bg-gray-10 gl-p-5">
+ <div class="gl-display-flex">
+ <div class="gl-mr-3">
+ <ref-selector
+ v-model="selectedBranch"
+ :project-id="projectId"
+ :enabled-ref-types="$options.refTypes"
+ :translations="$options.i18n.refSelectorTranslations"
+ @input="visitBranch"
+ />
+ </div>
+ <gl-button :href="commitsPath" data-testid="history-button"
+ >{{ $options.i18n.history }}
+ </gl-button>
+ </div>
+ </div>
+ <div data-testid="contributors-charts">
+ <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
+ <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
+ <resizable-chart-container>
+ <template #default="{ width }">
+ <gl-area-chart
+ class="gl-mb-5"
+ :width="width"
+ :data="masterChartData"
+ :option="masterChartOptions"
+ :height="masterChartHeight"
+ @created="onMasterChartCreated"
+ />
+ </template>
+ </resizable-chart-container>
- <div class="row">
- <div
- v-for="(contributor, index) in individualChartsData"
- :key="index"
- class="col-lg-6 col-12 gl-my-5"
- >
- <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4>
- <p class="gl-mb-3">
- {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
- </p>
- <resizable-chart-container>
- <template #default="{ width }">
- <gl-area-chart
- :width="width"
- :data="contributor.dates"
- :option="individualChartOptions"
- :height="individualChartHeight"
- @created="onIndividualChartCreated"
- />
- </template>
- </resizable-chart-container>
+ <div class="row">
+ <div
+ v-for="(contributor, index) in individualChartsData"
+ :key="index"
+ class="col-lg-6 col-12 gl-my-5"
+ >
+ <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4>
+ <p class="gl-mb-3">
+ {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
+ </p>
+ <resizable-chart-container>
+ <template #default="{ width }">
+ <gl-area-chart
+ :width="width"
+ :data="contributor.dates"
+ :option="individualChartOptions"
+ :height="individualChartHeight"
+ @created="onIndividualChartCreated"
+ />
+ </template>
+ </resizable-chart-container>
+ </div>
</div>
</div>
- </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js
index f66133a074d..1bb7360547c 100644
--- a/app/assets/javascripts/contributors/index.js
+++ b/app/assets/javascripts/contributors/index.js
@@ -7,18 +7,19 @@ export default () => {
if (!el) return null;
- const { projectGraphPath, projectBranch, defaultBranch } = el.dataset;
+ const { projectGraphPath, projectBranch, defaultBranch, projectId, commitsPath } = el.dataset;
const store = createStore(defaultBranch);
return new Vue({
el,
store,
-
render(createElement) {
return createElement(ContributorsGraphs, {
props: {
endpoint: projectGraphPath,
branch: projectBranch,
+ projectId,
+ commitsPath,
},
});
},
diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
index a851c7a9e85..57931121629 100644
--- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
+++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
@@ -1,7 +1,7 @@
<script>
import { s__, __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants';
+import { TYPENAME_CRM_CONTACT, TYPENAME_GROUP } from '~/graphql_shared/constants';
import CrmForm from '../../components/crm_form.vue';
import getGroupOrganizationsQuery from '../../organizations/components/graphql/get_group_organizations.query.graphql';
import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
@@ -44,10 +44,10 @@ export default {
contactGraphQLId() {
if (!this.isEditMode) return null;
- return convertToGraphQLId(TYPE_CRM_CONTACT, this.$route.params.id);
+ return convertToGraphQLId(TYPENAME_CRM_CONTACT, this.$route.params.id);
},
groupGraphQLId() {
- return convertToGraphQLId(TYPE_GROUP, this.groupId);
+ return convertToGraphQLId(TYPENAME_GROUP, this.groupId);
},
mutation() {
if (this.isEditMode) return updateContactMutation;
diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
index 01bff4b69d6..4d2a038458d 100644
--- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
+++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
@@ -1,7 +1,7 @@
<script>
import { s__, __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants';
+import { TYPENAME_CRM_ORGANIZATION, TYPENAME_GROUP } from '~/graphql_shared/constants';
import CrmForm from '../../components/crm_form.vue';
import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
import createOrganizationMutation from './graphql/create_organization.mutation.graphql';
@@ -23,10 +23,10 @@ export default {
organizationGraphQLId() {
if (!this.isEditMode) return null;
- return convertToGraphQLId(TYPE_CRM_ORGANIZATION, this.$route.params.id);
+ return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, this.$route.params.id);
},
groupGraphQLId() {
- return convertToGraphQLId(TYPE_GROUP, this.groupId);
+ return convertToGraphQLId(TYPENAME_GROUP, this.groupId);
},
mutation() {
if (this.isEditMode) return updateOrganizationMutation;
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index 8019a10a042..7503df9194b 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -17,6 +17,7 @@ import { escape, uniqueId } from 'lodash';
import Vue from 'vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { createAlert, VARIANT_INFO } from '~/flash';
+import { sanitize } from '~/lib/dompurify';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@@ -517,8 +518,36 @@ export default class Notes {
if (discussionContainer.length === 0) {
if (noteEntity.diff_discussion_html) {
const discussionElement = document.createElement('table');
- // eslint-disable-next-line no-unsanitized/method
- discussionElement.insertAdjacentHTML('afterbegin', noteEntity.diff_discussion_html);
+ let internalNote;
+ let discussionDOM;
+
+ if (!noteEntity.on_image) {
+ /*
+ DOMPurify will strip table-less <tr>/<td>, so to get it to stop deleting
+ nodes (since our note HTML starts with a table-less <tr>), we need to wrap
+ the noteEntity discussion HTML in a <table> to perform the other
+ sanitization.
+ */
+ internalNote = sanitize(`<table>${noteEntity.diff_discussion_html}</table>`, {
+ RETURN_DOM: true,
+ });
+ /*
+ Since we wrapped the <tr> in a <table>, we need to extract the <tr> back out.
+ DOMPurify returns a Body Element, so we have to start there, then get the
+ wrapping table, and then get the content we actually want.
+ Curiously, DOMPurify **ADDS** a totally novel <tbody>, so we're actually
+ inserting a completely as-yet-unseen <tbody> element here.
+ */
+ discussionDOM = internalNote.querySelector('table').firstChild;
+ } else {
+ // Image comments don't need <table> manipulation, they're already <div>s
+ internalNote = sanitize(noteEntity.diff_discussion_html, {
+ RETURN_DOM: true,
+ });
+ discussionDOM = internalNote.firstChild;
+ }
+
+ discussionElement.insertAdjacentElement('afterbegin', discussionDOM);
renderGFM(discussionElement);
const $discussion = $(discussionElement).unwrap();
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
index 674415ec449..4ce6395140e 100644
--- a/app/assets/javascripts/design_management/components/design_overlay.vue
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -262,6 +262,7 @@ export default {
<div
class="gl-absolute gl-top-0 gl-left-0 frame"
:style="overlayStyle"
+ data-testid="design-overlay"
@mousemove="onOverlayMousemove"
@mouseleave="onNoteMouseup"
>
@@ -287,6 +288,7 @@ export default {
:is-inactive="isNoteInactive(note)"
:is-resolved="note.resolved"
is-on-image
+ data-testid="note-pin"
@mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)"
/>
@@ -294,6 +296,7 @@ export default {
<design-note-pin
v-if="currentCommentForm"
:position="currentCommentPositionStyle"
+ data-testid="comment-badge"
@mousedown.stop="onNoteMousedown"
@mouseup.stop="onNoteMouseup"
/>
diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue
index 5354c7756f5..fd691d1f04e 100644
--- a/app/assets/javascripts/design_management/components/image.vue
+++ b/app/assets/javascripts/design_management/components/image.vue
@@ -72,12 +72,19 @@ export default {
},
setBaseImageSize() {
const { contentImg } = this.$refs;
- if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
+ if (!contentImg) return;
+ if (contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) {
+ this.baseImageSize = {
+ height: contentImg.naturalHeight,
+ width: contentImg.naturalWidth,
+ };
+ } else {
+ this.baseImageSize = {
+ height: contentImg.offsetHeight,
+ width: contentImg.offsetWidth,
+ };
+ }
- this.baseImageSize = {
- height: contentImg.offsetHeight,
- width: contentImg.offsetWidth,
- };
this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
},
setImageNaturalScale() {
@@ -96,6 +103,11 @@ export default {
const { height, width } = this.baseImageSize;
+ this.imageStyle = {
+ width: `${width}px`,
+ height: `${height}px`,
+ };
+
this.$parent.$emit(
'setMaxScale',
Math.round(((height + width) / (naturalHeight + naturalWidth)) * 100) / 100,
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 1e36aa686a4..f52486f0629 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -64,17 +64,17 @@ export default {
const icons = {
creation: {
name: 'file-addition-solid',
- classes: 'text-success-500',
+ classes: 'gl-text-green-500',
tooltip: __('Added in this version'),
},
modification: {
name: 'file-modified-solid',
- classes: 'text-primary-500',
+ classes: 'gl-text-blue-500',
tooltip: __('Modified in this version'),
},
deletion: {
name: 'file-deletion-solid',
- classes: 'text-danger-500',
+ classes: 'gl-text-red-500',
tooltip: __('Archived in this version'),
},
};
@@ -144,7 +144,7 @@ export default {
/>
</span>
</div>
- <gl-intersection-observer @appear="onAppear">
+ <gl-intersection-observer class="gl-flex-grow-1" @appear="onAppear">
<gl-loading-icon v-if="showLoadingSpinner" size="lg" />
<gl-icon
v-else-if="showImageErrorIcon"
@@ -156,7 +156,7 @@ export default {
v-show="showImage"
:src="imageLink"
:alt="filename"
- class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full design-img"
+ class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full gl-w-auto design-img"
data-qa-selector="design_image"
:data-qa-filename="filename"
:data-testid="`design-img-${id}`"
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
index 8c44c5a5d0a..cef2d5e1a18 100644
--- a/app/assets/javascripts/design_management/graphql.js
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -14,7 +14,7 @@ import { CREATE_DESIGN_TODO_EXISTS_ERROR } from './utils/error_messages';
Vue.use(VueApollo);
-const resolvers = {
+export const resolvers = {
Mutation: {
updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
const sourceData = cache.readQuery({ query: activeDiscussionQuery });
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index e5695c4390f..dfca6d61270 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -60,6 +60,16 @@ export default {
type: Boolean,
required: true,
},
+ isFirstHighlightedLine: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLastHighlightedLine: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
fileLineCoverage: {
type: Function,
required: true,
@@ -81,12 +91,23 @@ export default {
),
parallelViewLeftLineType: memoize(
(props) => {
- return utils.parallelViewLeftLineType(props.line, props.isHighlighted || props.isCommented);
+ return utils.parallelViewLeftLineType({
+ line: props.line,
+ highlighted: props.isHighlighted,
+ commented: props.isCommented,
+ selectionStart: props.isFirstHighlightedLine,
+ selectionEnd: props.isLastHighlightedLine,
+ });
},
(props) =>
- [props.line.left?.type, props.line.right?.type, props.isHighlighted, props.isCommented].join(
- ':',
- ),
+ [
+ props.line.left?.type,
+ props.line.right?.type,
+ props.isHighlighted,
+ props.isCommented,
+ props.isFirstHighlightedLine,
+ props.isLastHighlightedLine,
+ ].join(':'),
),
coverageStateLeft: memoize(
(props) => {
@@ -118,20 +139,40 @@ export default {
classNameMapCellLeft: memoize(
(props) => {
return utils.classNameMapCell({
- line: props.line.left,
- hll: props.isHighlighted || props.isCommented,
+ line: props.line?.left,
+ highlighted: props.isHighlighted,
+ commented: props.isCommented,
+ selectionStart: props.isFirstHighlightedLine,
+ selectionEnd: props.isLastHighlightedLine,
});
},
- (props) => [props.line.left.type, props.isHighlighted, props.isCommented].join(':'),
+ (props) =>
+ [
+ props.line?.left?.type,
+ props.isHighlighted,
+ props.isCommented,
+ props.isFirstHighlightedLine,
+ props.isLastHighlightedLine,
+ ].join(':'),
),
classNameMapCellRight: memoize(
(props) => {
return utils.classNameMapCell({
- line: props.line.right,
- hll: props.isHighlighted || props.isCommented,
+ line: props.line?.right,
+ highlighted: props.isHighlighted,
+ commented: props.isCommented,
+ selectionStart: props.isFirstHighlightedLine,
+ selectionEnd: props.isLastHighlightedLine,
});
},
- (props) => [props.line.right.type, props.isHighlighted, props.isCommented].join(':'),
+ (props) =>
+ [
+ props.line?.right?.type,
+ props.isHighlighted,
+ props.isCommented,
+ props.isFirstHighlightedLine,
+ props.isLastHighlightedLine,
+ ].join(':'),
),
shouldRenderCommentButton: memoize(
(props) => {
@@ -303,15 +344,24 @@ export default {
!props.inline || (props.line.left && props.line.left.type === $options.CONFLICT_MARKER)
"
>
- <div data-testid="left-empty-cell" class="diff-td diff-line-num old_line empty-cell">
+ <div
+ data-testid="left-empty-cell"
+ class="diff-td diff-line-num old_line empty-cell"
+ :class="$options.classNameMapCellLeft(props)"
+ >
&nbsp;
</div>
- <div v-if="props.inline" class="diff-td diff-line-num old_line empty-cell"></div>
- <div class="diff-td line-coverage left-side empty-cell"></div>
- <div v-if="props.inline" class="diff-td line-codequality left-side empty-cell"></div>
+ <div
+ class="diff-td line-coverage left-side empty-cell"
+ :class="$options.classNameMapCellLeft(props)"
+ ></div>
+ <div
+ class="diff-td line-codequality left-side empty-cell"
+ :class="$options.classNameMapCellLeft(props)"
+ ></div>
<div
class="diff-td line_content with-coverage left-side empty-cell"
- :class="[{ parallel: !props.inline }]"
+ :class="[{ parallel: !props.inline }, ...$options.classNameMapCellLeft(props)]"
></div>
</template>
</div>
@@ -390,13 +440,13 @@ export default {
:class="[
props.line.right.type,
$options.coverageStateRight(props).class,
- { hll: props.isHighlighted, hll: props.isCommented },
+ ...$options.classNameMapCellRight(props),
]"
class="diff-td line-coverage right-side has-tooltip"
></div>
<div
class="diff-td line-codequality right-side"
- :class="[props.line.right.type, { hll: props.isHighlighted, hll: props.isCommented }]"
+ :class="$options.classNameMapCellRight(props)"
>
<component
:is="$options.CodeQualityGutterIcon"
@@ -414,10 +464,9 @@ export default {
:class="[
props.line.right.type,
{
- hll: props.isHighlighted,
- hll: props.isCommented,
'gl-font-weight-bold': props.line.right.type === $options.CONFLICT_MARKER_THEIR,
},
+ ...$options.classNameMapCellRight(props),
]"
class="diff-td line_content with-coverage right-side parallel"
v-html="
@@ -426,10 +475,23 @@ export default {
></div>
</template>
<template v-else>
- <div data-testid="right-empty-cell" class="diff-td diff-line-num old_line empty-cell"></div>
- <div class="diff-td line-coverage right-side empty-cell"></div>
- <div class="diff-td line-codequality right-side empty-cell"></div>
- <div class="diff-td line_content with-coverage right-side empty-cell parallel"></div>
+ <div
+ data-testid="right-empty-cell"
+ class="diff-td diff-line-num old_line empty-cell"
+ :class="$options.classNameMapCellRight(props)"
+ ></div>
+ <div
+ class="diff-td line-coverage right-side empty-cell"
+ :class="$options.classNameMapCellRight(props)"
+ ></div>
+ <div
+ class="diff-td line-codequality right-side empty-cell"
+ :class="$options.classNameMapCellRight(props)"
+ ></div>
+ <div
+ class="diff-td line_content with-coverage right-side empty-cell parallel"
+ :class="$options.classNameMapCellRight(props)"
+ ></div>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index 479853caae3..a489c96b0c9 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -40,19 +40,33 @@ export const lineCode = (line) => {
return line.line_code || line.left?.line_code || line.right?.line_code;
};
-export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => {
- if (!line) return [];
- const { type } = line;
+export const classNameMapCell = ({
+ line,
+ highlighted,
+ commented,
+ selectionStart,
+ selectionEnd,
+ isLoggedIn,
+ isHover,
+}) => {
+ const classes = {
+ 'highlight-top': highlighted || selectionStart,
+ 'highlight-bottom': highlighted || selectionEnd,
+ hll: highlighted,
+ commented,
+ };
- return [
- type,
- {
- hll,
+ if (line) {
+ const { type } = line;
+ Object.assign(classes, {
+ [type]: true,
[LINE_HOVER_CLASS_NAME]: isLoggedIn && isHover && !isContextLine(type) && !isMetaLine(type),
- old_line: line.type === 'old',
- new_line: line.type === 'new',
- },
- ];
+ old_line: type === 'old',
+ new_line: type === 'new',
+ });
+ }
+
+ return [classes];
};
export const addCommentTooltip = (line) => {
@@ -88,14 +102,28 @@ export const addCommentTooltip = (line) => {
return tooltip;
};
-export const parallelViewLeftLineType = (line, hll) => {
+export const parallelViewLeftLineType = ({
+ line,
+ highlighted,
+ commented,
+ selectionStart,
+ selectionEnd,
+}) => {
if (line?.right?.type === NEW_NO_NEW_LINE_TYPE) {
return OLD_NO_NEW_LINE_TYPE;
}
const lineTypeClass = line?.left ? line.left.type : EMPTY_CELL_TYPE;
- return [lineTypeClass, { hll }];
+ return [
+ lineTypeClass,
+ {
+ hll: highlighted,
+ commented,
+ 'highlight-top': highlighted || selectionStart,
+ 'highlight-bottom': highlighted || selectionEnd,
+ },
+ ];
};
export const shouldShowCommentButton = (hover, context, meta, discussions) => {
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index aa9a17d18e3..a2e052e0f93 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -59,7 +59,12 @@ export default {
},
computed: {
...mapGetters('diffs', ['commitId', 'fileLineCoverage']),
- ...mapState('diffs', ['codequalityDiff', 'highlightedRow', 'coverageLoaded']),
+ ...mapState('diffs', [
+ 'codequalityDiff',
+ 'highlightedRow',
+ 'coverageLoaded',
+ 'selectedCommentPosition',
+ ]),
...mapState({
selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover,
@@ -144,6 +149,14 @@ export default {
false,
);
},
+ isFirstHighlightedLine(line) {
+ const lineCode = line.left?.line_code || line.right?.line_code;
+ return lineCode && lineCode === this.selectedCommentPosition?.start.line_code;
+ },
+ isLastHighlightedLine(line) {
+ const lineCode = line.left?.line_code || line.right?.line_code;
+ return lineCode && lineCode === this.selectedCommentPosition?.end.line_code;
+ },
handleParallelLineMouseDown(e) {
const line = e.target.closest('.diff-td');
if (line) {
@@ -230,10 +243,14 @@ export default {
:line="line"
:is-bottom="index + 1 === diffLinesLength"
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
+ :is-highlighted="isHighlighted(line)"
+ :is-first-highlighted-line="
+ isFirstHighlightedLine(line) || index === commentedLines.startLine
+ "
+ :is-last-highlighted-line="isLastHighlightedLine(line) || index === commentedLines.endLine"
:inline="inline"
:index="index"
:code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
- :is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage"
:coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)"
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index abf77fa2ede..8bb1872567c 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -101,7 +101,7 @@ export default {
</button>
</div>
</div>
- <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
+ <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList || search }" class="tree-list-scroll">
<template v-if="filteredTreeList.length">
<file-tree
v-for="file in filteredTreeList"
@@ -112,6 +112,9 @@ export default {
:hide-file-stats="hideFileStats"
:file-row-component="$options.DiffFileRow"
:current-diff-file-id="currentDiffFileId"
+ :style="{ '--level': 0 }"
+ :class="{ 'tree-list-parent': file.tree.length }"
+ class="gl-relative"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="(path) => scrollToFile({ path })"
/>
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index e6f7a31e07b..f90d29c84b8 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -87,8 +87,8 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const processingFileCount = this.getQueuedFiles().length + this.getUploadingFiles().length;
const shouldPad = processingFileCount >= 1;
+ addFileToForm(response.link.url, header.size);
pasteText(response.link.markdown, shouldPad);
- addFileToForm(response.link.url);
},
error: (file, errorMessage = __('Attaching the file failed.'), xhr) => {
// If 'error' event is fired by dropzone, the second parameter is error message.
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 87d869cc996..57477a993c5 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -417,6 +417,20 @@
"type": "object",
"additionalProperties": false,
"properties": {
+ "component": {
+ "description": "Local path to component directory or full path to external component directory.",
+ "type": "string",
+ "format": "uri-reference"
+ }
+ },
+ "required": [
+ "component"
+ ]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
"remote": {
"description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.",
"type": "string",
@@ -777,7 +791,7 @@
"properties": {
"value": {
"type": "string",
- "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#prefill-variables-in-manual-pipelines)"
+ "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesvalue)"
},
"options": {
"type": "array",
@@ -786,7 +800,7 @@
},
"minItems": 1,
"uniqueItems": true,
- "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#configure-a-list-of-selectable-values-for-a-prefilled-variable)"
+ "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variablesoptions)"
},
"description": {
"type": "string",
@@ -1955,4 +1969,4 @@
"additionalProperties": false
}
}
-} \ No newline at end of file
+}
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index 1bac0ef1359..ee5d95ae6f0 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -3,6 +3,10 @@ import { GlButton, GlForm, GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@
import { helpPagePath } from '~/helpers/help_page_helper';
import { isAbsolute } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import {
+ ENVIRONMENT_NEW_HELP_TEXT,
+ ENVIRONMENT_EDIT_HELP_TEXT,
+} from 'ee_else_ce/environments/constants';
export default {
components: {
@@ -13,6 +17,7 @@ export default {
GlLink,
GlSprintf,
},
+ inject: ['protectedEnvironmentSettingsPath'],
props: {
environment: {
required: true,
@@ -34,9 +39,8 @@ export default {
},
i18n: {
header: __('Environments'),
- helpMessage: __(
- 'Environments allow you to track deployments of your application. %{linkStart}More information%{linkEnd}.',
- ),
+ helpNewMessage: ENVIRONMENT_NEW_HELP_TEXT,
+ helpEditMessage: ENVIRONMENT_EDIT_HELP_TEXT,
nameLabel: __('Name'),
nameFeedback: __('This field is required'),
nameDisabledHelp: __("You cannot rename an environment after it's created."),
@@ -62,6 +66,9 @@ export default {
isNameDisabled() {
return Boolean(this.environment.id);
},
+ showEditHelp() {
+ return this.isNameDisabled && Boolean(this.protectedEnvironmentSettingsPath);
+ },
valid() {
return {
name: this.visited.name && this.environment.name !== '',
@@ -89,9 +96,14 @@ export default {
{{ $options.i18n.header }}
</h4>
<p class="gl-w-full">
- <gl-sprintf :message="$options.i18n.helpMessage">
+ <gl-sprintf
+ :message="showEditHelp ? $options.i18n.helpEditMessage : $options.i18n.helpNewMessage"
+ >
<template #link="{ content }">
- <gl-link :href="$options.helpPagePath">{{ content }}</gl-link>
+ <gl-link
+ :href="showEditHelp ? protectedEnvironmentSettingsPath : $options.helpPagePath"
+ >{{ content }}</gl-link
+ >
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 55e6a891e27..b2a69cdb6c6 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -15,6 +15,7 @@ import { ENVIRONMENTS_SCOPE } from '../constants';
import EnvironmentFolder from './environment_folder.vue';
import EnableReviewAppModal from './enable_review_app_modal.vue';
import StopEnvironmentModal from './stop_environment_modal.vue';
+import StopStaleEnvironmentsModal from './stop_stale_environments_modal.vue';
import EnvironmentItem from './new_environment_item.vue';
import ConfirmRollbackModal from './confirm_rollback_modal.vue';
import DeleteEnvironmentModal from './delete_environment_modal.vue';
@@ -31,6 +32,7 @@ export default {
EnableReviewAppModal,
EnvironmentItem,
StopEnvironmentModal,
+ StopStaleEnvironmentsModal,
GlBadge,
GlPagination,
GlSearchBoxByType,
@@ -75,6 +77,7 @@ export default {
i18n: {
newEnvironmentButtonLabel: s__('Environments|New environment'),
reviewAppButtonLabel: s__('Environments|Enable review app'),
+ cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'),
available: __('Available'),
stopped: __('Stopped'),
prevPage: __('Go to previous page'),
@@ -85,11 +88,13 @@ export default {
searchPlaceholder: s__('Environments|Search by environment name'),
},
modalId: 'enable-review-app-info',
+ stopStaleEnvsModalId: 'stop-stale-environments-modal',
data() {
const { page = '1', search = '', scope } = queryToObject(window.location.search);
return {
interval: undefined,
isReviewAppModalVisible: false,
+ isStopStaleEnvModalVisible: false,
page: parseInt(page, 10),
pageInfo: {},
scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope)
@@ -107,6 +112,9 @@ export default {
canSetupReviewApp() {
return this.environmentApp?.reviewApp?.canSetupReviewApp;
},
+ canCleanUpEnvs() {
+ return this.environmentApp?.canStopStaleEnvironments;
+ },
folders() {
return this.environmentApp?.environments?.filter((e) => e.size > 1) ?? [];
},
@@ -149,6 +157,19 @@ export default {
},
};
},
+ openCleanUpEnvsModal() {
+ if (!this.canCleanUpEnvs) {
+ return null;
+ }
+
+ return {
+ text: this.$options.i18n.cleanUpEnvsButtonLabel,
+ attributes: {
+ category: 'secondary',
+ variant: 'confirm',
+ },
+ };
+ },
stoppedCount() {
return this.environmentApp?.stoppedCount;
},
@@ -178,6 +199,9 @@ export default {
showReviewAppModal() {
this.isReviewAppModalVisible = true;
},
+ showCleanUpEnvsModal() {
+ this.isStopStaleEnvModalVisible = true;
+ },
setScope(scope) {
this.scope = scope;
this.moveToPage(1);
@@ -219,16 +243,24 @@ export default {
:modal-id="$options.modalId"
data-testid="enable-review-app-modal"
/>
+ <stop-stale-environments-modal
+ v-if="canCleanUpEnvs"
+ v-model="isStopStaleEnvModalVisible"
+ :modal-id="$options.stopStaleEnvsModalId"
+ data-testid="stop-stale-environments-modal"
+ />
<delete-environment-modal :environment="environmentToDelete" graphql />
<stop-environment-modal :environment="environmentToStop" graphql />
<confirm-rollback-modal :environment="environmentToRollback" graphql />
<canary-update-modal :environment="environmentToChangeCanary" :weight="weight" />
<gl-tabs
- :action-secondary="addEnvironment"
- :action-primary="openReviewAppModal"
+ :action-secondary="openReviewAppModal"
+ :action-primary="openCleanUpEnvsModal"
+ :action-tertiary="addEnvironment"
sync-active-tab-with-query-params
query-param-name="scope"
- @primary="showReviewAppModal"
+ @secondary="showReviewAppModal"
+ @primary="showCleanUpEnvsModal"
>
<gl-tab
:query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE"
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 9a100e0199e..73dfd993c5b 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -323,6 +323,7 @@ export default {
>
<deployment
:deployment="upcomingDeployment"
+ :visible="visible"
:class="{ 'gl-ml-7': inFolder }"
class="gl-pl-4"
>
diff --git a/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue
new file mode 100644
index 00000000000..57873b28d37
--- /dev/null
+++ b/app/assets/javascripts/environments/components/stop_stale_environments_modal.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlTooltipDirective, GlModal, GlDatepicker, GlFormGroup } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { stopStaleEnvironments } from '~/rest_api';
+import { MIN_STALE_ENVIRONMENT_DATE, MAX_STALE_ENVIRONMENT_DATE } from '../constants';
+
+export default {
+ id: 'stop-stale-environments-modal',
+ name: 'StopStaleEnvironmentsModal',
+
+ components: {
+ GlModal,
+ GlDatepicker,
+ GlFormGroup,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: {
+ projectId: {
+ default: '',
+ },
+ },
+ model: {
+ prop: 'visible',
+ event: 'change',
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ modalProps: {
+ primary: {
+ text: s__('Environments|Clean up'),
+ attributes: [{ variant: 'info' }],
+ },
+ cancel: {
+ text: __('Cancel'),
+ },
+ dateRange: {
+ minDate: MIN_STALE_ENVIRONMENT_DATE, // 10 years ago
+ maxDate: MAX_STALE_ENVIRONMENT_DATE,
+ },
+ },
+
+ data() {
+ return {
+ stopEnvironmentsBefore: MAX_STALE_ENVIRONMENT_DATE,
+ };
+ },
+
+ methods: {
+ onSubmit() {
+ stopStaleEnvironments(this.projectId, this.stopEnvironmentsBefore || this.maxDate);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ :action-primary="$options.modalProps.primary"
+ :action-cancel="$options.modalProps.cancel"
+ :visible="visible"
+ :modal-id="modalId"
+ :title="s__('Environments|Clean up environments')"
+ static
+ @primary="onSubmit"
+ @change="$emit('change', $event)"
+ >
+ <p>
+ {{
+ s__(
+ 'Environments|Select which environments to clean up. \
+ Protected environments are excluded. Learn more about cleaning up environments.',
+ )
+ }}
+ </p>
+
+ <gl-form-group
+ :label="s__('Environments|Stop unused environments')"
+ :label-description="
+ s__('Environments|Stop environments that have not been updated since the specified date:')
+ "
+ label-for="stop_environments-before"
+ >
+ <gl-datepicker
+ v-model="stopEnvironmentsBefore"
+ input-id="stop-environments-before"
+ data-testid="stop-environments-before"
+ :min-date="$options.modalProps.dateRange.minDate"
+ :max-date="$options.modalProps.dateRange.maxDate"
+ :default-date="$options.modalProps.dateRange.maxDate"
+ />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index c4d02da9d21..28424322dd2 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -1,4 +1,5 @@
import { __, s__ } from '~/locale';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
// These statuses are based on how the backend defines pod phases here
// lib/gitlab/kubernetes/pod.rb
@@ -77,3 +78,12 @@ export const REVIEW_APP_MODAL_I18N = {
viewMoreExampleProjects: s__('EnableReviewApp|View more example projects'),
copyToClipboardText: s__('EnableReviewApp|Copy snippet'),
};
+
+export const MIN_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 3650); // 10 years ago
+export const MAX_STALE_ENVIRONMENT_DATE = getDateInPast(new Date(), 7); // one week ago
+
+export const ENVIRONMENT_NEW_HELP_TEXT = __(
+ 'Environments allow you to track deployments of your application.%{linkStart} More information.%{linkEnd}',
+);
+
+export const ENVIRONMENT_EDIT_HELP_TEXT = ENVIRONMENT_NEW_HELP_TEXT;
diff --git a/app/assets/javascripts/environments/edit.js b/app/assets/javascripts/environments/edit.js
index dd6680f64bd..a128d2fb3c7 100644
--- a/app/assets/javascripts/environments/edit.js
+++ b/app/assets/javascripts/environments/edit.js
@@ -7,6 +7,7 @@ export default (el) =>
provide: {
projectEnvironmentsPath: el.dataset.projectEnvironmentsPath,
updateEnvironmentPath: el.dataset.updateEnvironmentPath,
+ protectedEnvironmentSettingsPath: el.dataset.protectedEnvironmentSettingsPath,
},
render(h) {
return h(EditEnvironment, {
diff --git a/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
new file mode 100644
index 00000000000..77d9311743c
--- /dev/null
+++ b/app/assets/javascripts/environments/environment_details/components/deployment_actions.vue
@@ -0,0 +1,31 @@
+<script>
+import ActionsComponent from '~/environments/components/environment_actions.vue';
+
+export default {
+ components: {
+ ActionsComponent,
+ },
+ props: {
+ actions: {
+ // actions shape:
+ /* Array<{
+ playable: boolean,
+ playPath: url,
+ name: string
+ scheduledAt: ISO_timestamp | null
+ }>
+ */
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ isActionsShown() {
+ return this.actions.length > 0;
+ },
+ },
+};
+</script>
+<template>
+ <actions-component v-if="isActionsShown" :actions="actions" graphql />
+</template>
diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js
index bf690ffedeb..3b33d6a676e 100644
--- a/app/assets/javascripts/environments/environment_details/constants.js
+++ b/app/assets/javascripts/environments/environment_details/constants.js
@@ -45,6 +45,12 @@ export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [
columnClass: 'gl-w-10p',
tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap',
},
+ {
+ key: 'actions',
+ label: __('Actions'),
+ columnClass: 'gl-w-10p',
+ tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap',
+ },
];
export const translations = {
diff --git a/app/assets/javascripts/environments/environment_details/deployments_table.vue b/app/assets/javascripts/environments/environment_details/deployments_table.vue
index 41570ee44c0..10f8c06e581 100644
--- a/app/assets/javascripts/environments/environment_details/deployments_table.vue
+++ b/app/assets/javascripts/environments/environment_details/deployments_table.vue
@@ -5,11 +5,13 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DeploymentStatusLink from './components/deployment_status_link.vue';
import DeploymentJob from './components/deployment_job.vue';
import DeploymentTriggerer from './components/deployment_triggerer.vue';
+import DeploymentActions from './components/deployment_actions.vue';
import { ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants';
export default {
components: {
DeploymentTriggerer,
+ DeploymentActions,
DeploymentJob,
Commit,
TimeAgoTooltip,
@@ -51,5 +53,8 @@ export default {
<template #cell(deployed)="{ item }">
<time-ago-tooltip :time="item.deployed" />
</template>
+ <template #cell(actions)="{ item }">
+ <deployment-actions :actions="item.actions" />
+ </template>
</gl-table-lite>
</template>
diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue
index b43f4233b9c..f4657c5100a 100644
--- a/app/assets/javascripts/environments/environment_details/index.vue
+++ b/app/assets/javascripts/environments/environment_details/index.vue
@@ -59,7 +59,11 @@ export default {
},
computed: {
deployments() {
- return this.project.environment?.deployments.nodes.map(convertToDeploymentTableRow) || [];
+ return (
+ this.project.environment?.deployments.nodes.map((deployment) =>
+ convertToDeploymentTableRow(deployment, this.project.environment),
+ ) || []
+ );
},
isLoading() {
return this.$apollo.queries.project.loading;
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
index 1a572208a1c..7a50ded7d6c 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
@@ -4,5 +4,6 @@ query getEnvironmentApp($page: Int, $scope: String, $search: String) {
stoppedCount
environments
reviewApp
+ canStopStaleEnvironments
}
}
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
index c6c2024c840..0182b3a7234 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql
@@ -13,6 +13,13 @@ query getEnvironmentDetails(
environment(name: $environmentName) {
id
name
+ lastDeployment(status: SUCCESS) {
+ id
+ job {
+ id
+ name
+ }
+ }
deployments(
orderBy: { createdAt: DESC }
first: $first
@@ -36,6 +43,19 @@ query getEnvironmentDetails(
name
id
webPath
+ playable
+ deploymentPipeline: pipeline {
+ id
+ jobs(whenExecuted: ["manual"], retried: false) {
+ nodes {
+ id
+ name
+ playable
+ scheduledAt
+ webPath
+ }
+ }
+ }
}
commit {
id
diff --git a/app/assets/javascripts/environments/graphql/resolvers.js b/app/assets/javascripts/environments/graphql/resolvers.js
index afd56d0cf0d..e21670870b8 100644
--- a/app/assets/javascripts/environments/graphql/resolvers.js
+++ b/app/assets/javascripts/environments/graphql/resolvers.js
@@ -54,6 +54,7 @@ export const resolvers = (endpoint) => ({
...convertObjectPropsToCamelCase(res.data.review_app),
__typename: 'ReviewApp',
},
+ canStopStaleEnvironments: res.data.can_stop_stale_environments,
stoppedCount: res.data.stopped_count,
__typename: 'LocalEnvironmentApp',
};
diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
index bfe92fe3125..9802dcbcf78 100644
--- a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
+++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js
@@ -41,22 +41,46 @@ export const getCommitFromDeploymentNode = (deploymentNode) => {
};
};
+export const convertJobToDeploymentAction = (job) => {
+ return {
+ name: job.name,
+ playable: job.playable,
+ scheduledAt: job.scheduledAt,
+ playPath: `${job.webPath}/play`,
+ };
+};
+
+export const getActionsFromDeploymentNode = (deploymentNode, lastDeploymentName) => {
+ if (!deploymentNode || !lastDeploymentName) {
+ return [];
+ }
+
+ return (
+ deploymentNode.job?.deploymentPipeline?.jobs?.nodes
+ ?.filter((deployment) => deployment.name !== lastDeploymentName)
+ .map(convertJobToDeploymentAction) || []
+ );
+};
+
/**
* This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table
* @param {Object} deploymentNode
* @returns {Object}
*/
-export const convertToDeploymentTableRow = (deploymentNode) => {
+export const convertToDeploymentTableRow = (deploymentNode, environment) => {
+ const { lastDeployment } = environment;
+ const commit = getCommitFromDeploymentNode(deploymentNode);
return {
status: deploymentNode.status.toLowerCase(),
id: deploymentNode.iid,
triggerer: deploymentNode.triggerer,
- commit: getCommitFromDeploymentNode(deploymentNode),
+ commit,
job: deploymentNode.job && {
webPath: deploymentNode.job.webPath,
label: `${deploymentNode.job.name} (#${getIdFromGraphQLId(deploymentNode.job.id)})`,
},
created: deploymentNode.createdAt || '',
deployed: deploymentNode.finishedAt || '',
+ actions: getActionsFromDeploymentNode(deploymentNode, lastDeployment?.job?.name),
};
};
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index cebf73ef8e5..483f1d2c7a0 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -3,56 +3,11 @@ import Vue from 'vue';
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
-const FLASH_TYPES = {
- ALERT: 'alert',
- NOTICE: 'notice',
- SUCCESS: 'success',
- WARNING: 'warning',
-};
-
-const VARIANT_SUCCESS = 'success';
-const VARIANT_WARNING = 'warning';
-const VARIANT_DANGER = 'danger';
-const VARIANT_INFO = 'info';
-const VARIANT_TIP = 'tip';
-
-const FLASH_CLOSED_EVENT = 'flashClosed';
-
-const getCloseEl = (flashEl) => {
- return flashEl.querySelector('.js-close-icon');
-};
-
-const hideFlash = (flashEl, fadeTransition = true) => {
- if (fadeTransition) {
- Object.assign(flashEl.style, {
- transition: 'opacity 0.15s',
- opacity: '0',
- });
- }
-
- flashEl.addEventListener(
- 'transitionend',
- () => {
- 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');
- },
- {
- once: true,
- passive: true,
- },
- );
-
- if (!fadeTransition) flashEl.dispatchEvent(new Event('transitionend'));
-};
-
-const addDismissFlashClickListener = (flashEl, fadeTransition) => {
- // There are some flash elements which do not have a closeEl.
- // https://gitlab.com/gitlab-org/gitlab/blob/763426ef344488972eb63ea5be8744e0f8459e6b/ee/app/views/layouts/header/_read_only_banner.html.haml
- getCloseEl(flashEl)?.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
-};
+export const VARIANT_SUCCESS = 'success';
+export const VARIANT_WARNING = 'warning';
+export const VARIANT_DANGER = 'danger';
+export const VARIANT_INFO = 'info';
+export const VARIANT_TIP = 'tip';
/**
* Render an alert at the top of the page, or, optionally an
@@ -96,7 +51,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => {
* @param {boolean} [options.captureError] - Whether to send error to Sentry
* @param {object} [options.error] - Error to be captured in Sentry
*/
-const createAlert = function createAlert({
+export const createAlert = ({
message,
title,
variant = VARIANT_DANGER,
@@ -108,7 +63,7 @@ const createAlert = function createAlert({
onDismiss = null,
captureError = false,
error = null,
-}) {
+}) => {
if (captureError && error) Sentry.captureException(error);
const alertContainer = parent.querySelector(containerSelector);
@@ -180,16 +135,3 @@ const createAlert = function createAlert({
},
});
};
-
-export {
- hideFlash,
- addDismissFlashClickListener,
- FLASH_TYPES,
- FLASH_CLOSED_EVENT,
- createAlert,
- VARIANT_SUCCESS,
- VARIANT_WARNING,
- VARIANT_DANGER,
- VARIANT_INFO,
- VARIANT_TIP,
-};
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index a4e883c96b5..947d3053094 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -6,6 +6,7 @@ import {
mapVuexModuleActions,
mapVuexModuleGetters,
} from '~/lib/utils/vuex_module_mappers';
+import Tracking from '~/tracking';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import eventHub from '../event_hub';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
@@ -13,6 +14,8 @@ import FrequentItemsList from './frequent_items_list.vue';
import frequentItemsMixin from './frequent_items_mixin';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
+const trackingMixin = Tracking.mixin();
+
export default {
components: {
FrequentItemsSearchInput,
@@ -24,7 +27,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [frequentItemsMixin],
+ mixins: [frequentItemsMixin, trackingMixin],
inject: ['vuexModule'],
props: {
currentUserName: {
@@ -84,6 +87,13 @@ export default {
'toggleItemsListEditablity',
'fetchFrequentItems',
]),
+ toggleItemsListEditablityTracked() {
+ this.track('click_button', {
+ label: 'toggle_edit_frequent_items',
+ property: 'navigation_top',
+ });
+ this.toggleItemsListEditablity();
+ },
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
@@ -155,7 +165,7 @@ export default {
:title="translations.headerEditToggle"
:class="{ 'gl-bg-gray-100!': isItemsListEditable }"
class="gl-p-2!"
- @click="toggleItemsListEditablity"
+ @click="toggleItemsListEditablityTracked"
>
<gl-icon name="pencil" :class="{ 'gl-text-gray-900!': isItemsListEditable }" />
</gl-button>
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 75ea9beb5cf..056dedf8757 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { snakeCase } from 'lodash';
import SafeHtml from '~/vue_shared/directives/safe_html';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -61,10 +60,17 @@ export default {
return highlight(this.itemName, this.matcher);
},
itemTrackingLabel() {
- return `${this.dropdownType}_dropdown_frequent_items_list_item_${snakeCase(this.itemName)}`;
+ return `${this.dropdownType}_dropdown_frequent_items_list_item`;
},
},
methods: {
+ removeFrequentItemTracked(item) {
+ this.track('click_button', {
+ label: `${this.dropdownType}_dropdown_remove_frequent_item`,
+ property: 'navigation_top',
+ });
+ this.removeFrequentItem(item);
+ },
...mapVuexModuleActions((vm) => vm.vuexModule, ['removeFrequentItem']),
},
};
@@ -78,7 +84,7 @@ export default {
class="gl-text-left gl-w-full"
button-text-classes="gl-display-flex gl-w-full"
data-testid="frequent-item-link"
- @click="track('click_link', { label: itemTrackingLabel })"
+ @click="track('click_link', { label: itemTrackingLabel, property: 'navigation_top' })"
>
<div class="gl-flex-grow-1">
<project-avatar
@@ -116,9 +122,9 @@ export default {
category="tertiary"
:aria-label="__('Remove')"
:title="__('Remove')"
- class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-top-4 gl-right-4"
+ class="gl-align-self-center gl-p-1! gl-absolute! gl-w-auto! gl-right-4 gl-top-half gl-translate-y-n50"
data-testid="item-remove"
- @click.stop.prevent="removeFrequentItem(itemId)"
+ @click.stop.prevent="removeFrequentItemTracked(itemId)"
>
<gl-icon name="close" />
</gl-button>
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
index 4a1b7e57749..023245f050b 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
@@ -28,12 +28,25 @@ export default {
searchQuery: debounce(function debounceSearchQuery() {
this.track('type_search_query', {
label: `${this.dropdownType}_dropdown_frequent_items_search_input`,
+ property: 'navigation_top',
});
this.setSearchQuery(this.searchQuery);
}, 500),
},
methods: {
...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']),
+ trackFocus() {
+ this.track('focus_input', {
+ label: `${this.dropdownType}_dropdown_frequent_items_search_input`,
+ property: 'navigation_top',
+ });
+ },
+ trackBlur() {
+ this.track('blur_input', {
+ label: `${this.dropdownType}_dropdown_frequent_items_search_input`,
+ property: 'navigation_top',
+ });
+ },
},
};
</script>
@@ -43,6 +56,8 @@ export default {
<gl-search-box-by-type
v-model="searchQuery"
:placeholder="translations.searchInputPlaceholder"
+ @focus="trackFocus"
+ @blur="trackBlur"
/>
</div>
</template>
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 2b157fac878..f4008fe3cc9 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,5 +1,6 @@
import autosize from 'autosize';
import $ from 'jquery';
+import { isEmpty } from 'lodash';
import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete';
import { disableButtonIfEmptyField } from '~/lib/utils/common_utils';
import dropzoneInput from './dropzone_input';
@@ -12,14 +13,22 @@ export default class GLForm {
* @param {jQuery} form Root element of the GLForm
* @param {Object} enableGFM Which autocomplete features should be enabled?
* @param {Boolean} forceNew If true, treat the element as a **new** form even if `gfm-form` class already exists.
+ * @param {Object} gfmDataSources The paths of the autocomplete data sources to use for GfmAutoComplete
+ * By default, the backend embeds these in the global object gl.GfmAutocomplete.dataSources.
+ * Use this param to override them.
*/
- constructor(form, enableGFM = {}, forceNew = false) {
+ constructor(form, enableGFM = {}, forceNew = false, gfmDataSources = {}) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
// Disable autocomplete for keywords which do not have dataSources available
- const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
+ let dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
+
+ if (!isEmpty(gfmDataSources)) {
+ dataSources = gfmDataSources;
+ }
+
Object.keys(this.enableGFM).forEach((item) => {
if (item !== 'emojis' && !dataSources[item]) {
this.enableGFM[item] = false;
@@ -29,7 +38,7 @@ export default class GLForm {
// Before we start, we should clean up any previous data for this form
this.destroy();
// Set up the form
- this.setupForm(forceNew);
+ this.setupForm(dataSources, forceNew);
this.form.data('glForm', this);
}
@@ -46,7 +55,7 @@ export default class GLForm {
this.form.data('glForm', null);
}
- setupForm(forceNew = false) {
+ setupForm(dataSources, forceNew = false) {
const isNewForm = this.form.is(':not(.gfm-form)') || forceNew;
this.form.removeClass('js-new-note-form');
if (isNewForm) {
@@ -57,7 +66,7 @@ export default class GLForm {
this.form.find('.js-note-text'),
this.form.find('.js-comment-button, .js-note-new-discussion'),
);
- this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
+ this.autoComplete = new GfmAutoComplete(dataSources);
this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM);
this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 });
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index 0a4733de65f..ad339155a59 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -13,7 +13,7 @@ export default class GpgBadges {
return Promise.resolve();
}
- const badges = $('.js-loading-gpg-badge');
+ const badges = $('.js-loading-signature-badge');
badges.html(loadingIconForLegacyJS());
badges.children().attr('aria-label', __('Loading'));
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 22fa2912881..3c4ca4c197e 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -1,23 +1,28 @@
export const MINIMUM_SEARCH_LENGTH = 3;
-export const TYPE_BOARD = 'Board';
-export const TYPE_CI_RUNNER = 'Ci::Runner';
-export const TYPE_CRM_CONTACT = 'CustomerRelations::Contact';
-export const TYPE_CRM_ORGANIZATION = 'CustomerRelations::Organization';
-export const TYPE_DISCUSSION = 'Discussion';
-export const TYPE_EPIC = 'Epic';
-export const TYPE_EPIC_BOARD = 'Boards::EpicBoard';
-export const TYPE_GROUP = 'Group';
-export const TYPE_ISSUE = 'Issue';
-export const TYPE_ITERATION = 'Iteration';
-export const TYPE_ITERATIONS_CADENCE = 'Iterations::Cadence';
-export const TYPE_MERGE_REQUEST = 'MergeRequest';
-export const TYPE_MILESTONE = 'Milestone';
-export const TYPE_NOTE = 'Note';
-export const TYPE_PACKAGES_PACKAGE = 'Packages::Package';
-export const TYPE_PROJECT = 'Project';
-export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
-export const TYPE_SITE_PROFILE = 'DastSiteProfile';
-export const TYPE_USER = 'User';
-export const TYPE_VULNERABILITY = 'Vulnerability';
-export const TYPE_WORK_ITEM = 'WorkItem';
+export const TYPENAME_BOARD = 'Board';
+export const TYPENAME_CI_BUILD = 'Ci::Build';
+export const TYPENAME_CI_PIPELINE = 'Ci::Pipeline';
+export const TYPENAME_CI_RUNNER = 'Ci::Runner';
+export const TYPENAME_CI_VARIABLE = 'Ci::Variable';
+export const TYPENAME_COMMIT_STATUS = 'CommitStatus';
+export const TYPENAME_CRM_CONTACT = 'CustomerRelations::Contact';
+export const TYPENAME_CRM_ORGANIZATION = 'CustomerRelations::Organization';
+export const TYPENAME_DISCUSSION = 'Discussion';
+export const TYPENAME_EPIC = 'Epic';
+export const TYPENAME_EPIC_BOARD = 'Boards::EpicBoard';
+export const TYPENAME_GROUP = 'Group';
+export const TYPENAME_ISSUE = 'Issue';
+export const TYPENAME_ITERATION = 'Iteration';
+export const TYPENAME_ITERATIONS_CADENCE = 'Iterations::Cadence';
+export const TYPENAME_MERGE_REQUEST = 'MergeRequest';
+export const TYPENAME_MILESTONE = 'Milestone';
+export const TYPENAME_NOTE = 'Note';
+export const TYPENAME_PACKAGES_PACKAGE = 'Packages::Package';
+export const TYPENAME_PROJECT = 'Project';
+export const TYPENAME_SCANNER_PROFILE = 'DastScannerProfile';
+export const TYPENAME_SITE_PROFILE = 'DastSiteProfile';
+export const TYPENAME_USER = 'User';
+export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner';
+export const TYPENAME_VULNERABILITY = 'Vulnerability';
+export const TYPENAME_WORK_ITEM = 'WorkItem';
diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index 01cc2fc3018..316bc746051 100644
--- a/app/assets/javascripts/graphql_shared/issuable_client.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -6,6 +6,8 @@ import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.grap
import createDefaultClient from '~/lib/graphql';
import typeDefs from '~/work_items/graphql/typedefs.graphql';
import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
+import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql';
+import { findHierarchyWidgetChildren } from '~/work_items/utils';
export const config = {
typeDefs,
@@ -13,7 +15,9 @@ export const config = {
// included temporarily until Vuex is removed from boards app
dataIdFromObject: (object) => {
// eslint-disable-next-line no-underscore-dangle
- return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
+ return object.__typename === 'BoardList' && !window.gon?.features?.apolloBoards
+ ? object.iid
+ : defaultDataIdFromObject(object);
},
typePolicies: {
Project: {
@@ -72,6 +76,7 @@ export const config = {
},
};
}
+
return incomingWidget || existingWidget;
});
},
@@ -83,12 +88,85 @@ export const config = {
nodes: concatPagination(),
},
},
+ ...(window.gon?.features?.apolloBoards
+ ? {
+ BoardList: {
+ fields: {
+ issues: {
+ keyArgs: ['filters'],
+ },
+ },
+ },
+ IssueConnection: {
+ merge(existing = { nodes: [] }, incoming, { args }) {
+ if (!args.after) {
+ return incoming;
+ }
+ return {
+ ...incoming,
+ nodes: [...existing.nodes, ...incoming.nodes],
+ };
+ },
+ },
+ EpicList: {
+ fields: {
+ epics: {
+ keyArgs: ['filters'],
+ },
+ },
+ },
+ EpicConnection: {
+ merge(existing = { nodes: [] }, incoming, { args }) {
+ if (!args.after) {
+ return incoming;
+ }
+ return {
+ ...incoming,
+ nodes: [...existing.nodes, ...incoming.nodes],
+ };
+ },
+ },
+ BoardEpicConnection: {
+ merge(existing = { nodes: [] }, incoming, { args }) {
+ if (!args.after) {
+ return incoming;
+ }
+ return {
+ ...incoming,
+ nodes: [...existing.nodes, ...incoming.nodes],
+ };
+ },
+ },
+ }
+ : {}),
},
},
};
export const resolvers = {
Mutation: {
+ addHierarchyChild: (_, { id, workItem }, { cache }) => {
+ const queryArgs = { query: getWorkItemLinksQuery, variables: { id } };
+ const sourceData = cache.readQuery(queryArgs);
+
+ const data = produce(sourceData, (draftState) => {
+ findHierarchyWidgetChildren(draftState.workItem).push(workItem);
+ });
+
+ cache.writeQuery({ ...queryArgs, data });
+ },
+ removeHierarchyChild: (_, { id, workItem }, { cache }) => {
+ const queryArgs = { query: getWorkItemLinksQuery, variables: { id } };
+ const sourceData = cache.readQuery(queryArgs);
+
+ const data = produce(sourceData, (draftState) => {
+ const hierarchyChildren = findHierarchyWidgetChildren(draftState.workItem);
+ const index = hierarchyChildren.findIndex((child) => child.id === workItem.id);
+ hierarchyChildren.splice(index, 1);
+ });
+
+ cache.writeQuery({ ...queryArgs, data });
+ },
updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
const sourceData = cache.readQuery({ query: getIssueStateQuery });
const data = produce(sourceData, (draftData) => {
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index a622b342c0a..4a5536986bd 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -156,6 +156,7 @@
"WorkItemWidgetRequirementLegacy",
"WorkItemWidgetStartAndDueDate",
"WorkItemWidgetStatus",
+ "WorkItemWidgetTestReports",
"WorkItemWidgetWeight"
]
-}
+} \ No newline at end of file
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 8fb70eb59bd..806e89d6e9f 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -104,3 +104,15 @@ export const convertNodeIdsFromGraphQLIds = (nodes) => {
return nodes.map((node) => (node.id ? { ...node, id: getIdFromGraphQLId(node.id) } : node));
};
+
+/**
+ * This function takes a GraphQL query data as a required argument and
+ * the field name to resolve as an optional argument
+ * and returns resolved field's data or an empty array
+ * @param {Object} queryData
+ * @param {String} nodesField (in most cases it will be 'nodes')
+ * @returns {Array}
+ */
+export const getNodesOrDefault = (queryData, nodesField = 'nodes') => {
+ return queryData?.[nodesField] ?? [];
+};
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 46d5341ea97..148bf0a98ee 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui';
import { createAlert } from '~/flash';
+import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status';
import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -225,7 +226,7 @@ export default {
})
.catch((err) => {
let message = COMMON_STR.FAILURE;
- if (err.status === 403) {
+ if (err.status === HTTP_STATUS_FORBIDDEN) {
message = COMMON_STR.LEAVE_FORBIDDEN;
}
createAlert({ message });
diff --git a/app/assets/javascripts/groups/init_transfer_group_form.js b/app/assets/javascripts/groups/init_transfer_group_form.js
index 503dad673dd..6eab284c066 100644
--- a/app/assets/javascripts/groups/init_transfer_group_form.js
+++ b/app/assets/javascripts/groups/init_transfer_group_form.js
@@ -17,6 +17,7 @@ export default () => {
targetFormId = null,
buttonText: confirmButtonText = '',
groupName = '',
+ groupFullPath,
groupId: resourceId,
isPaidGroup,
} = el.dataset;
@@ -35,7 +36,7 @@ export default () => {
props: {
isPaidGroup: parseBoolean(isPaidGroup),
confirmButtonText,
- confirmationPhrase: groupName,
+ confirmationPhrase: groupFullPath,
},
on: {
confirm: () => {
diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue
index e0c8ce36e3c..360af772a10 100644
--- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue
+++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue
@@ -25,6 +25,7 @@ export const i18n = {
'ProjectTransfer|An error occurred fetching the transfer locations, please refresh the page and try again.',
),
ALERT_DISMISS_LABEL: __('Dismiss'),
+ NO_RESULTS_TEXT: __('No results found.'),
};
export default {
@@ -90,6 +91,9 @@ export default {
hasGroupTransferLocations() {
return this.groupTransferLocations.length;
},
+ hasAdditionalDropdownItems() {
+ return this.filteredAdditionalDropdownItems.length;
+ },
selectedText() {
return this.value?.humanName || this.label;
},
@@ -99,6 +103,17 @@ export default {
showAdditionalDropdownItems() {
return !this.isLoading && this.filteredAdditionalDropdownItems.length;
},
+ hasNoResults() {
+ if (this.isLoading || this.isSearchLoading) {
+ return false;
+ }
+
+ return (
+ !this.hasAdditionalDropdownItems &&
+ !this.hasUserTransferLocations &&
+ !this.hasGroupTransferLocations
+ );
+ },
},
watch: {
searchTerm() {
@@ -274,6 +289,9 @@ export default {
>{{ item.humanName }}</gl-dropdown-item
>
</div>
+ <gl-dropdown-item v-if="hasNoResults" button-class="gl-text-gray-900!" disabled>{{
+ $options.i18n.NO_RESULTS_TEXT
+ }}</gl-dropdown-item>
<gl-loading-icon v-if="isLoading" class="gl-mb-3" size="sm" />
<gl-intersection-observer v-if="hasNextPageOfGroups" @appear="handleLoadMoreGroups" />
</gl-dropdown>
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index f58781fa9ec..6c9354b663f 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -33,6 +33,13 @@ function initStatusTriggers() {
if (setStatusModalTriggerEl) {
setStatusModalTriggerEl.addEventListener('click', () => {
+ const topNavbar = document.querySelector('.navbar-gitlab');
+ const buttonWithinTopNav = topNavbar && topNavbar.contains(setStatusModalTriggerEl);
+ Tracking.event(undefined, 'click_button', {
+ label: 'user_edit_status',
+ property: buttonWithinTopNav ? 'navigation_top' : undefined,
+ });
+
import(
/* webpackChunkName: 'statusModalBundle' */ './set_status_modal/set_status_modal_wrapper.vue'
)
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index bf5daf29b21..ace0d77c431 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -171,7 +171,7 @@ export default {
Tracking.event(undefined, 'focus_input', {
label: 'global_search',
- property: 'top_navigation',
+ property: 'navigation_top',
});
}
},
@@ -190,7 +190,7 @@ export default {
Tracking.event(undefined, 'blur_input', {
label: 'global_search',
- property: 'top_navigation',
+ property: 'navigation_top',
});
}, 200);
},
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index cda3379309c..65e113e5084 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -77,3 +77,5 @@ export const DROPDOWN_ORDER = [
];
export const FETCH_TYPES = ['generic', 'search'];
+
+export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index f6f5c6a14fa..f6963263725 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -1,36 +1,44 @@
import Vue from 'vue';
+import * as Sentry from '@sentry/browser';
import Translate from '~/vue_shared/translate';
import HeaderSearchApp from './components/app.vue';
import createStore from './store';
+import { SEARCH_INPUT_FIELD_MAX_WIDTH } from './constants';
Vue.use(Translate);
export const initHeaderSearchApp = (search = '') => {
const el = document.getElementById('js-header-search');
- let navBarEl = null;
+ const headerEl = document.querySelector('.header-content');
- if (!el) {
+ if (!el && !headerEl) {
return false;
}
+ const searchContainer = headerEl.querySelector('.global-search-container');
+ const newHeader = headerEl.querySelector('.header-search-new');
+
const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset;
let { searchContext } = el.dataset;
- searchContext = JSON.parse(searchContext);
+
+ try {
+ searchContext = JSON.parse(searchContext);
+ newHeader.style.maxWidth = SEARCH_INPUT_FIELD_MAX_WIDTH;
+ } catch (error) {
+ Sentry.captureException(error);
+ }
return new Vue({
el,
store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext, search }),
- mounted() {
- navBarEl = document.querySelector('.header-content');
- },
render(createElement) {
return createElement(HeaderSearchApp, {
on: {
expandSearchBar: () => {
- navBarEl?.classList.add('header-search-is-active');
+ searchContainer.style.flexGrow = '1';
},
collapseSearchBar: () => {
- navBarEl?.classList.remove('header-search-is-active');
+ searchContainer.style.flexGrow = '0';
},
},
});
diff --git a/app/assets/javascripts/helpers/init_simple_app_helper.js b/app/assets/javascripts/helpers/init_simple_app_helper.js
new file mode 100644
index 00000000000..695fc455f13
--- /dev/null
+++ b/app/assets/javascripts/helpers/init_simple_app_helper.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+
+/**
+ * Initializes a component as a simple vue app, passing the necessary props. If the element
+ * has a data attribute named `data-view-model`, the content of that attributed will be
+ * converted from json and passed on to the component as a prop. The root component is then
+ * responsible for setting up it's children, injections, and other desired features.
+ *
+ * @param {string} selector css selector for where to build
+ * @param {Vue.component} component The Vue compoment to be built as the root of the app
+ *
+ * @example
+ * ```html
+ * <div id='#mount-here' data-view-model="{'some': 'object'}" />
+ * ```
+ *
+ * ```javascript
+ * initSimpleApp('#mount-here', MyApp)
+ * ```
+ *
+ * This will mount MyApp as root on '#mount-here'. It will receive {'some': 'object'} as it's
+ * view model prop.
+ */
+export const initSimpleApp = (selector, component) => {
+ const element = document.querySelector(selector);
+
+ if (!element) {
+ return null;
+ }
+
+ const props = element.dataset.viewModel ? JSON.parse(element.dataset.viewModel) : {};
+
+ return new Vue({
+ el: element,
+ render(h) {
+ return h(component, { props });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index da2d4fbe7f0..8342b3f428c 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -1,10 +1,9 @@
<script>
-import { mapGetters, mapState } from 'vuex';
+import { mapState } from 'vuex';
import { __ } from '~/locale';
import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants';
import JobsDetail from '../jobs/detail.vue';
import PipelinesList from '../pipelines/list.vue';
-import Clientside from '../preview/clientside.vue';
import ResizablePanel from '../resizable_panel.vue';
import TerminalView from '../terminal/view.vue';
import CollapsibleSidebar from './collapsible_sidebar.vue';
@@ -20,12 +19,8 @@ export default {
},
computed: {
...mapState('terminal', { isTerminalVisible: 'isVisible' }),
- ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
- ...mapGetters(['packageJson']),
+ ...mapState(['currentMergeRequestId']),
...mapState('rightPane', ['isOpen']),
- showLivePreview() {
- return this.packageJson && this.clientsidePreviewEnabled;
- },
rightExtensionTabs() {
return [
{
@@ -38,12 +33,6 @@ export default {
icon: 'rocket',
},
{
- show: this.showLivePreview,
- title: __('Live preview'),
- views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }],
- icon: 'live-preview',
- },
- {
show: this.isTerminalVisible,
title: __('Terminal'),
views: [{ component: TerminalView, ...rightSidebarViews.terminal }],
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
deleted file mode 100644
index 70b881b6ff6..00000000000
--- a/app/assets/javascripts/ide/components/preview/clientside.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { listen } from 'codesandbox-api';
-import { isEmpty, debounce } from 'lodash';
-import { SandpackClient } from '@codesandbox/sandpack-client';
-import { mapActions, mapGetters, mapState } from 'vuex';
-import {
- packageJsonPath,
- LIVE_PREVIEW_DEBOUNCE,
- PING_USAGE_PREVIEW_KEY,
- PING_USAGE_PREVIEW_SUCCESS_KEY,
-} from '../../constants';
-import eventHub from '../../eventhub';
-import { createPathWithExt } from '../../utils';
-import Navigator from './navigator.vue';
-
-export default {
- components: {
- Navigator,
- GlLoadingIcon,
- },
- data() {
- return {
- client: {},
- loading: false,
- sandpackReady: false,
- };
- },
- computed: {
- ...mapState(['entries', 'promotionSvgPath', 'links', 'codesandboxBundlerUrl']),
- ...mapGetters(['packageJson', 'currentProject']),
- normalizedEntries() {
- return Object.keys(this.entries).reduce((acc, path) => {
- const file = this.entries[path];
-
- if (file.type === 'tree' || !(file.raw || file.content)) return acc;
-
- return {
- ...acc,
- [`/${path}`]: {
- code: file.content || file.raw,
- },
- };
- }, {});
- },
- mainEntry() {
- if (!this.packageJson.raw) return false;
-
- const parsedPackage = JSON.parse(this.packageJson.raw);
-
- return parsedPackage.main;
- },
- showPreview() {
- return this.mainEntry && !this.loading;
- },
- showEmptyState() {
- return !this.mainEntry && !this.loading;
- },
- showOpenInCodeSandbox() {
- return this.currentProject && this.currentProject.visibility === 'public';
- },
- sandboxOpts() {
- return {
- files: { ...this.normalizedEntries },
- entry: `/${this.mainEntry}`,
- showOpenInCodeSandbox: this.showOpenInCodeSandbox,
- };
- },
- },
- watch: {
- sandpackReady: {
- handler(val) {
- if (val) {
- this.pingUsage(PING_USAGE_PREVIEW_SUCCESS_KEY);
- }
- },
- },
- },
- mounted() {
- this.onFilesChangeCallback = debounce(() => this.update(), LIVE_PREVIEW_DEBOUNCE);
- eventHub.$on('ide.files.change', this.onFilesChangeCallback);
-
- this.loading = true;
-
- return this.loadFileContent(packageJsonPath)
- .then(() => {
- this.loading = false;
- })
- .then(() => this.$nextTick())
- .then(() => this.initPreview());
- },
- beforeDestroy() {
- // Setting sandpackReady = false protects us form a phantom `update()` being called when `debounce` finishes.
- this.sandpackReady = false;
- eventHub.$off('ide.files.change', this.onFilesChangeCallback);
-
- if (!isEmpty(this.client)) {
- this.client.cleanup();
- }
-
- this.client = {};
-
- if (this.listener) {
- this.listener();
- }
- },
- methods: {
- ...mapActions(['getFileData', 'getRawFileData']),
- ...mapActions('clientside', ['pingUsage']),
- loadFileContent(path) {
- return this.getFileData({ path, makeFileActive: false }).then(() =>
- this.getRawFileData({ path }),
- );
- },
- initPreview() {
- if (!this.mainEntry) return null;
-
- this.pingUsage(PING_USAGE_PREVIEW_KEY);
-
- return this.loadFileContent(this.mainEntry)
- .then(() => this.$nextTick())
- .then(() => {
- this.initClient();
-
- this.listener = listen((e) => {
- switch (e.type) {
- case 'done':
- this.sandpackReady = true;
- break;
- default:
- break;
- }
- });
- });
- },
- update() {
- if (!this.sandpackReady) return;
-
- if (isEmpty(this.client)) {
- this.initPreview();
-
- return;
- }
-
- this.client.updatePreview(this.sandboxOpts);
- },
- initClient() {
- const { codesandboxBundlerUrl: bundlerURL } = this;
-
- const settings = {
- fileResolver: {
- isFile: (p) => Promise.resolve(Boolean(this.entries[createPathWithExt(p)])),
- readFile: (p) => this.loadFileContent(createPathWithExt(p)).then((content) => content),
- },
- ...(bundlerURL ? { bundlerURL } : {}),
- };
-
- this.client = new SandpackClient('#ide-preview', this.sandboxOpts, settings);
- },
- },
-};
-</script>
-
-<template>
- <div class="preview h-100 w-100 d-flex flex-column gl-bg-white">
- <template v-if="showPreview">
- <navigator :client="client" />
- <div id="ide-preview"></div>
- </template>
- <div
- v-else-if="showEmptyState"
- v-once
- class="d-flex h-100 flex-column align-items-center justify-content-center svg-content"
- >
- <img :src="promotionSvgPath" :alt="s__('IDE|Live Preview')" width="130" height="100" />
- <h3>{{ s__('IDE|Live Preview') }}</h3>
- <p class="text-center">
- {{ s__('IDE|Preview your web application using Web IDE client-side evaluation.') }}
- </p>
- <a
- :href="links.webIDEHelpPagePath"
- class="btn gl-button btn-confirm"
- target="_blank"
- rel="noopener noreferrer"
- >
- {{ s__('IDE|Get started with Live Preview') }}
- </a>
- </div>
- <gl-loading-icon v-else size="lg" class="align-self-center mt-auto mb-auto" />
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
deleted file mode 100644
index 852de16d508..00000000000
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ /dev/null
@@ -1,136 +0,0 @@
-<script>
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
-import { listen } from 'codesandbox-api';
-
-export default {
- components: {
- GlIcon,
- GlLoadingIcon,
- },
- props: {
- client: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- currentBrowsingIndex: null,
- navigationStack: [],
- forwardNavigationStack: [],
- path: '',
- loading: true,
- };
- },
- computed: {
- backButtonDisabled() {
- return this.navigationStack.length <= 1;
- },
- forwardButtonDisabled() {
- return !this.forwardNavigationStack.length;
- },
- },
- mounted() {
- this.listener = listen((e) => {
- switch (e.type) {
- case 'urlchange':
- this.onUrlChange(e);
- break;
- case 'done':
- this.loading = false;
- break;
- default:
- break;
- }
- });
- },
- beforeDestroy() {
- this.listener();
- },
- methods: {
- onUrlChange(e) {
- const lastPath = this.path;
-
- this.path = e.url.replace(this.client.bundlerURL, '') || '/';
-
- if (lastPath !== this.path) {
- this.currentBrowsingIndex =
- this.currentBrowsingIndex === null ? 0 : this.currentBrowsingIndex + 1;
- this.navigationStack.push(this.path);
- }
- },
- back() {
- const lastPath = this.path;
-
- this.visitPath(this.navigationStack[this.currentBrowsingIndex - 1]);
-
- this.forwardNavigationStack.push(lastPath);
-
- if (this.currentBrowsingIndex === 1) {
- this.currentBrowsingIndex = null;
- this.navigationStack = [];
- }
- },
- forward() {
- this.visitPath(this.forwardNavigationStack.splice(0, 1)[0]);
- },
- refresh() {
- this.visitPath(this.path);
- },
- visitPath(path) {
- // eslint-disable-next-line vue/no-mutating-props
- this.client.iframe.src = `${this.client.bundlerURL}${path}`;
- },
- },
-};
-</script>
-
-<template>
- <header class="ide-preview-header d-flex align-items-center">
- <button
- :aria-label="s__('IDE|Back')"
- :disabled="backButtonDisabled"
- :class="{
- 'disabled-content': backButtonDisabled,
- }"
- type="button"
- class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
- @click="back"
- >
- <gl-icon :size="24" name="chevron-left" class="m-auto" />
- </button>
- <button
- :aria-label="s__('IDE|Back')"
- :disabled="forwardButtonDisabled"
- :class="{
- 'disabled-content': forwardButtonDisabled,
- }"
- type="button"
- class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
- @click="forward"
- >
- <gl-icon :size="24" name="chevron-right" class="m-auto" />
- </button>
- <button
- :aria-label="s__('IDE|Refresh preview')"
- type="button"
- class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
- @click="refresh"
- >
- <gl-icon :size="16" name="retry" class="m-auto" />
- </button>
- <div class="position-relative w-100 gl-ml-2">
- <input
- :value="path || '/'"
- type="text"
- class="ide-navigator-location form-control bg-white"
- readonly
- />
- <gl-loading-icon
- v-if="loading"
- size="sm"
- class="position-absolute ide-preview-loading-icon"
- />
- </div>
- </header>
-</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 01ce5fa07ee..1aa64656c30 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -64,7 +64,6 @@ export const rightSidebarViews = {
pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
- clientSidePreview: { name: 'clientside', keepAlive: false },
terminal: { name: 'terminal', keepAlive: true },
};
@@ -101,22 +100,13 @@ export const commitActionTypes = {
update: 'update',
};
-export const packageJsonPath = 'package.json';
-
export const SIDE_LEFT = 'left';
export const SIDE_RIGHT = 'right';
-// Live Preview feature
-export const LIVE_PREVIEW_DEBOUNCE = 2000;
-
// This is the maximum number of files to auto open when opening the Web IDE
// from a merge request
export const MAX_MR_FILES_AUTO_OPEN = 10;
export const DEFAULT_BRANCH = 'main';
-// Ping Usage Metrics Keys
-export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview';
-export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success';
-
export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367';
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 1347d92b3b7..29c44d2f596 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -67,10 +67,8 @@ export const initLegacyWebIDE = (el, options = {}) => {
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.init({
- clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
- codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl,
environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
previewMarkdownPath: el.dataset.previewMarkdownPath,
userPreferencesPath: el.dataset.userPreferencesPath,
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
index d3c64754e8a..4d3cefcb107 100644
--- a/app/assets/javascripts/ide/init_gitlab_web_ide.js
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -7,6 +7,7 @@ import csrf from '~/lib/utils/csrf';
import { getBaseConfig } from './lib/gitlab_web_ide/get_base_config';
import { setupRootElement } from './lib/gitlab_web_ide/setup_root_element';
import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants';
+import { handleTracking } from './lib/gitlab_web_ide/handle_tracking_event';
const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => {
const remotePath = cleanLeadingSeparator(remotePathArg);
@@ -38,6 +39,9 @@ export const initGitlabWebIDE = async (el) => {
filePath,
mergeRequest: mrId,
forkInfo: forkInfoJSON,
+ editorFontSrcUrl,
+ editorFontFormat,
+ editorFontFamily,
} = el.dataset;
const rootEl = setupRootElement(el);
@@ -64,6 +68,12 @@ export const initGitlabWebIDE = async (el) => {
feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE,
userPreferences: el.dataset.userPreferencesPath,
},
+ editorFont: {
+ srcUrl: editorFontSrcUrl,
+ fontFamily: editorFontFamily,
+ format: editorFontFormat,
+ },
+ handleTracking,
async handleStartRemote({ remoteHost, remotePath, connectionToken }) {
const confirmed = await confirmAction(
__('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'),
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index 289027c3054..7a516f5e3f5 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -1,12 +1,5 @@
-import { useNewFonts } from '~/lib/utils/common_utils';
import { getCssVariable } from '~/lib/utils/css_utils';
-const fontOptions = {};
-
-if (useNewFonts()) {
- fontOptions.fontFamily = getCssVariable('--code-editor-font');
-}
-
export const defaultEditorOptions = {
model: null,
readOnly: false,
@@ -18,7 +11,7 @@ export const defaultEditorOptions = {
wordWrap: 'on',
glyphMargin: true,
automaticLayout: true,
- ...fontOptions,
+ fontFamily: getCssVariable('--code-editor-font'),
};
export const defaultDiffOptions = {
diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js
new file mode 100644
index 00000000000..615dad02386
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/handle_tracking_event.js
@@ -0,0 +1,20 @@
+import { snakeCase } from 'lodash';
+import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
+import Tracking from '~/tracking';
+
+export const handleTracking = ({ name, data }) => {
+ const snakeCaseEventName = snakeCase(name);
+
+ if (data && Object.keys(data).length) {
+ Tracking.event(undefined, snakeCaseEventName, {
+ /* See GitLab snowplow schema for a definition of the extra field
+ * https://docs.gitlab.com/ee/development/snowplow/schemas.html#gitlab_standard.
+ */
+ extra: convertObjectPropsToSnakeCase(data, {
+ deep: true,
+ }),
+ });
+ } else {
+ Tracking.event(undefined, snakeCaseEventName);
+ }
+};
diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js
index 78990953beb..f437965b25a 100644
--- a/app/assets/javascripts/ide/lib/mirror.js
+++ b/app/assets/javascripts/ide/lib/mirror.js
@@ -1,3 +1,4 @@
+import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import createDiff from './create_diff';
@@ -26,7 +27,7 @@ const cancellableWait = (time) => {
const isErrorResponse = (error) => error && error.code !== 0;
-const isErrorPayload = (payload) => payload && payload.status_code !== 200;
+const isErrorPayload = (payload) => payload && payload.status_code !== HTTP_STATUS_OK;
const getErrorFromResponse = (data) => {
if (isErrorResponse(data.error)) {
diff --git a/app/assets/javascripts/ide/remote/index.js b/app/assets/javascripts/ide/remote/index.js
index fb8db20c0c1..6966786ca4e 100644
--- a/app/assets/javascripts/ide/remote/index.js
+++ b/app/assets/javascripts/ide/remote/index.js
@@ -1,6 +1,7 @@
import { startRemote } from '@gitlab/web-ide';
import { getBaseConfig, setupRootElement } from '~/ide/lib/gitlab_web_ide';
import { isSameOriginUrl, joinPaths } from '~/lib/utils/url_utility';
+import { handleTracking } from '~/ide/lib/gitlab_web_ide/handle_tracking_event';
/**
* @param {Element} rootEl
@@ -36,5 +37,6 @@ export const mountRemoteIDE = async (el) => {
// TODO Handle error better
handleError: visitReturnUrl,
handleClose: visitReturnUrl,
+ handleTracking,
});
};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index dc0f3a1d7e9..b7445d3ad0a 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,6 +1,7 @@
import { escape } from 'lodash';
import Vue from 'vue';
import { createAlert } from '~/flash';
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import {
@@ -278,7 +279,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force =
resolve(data);
})
.catch((e) => {
- if (e.response.status === 404) {
+ if (e.response.status === HTTP_STATUS_NOT_FOUND) {
reject(e);
} else {
createAlert({
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 3c02b1d1da7..c0f666c6652 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -2,7 +2,6 @@ import Api from '~/api';
import { addNumericSuffix } from '~/ide/utils';
import {
leftSidebarViews,
- packageJsonPath,
DEFAULT_PERMISSIONS,
PERMISSION_READ_MR,
PERMISSION_CREATE_MR,
@@ -153,8 +152,6 @@ export const currentBranch = (state, getters) =>
export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name;
-export const packageJson = (state) => state.entries[packageJsonPath];
-
export const isOnDefaultBranch = (_state, getters) =>
getters.currentProject && getters.currentProject.default_branch === getters.branchName;
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index b660ff178a2..c2f7126159c 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -3,7 +3,6 @@ import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import branches from './modules/branches';
-import clientsideModule from './modules/clientside';
import commitModule from './modules/commit';
import editorModule from './modules/editor';
import { setupFileEditorsSync } from './modules/editor/setup';
@@ -29,7 +28,6 @@ export const createStoreOptions = () => ({
branches,
fileTemplates: fileTemplates(),
rightPane: paneModule(),
- clientside: clientsideModule(),
router: routerModule,
editor: editorModule,
},
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
deleted file mode 100644
index 1a8e665867f..00000000000
--- a/app/assets/javascripts/ide/stores/modules/clientside/actions.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export const pingUsage = ({ rootGetters }, metricName) => {
- const { web_url: projectUrl } = rootGetters.currentProject;
-
- const url = `${projectUrl}/service_ping/${metricName}`;
-
- return axios.post(url);
-};
-
-export default pingUsage;
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js
deleted file mode 100644
index b28f7b935a8..00000000000
--- a/app/assets/javascripts/ide/stores/modules/clientside/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import * as actions from './actions';
-
-export default () => ({
- namespaced: true,
- actions,
-});
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index b89d9d38a1a..356bbf28a48 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -26,10 +26,8 @@ export default () => ({
path: '',
entry: {},
},
- clientsidePreviewEnabled: false,
renderWhitespaceInCode: false,
editorTheme: DEFAULT_THEME,
- codesandboxBundlerUrl: null,
environmentsGuidanceAlertDismissed: false,
environmentsGuidanceAlertDetected: false,
previewMarkdownPath: '',
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index db677c574d1..6dc0b2cec24 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -65,7 +65,9 @@ const STATUS_MAP = {
};
function isIncompleteImport(stats) {
- return Object.keys(stats.fetched).some((key) => stats.fetched[key] !== stats.imported[key]);
+ return Object.keys(stats?.fetched ?? []).some(
+ (key) => stats.fetched[key] !== stats.imported[key],
+ );
}
export default {
@@ -91,7 +93,9 @@ export default {
computed: {
knownStats() {
const knownStatisticKeys = Object.keys(STATISTIC_ITEMS);
- return Object.keys(this.stats.fetched).filter((key) => knownStatisticKeys.includes(key));
+ return Object.keys(this.stats?.fetched ?? []).filter((key) =>
+ knownStatisticKeys.includes(key),
+ );
},
hasStats() {
@@ -142,7 +146,13 @@ export default {
<template>
<div>
<div class="gl-display-inline-block gl-w-13">
- <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" class="gl-mr-2">
+ <gl-badge
+ :icon="mappedStatus.icon"
+ :variant="mappedStatus.variant"
+ size="md"
+ icon-size="sm"
+ class="gl-mr-2"
+ >
{{ mappedStatus.text }}
</gl-badge>
</div>
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 8d72942447c..ed7c9e7abe9 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
@@ -46,7 +46,7 @@ export default {
<template>
<span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
<gl-dropdown
- v-if="isProjectsImportEnabled && isAvailableForImport"
+ v-if="isProjectsImportEnabled && (isAvailableForImport || isFinished)"
:text="isFinished ? __('Re-import with projects') : __('Import with projects')"
:disabled="isInvalid"
variant="confirm"
@@ -60,7 +60,7 @@ export default {
}}</gl-dropdown-item>
</gl-dropdown>
<gl-button
- v-else-if="isAvailableForImport"
+ v-else-if="isAvailableForImport || isFinished"
:disabled="isInvalid"
variant="confirm"
category="secondary"
@@ -70,7 +70,7 @@ export default {
{{ isFinished ? __('Re-import') : __('Import') }}
</gl-button>
<gl-icon
- v-if="isAvailableForImport && isFinished"
+ v-if="isFinished"
v-gl-tooltip
:size="16"
name="information-o"
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 c590d832568..7d2ddd2176b 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
@@ -17,6 +17,7 @@ import {
import { debounce } from 'lodash';
import { createAlert } from '~/flash';
import { s__, __, n__, sprintf } from '~/locale';
+import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
@@ -102,8 +103,12 @@ export default {
perPage: DEFAULT_PAGE_SIZE,
selectedGroupsIds: [],
pendingGroupsIds: [],
+ reimportRequests: [],
importTargets: {},
unavailableFeaturesAlertVisible: true,
+ helpUrl: helpPagePath('ee/user/group/import', {
+ anchor: 'visibility-rules',
+ }),
};
},
@@ -177,9 +182,14 @@ export default {
const importTarget = this.importTargets[group.id];
const status = this.getStatus(group);
+ const isGroupAvailableForImport = isFinished(group)
+ ? this.reimportRequests.includes(group.id)
+ : isAvailableForImport(group) && status !== STATUSES.SCHEDULING;
+
const flags = {
- isInvalid: importTarget.validationErrors?.length > 0,
- isAvailableForImport: isAvailableForImport(group) && status !== STATUSES.SCHEDULING,
+ isInvalid: (importTarget.validationErrors ?? []).filter((e) => !e.nonBlocking).length > 0,
+ isAvailableForImport: isGroupAvailableForImport,
+ isAllowedForReimport: false,
isFinished: isFinished(group),
};
@@ -355,13 +365,9 @@ export default {
this.validateImportTarget(newImportTarget);
},
- async importGroups(importRequests) {
+ async requestGroupsImport(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);
}
@@ -373,11 +379,19 @@ export default {
variables: { importRequests },
});
} catch (error) {
- createAlert({
- message: i18n.ERROR_IMPORT,
- captureError: true,
- error,
- });
+ if (error.networkError?.response?.status === HTTP_STATUS_TOO_MANY_REQUESTS) {
+ newPendingGroupsIds.forEach((id) => {
+ this.importTargets[id].validationErrors = [
+ { field: NEW_NAME_FIELD, message: i18n.ERROR_TOO_MANY_REQUESTS, nonBlocking: true },
+ ];
+ });
+ } else {
+ createAlert({
+ message: i18n.ERROR_IMPORT,
+ captureError: true,
+ error,
+ });
+ }
} finally {
this.pendingGroupsIds = this.pendingGroupsIds.filter(
(id) => !newPendingGroupsIds.includes(id),
@@ -385,6 +399,26 @@ export default {
}
},
+ importGroup({ group, extraArgs, index }) {
+ if (group.flags.isFinished && !this.reimportRequests.includes(group.id)) {
+ this.validateImportTarget(group.importTarget);
+ this.reimportRequests.push(group.id);
+ this.$nextTick(() => {
+ this.$refs[`importTargetCell-${index}`].focusNewName();
+ });
+ } else {
+ this.reimportRequests = this.reimportRequests.filter((id) => id !== group.id);
+ this.requestGroupsImport([
+ {
+ sourceGroupId: group.id,
+ targetNamespace: group.importTarget.targetNamespace.fullPath,
+ newName: group.importTarget.newName,
+ ...extraArgs,
+ },
+ ]);
+ }
+ },
+
importSelectedGroups(extraArgs = {}) {
const importRequests = this.groupsTableData
.filter((group) => this.selectedGroupsIds.includes(group.id))
@@ -395,7 +429,7 @@ export default {
...extraArgs,
}));
- this.importGroups(importRequests);
+ this.requestGroupsImport(importRequests);
},
setPageSize(size) {
@@ -552,6 +586,7 @@ export default {
</div>
<gl-alert
v-if="unavailableFeatures.length > 0 && unavailableFeaturesAlertVisible"
+ data-testid="unavailable-features-alert"
variant="warning"
:title="unavailableFeaturesAlertTitle"
@dismiss="unavailableFeaturesAlertVisible = false"
@@ -582,6 +617,19 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
+ <gl-alert variant="warning" :dismissible="false" class="mt-3">
+ <gl-sprintf
+ :message="
+ s__(
+ 'BulkImport|Be aware of %{linkStart}visibility rules%{linkEnd} when importing groups.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="helpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<div
class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
@@ -742,8 +790,9 @@ export default {
<template #cell(webUrl)="{ item: group }">
<import-source-cell :group="group" />
</template>
- <template #cell(importTarget)="{ item: group }">
+ <template #cell(importTarget)="{ item: group, index }">
<import-target-cell
+ :ref="`importTargetCell-${index}`"
:group="group"
:group-path-regex="groupPathRegex"
@update-target-namespace="updateImportTarget(group, { targetNamespace: $event })"
@@ -753,22 +802,13 @@ export default {
<template #cell(progress)="{ item: group }">
<import-status-cell :status="group.visibleStatus" class="gl-line-height-32" />
</template>
- <template #cell(actions)="{ item: group }">
+ <template #cell(actions)="{ item: group, index }">
<import-actions-cell
:is-projects-import-enabled="isProjectsImportEnabled"
: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,
- ...$event,
- },
- ])
- "
+ @import-group="importGroup({ group, extraArgs: $event, index })"
/>
</template>
</gl-table>
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 04a90d9c20c..807b084fefb 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
@@ -38,6 +38,15 @@ export default {
// this will highlight field in green like "passed validation"
return this.group.flags.isInvalid && this.group.flags.isAvailableForImport ? false : null;
},
+ isPathSelectionAvailable() {
+ return this.group.flags.isAvailableForImport;
+ },
+ },
+
+ methods: {
+ focusNewName() {
+ this.$refs.newName.$el.focus();
+ },
},
};
</script>
@@ -48,7 +57,7 @@ export default {
<import-group-dropdown
#default="{ namespaces }"
:text="fullPath"
- :disabled="!group.flags.isAvailableForImport"
+ :disabled="!isPathSelectionAvailable"
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"
@@ -76,23 +85,22 @@ export default {
<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': !group.flags.isAvailableForImport,
- 'gl-border-gray-200': group.flags.isAvailableForImport,
+ 'gl-text-gray-400 gl-border-gray-100': !isPathSelectionAvailable,
+ 'gl-border-gray-200': isPathSelectionAvailable,
}"
>
/
</div>
<div class="gl-flex-grow-1">
<gl-form-input
+ ref="newName"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
- 'gl-inset-border-1-gray-200!':
- group.flags.isAvailableForImport && !group.flags.isInvalid,
- 'gl-inset-border-1-gray-100!':
- !group.flags.isAvailableForImport && !group.flags.isInvalid,
+ 'gl-inset-border-1-gray-200!': isPathSelectionAvailable,
+ 'gl-inset-border-1-gray-100!': !isPathSelectionAvailable,
}"
debounce="500"
- :disabled="!group.flags.isAvailableForImport"
+ :disabled="!isPathSelectionAvailable"
:value="group.importTarget.newName"
:aria-label="__('New name')"
:state="validNameState"
@@ -101,7 +109,7 @@ export default {
</div>
</div>
<div
- v-if="group.flags.isAvailableForImport && (group.flags.isInvalid || validationMessage)"
+ v-if="isPathSelectionAvailable && (group.flags.isInvalid || validationMessage)"
class="gl-text-red-500 gl-m-0 gl-mt-2"
role="alert"
>
diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js
index 7e532dfec05..60938272d11 100644
--- a/app/assets/javascripts/import_entities/import_groups/constants.js
+++ b/app/assets/javascripts/import_entities/import_groups/constants.js
@@ -11,6 +11,9 @@ export const i18n = {
),
ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
+ ERROR_TOO_MANY_REQUESTS: s__(
+ 'Bulkmport|Over six imports in one minute were attempted. Wait at least one minute and try again.',
+ ),
NO_GROUPS_FOUND: s__('BulkImport|No groups found'),
OWNER: __('Owner'),
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 63a36f1a79f..aaa37f145aa 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
@@ -182,16 +182,16 @@ export default {
<div v-if="repositories.length" class="gl-w-full">
<table>
<thead class="gl-border-0 gl-border-solid gl-border-t-1 gl-border-gray-100">
- <th class="import-jobs-from-col gl-p-4 gl-vertical-align-top gl-border-b-1">
+ <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
{{ fromHeaderText }}
</th>
- <th class="import-jobs-to-col gl-p-4 gl-vertical-align-top gl-border-b-1">
+ <th class="gl-w-half gl-p-4 gl-vertical-align-top gl-border-b-1">
{{ __('To GitLab') }}
</th>
- <th class="import-jobs-status-col gl-p-4 gl-vertical-align-top gl-border-b-1">
+ <th class="gl-p-4 gl-vertical-align-top gl-border-b-1">
{{ __('Status') }}
</th>
- <th class="import-jobs-cta-col gl-p-4 gl-vertical-align-top gl-border-b-1"></th>
+ <th class="gl-p-4 gl-vertical-align-top gl-border-b-1"></th>
</thead>
<tbody>
<template v-for="repo in repositories">
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 da5dcfa71e3..265cca9070e 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
@@ -9,6 +9,8 @@ import {
GlDropdownDivider,
GlDropdownSectionHeader,
GlTooltip,
+ GlSprintf,
+ GlTooltipDirective,
} from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
import { __ } from '~/locale';
@@ -32,6 +34,10 @@ export default {
GlBadge,
GlLink,
GlTooltip,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
repo: {
@@ -53,6 +59,12 @@ export default {
},
},
+ data() {
+ return {
+ isSelectedForReimport: false,
+ };
+ },
+
computed: {
...mapState(['ciCdOnly']),
...mapGetters(['getImportTarget']),
@@ -94,7 +106,11 @@ export default {
},
importButtonText() {
- return this.ciCdOnly ? __('Connect') : __('Import');
+ if (this.ciCdOnly) {
+ return __('Connect');
+ }
+
+ return this.isFinished ? __('Re-import') : __('Import');
},
newNameInput: {
@@ -115,6 +131,22 @@ export default {
importTarget: { ...this.importTarget, ...changedValues },
});
},
+
+ handleImportRepo() {
+ if (this.isFinished && !this.isSelectedForReimport) {
+ this.isSelectedForReimport = true;
+ this.$nextTick(() => {
+ this.$refs.newNameInput.$el.focus();
+ });
+ } else {
+ this.isSelectedForReimport = false;
+
+ this.fetchImport({
+ repoId: this.repo.importSource.id,
+ optionalStages: this.optionalStages,
+ });
+ }
+ },
},
helpUrl: helpPagePath('/user/project/import/github.md'),
@@ -132,6 +164,20 @@ export default {
>{{ repo.importSource.fullName }}
<gl-icon v-if="repo.importSource.providerLink" name="external-link" />
</gl-link>
+ <div v-if="isFinished" class="gl-font-sm">
+ <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
+ <template #link>
+ <gl-link
+ :href="repo.importedProject.fullPath"
+ class="gl-font-sm"
+ target="_blank"
+ data-qa-selector="go_to_project_link"
+ >
+ {{ displayFullPath }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</td>
<td
class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
@@ -139,7 +185,7 @@ export default {
data-qa-selector="project_path_content"
>
<template v-if="repo.importSource.target">{{ repo.importSource.target }}</template>
- <template v-else-if="isImportNotStarted">
+ <template v-else-if="isImportNotStarted || isSelectedForReimport">
<div class="gl-display-flex gl-align-items-stretch gl-w-full">
<import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace">
<template v-if="namespaces.length">
@@ -166,6 +212,7 @@ export default {
/
</div>
<gl-form-input
+ ref="newNameInput"
v-model="newNameInput"
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
data-qa-selector="project_path_field"
@@ -177,7 +224,7 @@ export default {
<td class="gl-p-4 gl-vertical-align-top" data-qa-selector="import_status_indicator">
<import-status :status="importStatus" :stats="stats" />
</td>
- <td data-testid="actions" class="gl-vertical-align-top gl-pt-4">
+ <td data-testid="actions" class="gl-vertical-align-top gl-pt-4 gl-white-space-nowrap">
<gl-tooltip :target="() => $refs.cancelButton.$el">
<div class="gl-text-left">
<p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p>
@@ -199,22 +246,26 @@ export default {
@click="cancelImport({ repoId: repo.importSource.id })"
/>
<gl-button
- v-if="isFinished"
- class="btn btn-default"
- :href="repo.importedProject.fullPath"
- rel="noreferrer noopener"
- target="_blank"
- data-qa-selector="go_to_project_button"
- >{{ __('Go to project') }}
- </gl-button>
- <gl-button
- v-if="isImportNotStarted"
+ v-if="isImportNotStarted || isFinished"
type="button"
data-qa-selector="import_button"
- @click="fetchImport({ repoId: repo.importSource.id, optionalStages })"
+ @click="handleImportRepo()"
>
{{ importButtonText }}
</gl-button>
+ <gl-icon
+ v-if="isFinished"
+ v-gl-tooltip
+ :size="16"
+ name="information-o"
+ :title="
+ s__(
+ 'ImportProjects|Re-import creates a new project. It does not sync with the existing project.',
+ )
+ "
+ class="gl-ml-3"
+ />
+
<gl-badge v-else-if="isIncompatible" variant="danger">{{
__('Incompatible project')
}}</gl-badge>
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index 8b2e0364d7a..734e7b10a77 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -2,16 +2,6 @@ import Vue from 'vue';
import { STATUSES } from '../../constants';
import * as types from './mutation_types';
-const makeNewImportedProject = (importedProject) => ({
- importSource: {
- id: importedProject.id,
- fullName: importedProject.importSource,
- sanitizedName: importedProject.name,
- providerLink: importedProject.providerLink,
- },
- importedProject: { ...importedProject },
-});
-
const makeNewIncompatibleProject = (project) => ({
importSource: { ...project, incompatible: true },
importedProject: null,
@@ -55,14 +45,6 @@ export default {
// Legacy code path, will be removed when all importers will be switched to new pagination format
// https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091
- const newImportedProjects = processLegacyEntries({
- newRepositories: repositories.importedProjects.filter(
- (p) => p.importStatus !== STATUSES.CANCELED,
- ),
- existingRepositories: state.repositories,
- factory: makeNewImportedProject,
- });
-
const incompatibleRepos = repositories.incompatibleRepos ?? [];
const newIncompatibleProjects = processLegacyEntries({
newRepositories: incompatibleRepos,
@@ -70,16 +52,22 @@ export default {
factory: makeNewIncompatibleProject,
});
- const existingProjects = [...newImportedProjects, ...state.repositories];
- const existingProjectNames = new Set(existingProjects.map((p) => p.importSource.fullName));
+ const existingProjectNames = new Set(state.repositories.map((p) => p.importSource.fullName));
+ const importedProjects = [...(repositories.importedProjects ?? [])].reverse();
const newProjects = repositories.providerRepos
.filter((project) => !existingProjectNames.has(project.fullName))
- .map((project) => ({
- importSource: project,
- importedProject: null,
- }));
+ .map((project) => {
+ const importedProject = importedProjects.find(
+ (p) => p.providerLink === project.providerLink,
+ );
+
+ return {
+ importSource: project,
+ importedProject,
+ };
+ });
- state.repositories = [...existingProjects, ...newProjects, ...newIncompatibleProjects];
+ state.repositories = [...state.repositories, ...newProjects, ...newIncompatibleProjects];
if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) {
state.pageInfo.page -= 1;
@@ -113,7 +101,7 @@ export default {
[types.RECEIVE_IMPORT_ERROR](state, repoId) {
const existingRepo = state.repositories.find((r) => r.importSource.id === repoId);
- existingRepo.importedProject = null;
+ existingRepo.importedProject.importStatus = STATUSES.FAILED;
},
[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) {
diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js
index c4c9e544c1e..08a96160ee3 100644
--- a/app/assets/javascripts/import_entities/import_projects/utils.js
+++ b/app/assets/javascripts/import_entities/import_projects/utils.js
@@ -11,7 +11,7 @@ export function getImportStatus(project) {
export function isProjectImportable(project) {
return (
!isIncompatible(project) &&
- [STATUSES.NONE, STATUSES.CANCELED].includes(getImportStatus(project))
+ [STATUSES.NONE, STATUSES.CANCELED, STATUSES.FAILED].includes(getImportStatus(project))
);
}
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 14ab7b2dc1e..f8e70fea7aa 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -402,6 +402,7 @@ export default {
>
<gl-link
data-testid="incident-link"
+ data-qa-selector="incident_link"
:href="showIncidentLink(item)"
class="gl-min-w-0"
>
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index b956bdf067d..5d08520bb5c 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -58,19 +58,21 @@ export const integrationTriggerEvents = {
export const integrationTriggerEventTitles = {
[integrationTriggerEvents.PUSH]: s__('IntegrationEvents|A push is made to the repository'),
[integrationTriggerEvents.ISSUE]: s__(
- 'IntegrationEvents|An issue is created, updated, or closed',
+ 'IntegrationEvents|An issue is created, closed, or reopened',
),
[integrationTriggerEvents.CONFIDENTIAL_ISSUE]: s__(
- 'IntegrationEvents|A confidential issue is created, updated, or closed',
+ 'IntegrationEvents|A confidential issue is created, closed, or reopened',
),
[integrationTriggerEvents.MERGE_REQUEST]: s__(
- 'IntegrationEvents|A merge request is created, updated, or merged',
+ 'IntegrationEvents|A merge request is created, merged, closed, or reopened',
),
- [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added on an issue'),
+ [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added'),
[integrationTriggerEvents.CONFIDENTIAL_NOTE]: s__(
- 'IntegrationEvents|A comment is added on a confidential issue',
+ 'IntegrationEvents|An internal note or comment on a confidential issue is added',
+ ),
+ [integrationTriggerEvents.TAG_PUSH]: s__(
+ 'IntegrationEvents|A tag is pushed to the repository or removed',
),
- [integrationTriggerEvents.TAG_PUSH]: s__('IntegrationEvents|A tag is pushed to the repository'),
[integrationTriggerEvents.PIPELINE]: s__('IntegrationEvents|A pipeline status changes'),
[integrationTriggerEvents.WIKI_PAGE]: s__('IntegrationEvents|A wiki page is created or updated'),
[integrationTriggerEvents.DEPLOYMENT]: s__(
@@ -88,7 +90,7 @@ export const billingPlanNames = {
[billingPlans.ULTIMATE]: s__('BillingPlans|Ultimate'),
};
-const INTEGRATION_TYPE_SLACK = 'slack';
+export const INTEGRATION_TYPE_SLACK = 'slack';
const INTEGRATION_TYPE_SLACK_APPLICATION = 'gitlab_slack_application';
const INTEGRATION_TYPE_MATTERMOST = 'mattermost';
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 1e58b604bf7..d671ec33bcb 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -208,6 +208,17 @@ export default {
data-testid="redirect-to-field"
/>
+ <div v-if="shouldUpgradeSlack" class="gl-mb-6">
+ <gl-alert
+ :dismissible="false"
+ :title="$options.slackUpgradeInfo.title"
+ :primary-button-link="customState.upgradeSlackUrl"
+ :primary-button-text="$options.slackUpgradeInfo.btnText"
+ class="gl-mb-5"
+ >{{ $options.slackUpgradeInfo.text }}</gl-alert
+ >
+ </div>
+
<override-dropdown
v-if="defaultState !== null"
:inherit-from-id="defaultState.id"
@@ -241,17 +252,6 @@ export default {
</div>
</section>
- <div v-if="shouldUpgradeSlack" class="gl-border-t">
- <gl-alert
- :dismissible="false"
- :title="$options.slackUpgradeInfo.title"
- :primary-button-link="customState.upgradeSlackUrl"
- :primary-button-text="$options.slackUpgradeInfo.btnText"
- class="gl-mb-8 gl-mt-5"
- >{{ $options.slackUpgradeInfo.text }}</gl-alert
- >
- </div>
-
<template v-if="hasSections">
<integration-form-section
v-for="(section, index) in customState.sections"
diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue
index 439c243f418..62f0fe4d6bf 100644
--- a/app/assets/javascripts/integrations/index/components/integrations_table.vue
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -1,7 +1,9 @@
<script>
import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { INTEGRATION_TYPE_SLACK } from '~/integrations/constants';
import { sprintf, s__, __ } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -13,6 +15,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
integrations: {
type: Array,
@@ -55,6 +58,15 @@ export default {
},
];
},
+ filteredIntegrations() {
+ if (this.glFeatures.integrationSlackAppNotifications) {
+ return this.integrations.filter(
+ (integration) =>
+ !(integration.name === INTEGRATION_TYPE_SLACK && integration.active === false),
+ );
+ }
+ return this.integrations;
+ },
},
methods: {
getStatusTooltipTitle(integration) {
@@ -67,7 +79,7 @@ export default {
</script>
<template>
- <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed>
+ <gl-table :items="filteredIntegrations" :fields="fields" :empty-text="emptyText" show-empty fixed>
<template #cell(active)="{ item }">
<gl-icon
v-if="item.active"
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 fa1aa6b0d88..607c888b85a 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -1,8 +1,7 @@
<script>
import {
GlAlert,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlLink,
GlSprintf,
GlFormCheckboxGroup,
@@ -13,6 +12,7 @@ import {
import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
import Api from '~/api';
+import Tracking from '~/tracking';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
@@ -22,6 +22,7 @@ import {
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
LEARN_GITLAB,
+ INVITE_MEMBER_MODAL_TRACKING_CATEGORY,
} from '../constants';
import eventHub from '../event_hub';
import { responseFromSuccess } from '../utils/response_message_parser';
@@ -40,8 +41,7 @@ export default {
components: {
GlAlert,
GlLink,
- GlDropdown,
- GlDropdownItem,
+ GlCollapsibleListbox,
GlSprintf,
GlFormCheckboxGroup,
GlButton,
@@ -52,6 +52,7 @@ export default {
ModalConfetti,
UserLimitNotification,
},
+ mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })],
inject: ['newProjectPath'],
props: {
id: {
@@ -109,6 +110,11 @@ export default {
required: false,
default: () => ({}),
},
+ activeTrialDataset: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
reloadPageOnSubmit: {
type: Boolean,
required: false,
@@ -124,6 +130,7 @@ export default {
invalidMembers: {},
selectedTasksToBeDone: [],
selectedTaskProject: this.projects[0],
+ selectedTaskProjectId: this.projects[0]?.id,
source: 'unknown',
mode: 'default',
// Kept in sync with "base"
@@ -131,6 +138,7 @@ export default {
errorsLimit: 2,
isErrorsSectionExpanded: false,
shouldShowEmptyInvitesAlert: false,
+ projectsForDropdown: this.projects.map((p) => ({ value: p.id, text: p.title, ...p })),
};
},
computed: {
@@ -263,11 +271,12 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ mode = 'default', source }) {
+ openModal({ mode = 'default', source = 'unknown' }) {
this.mode = mode;
this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
+ this.track('render', { label: this.source });
},
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
@@ -339,6 +348,12 @@ export default {
const tracking = new ExperimentTracking(INVITE_MEMBERS_FOR_TASK.name, { label, property });
tracking.event(INVITE_MEMBERS_FOR_TASK.submit);
},
+ onCancel() {
+ this.track('click_cancel', { label: this.source });
+ },
+ onClose() {
+ this.track('click_x', { label: this.source });
+ },
resetFields() {
this.clearValidation();
this.isLoading = false;
@@ -347,10 +362,12 @@ export default {
this.selectedTasksToBeDone = [];
[this.selectedTaskProject] = this.projects;
},
- changeSelectedTaskProject(project) {
- this.selectedTaskProject = project;
+ changeSelectedTaskProject(projectId) {
+ this.selectedTaskProject = this.projects.find((project) => project.id === projectId);
},
onInviteSuccess() {
+ this.track('invite_successful', { label: this.source });
+
if (this.reloadPageOnSubmit) {
reloadOnInvitationSuccess();
} else {
@@ -404,7 +421,10 @@ export default {
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
:users-limit-dataset="usersLimitDataset"
+ :active-trial-dataset="activeTrialDataset"
:full-path="fullPath"
+ @close="onClose"
+ @cancel="onCancel"
@reset="resetFields"
@submit="sendInvite"
@access-level="onAccessLevelUpdate"
@@ -513,23 +533,14 @@ export default {
<label class="gl-mt-5 gl-display-block">
{{ $options.labels.tasksProject.title }}
</label>
- <gl-dropdown
+ <gl-collapsible-listbox
+ v-model="selectedTaskProjectId"
+ :items="projectsForDropdown"
+ :block="true"
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>
+ @select="changeSelectedTaskProject"
+ />
</template>
</template>
<gl-alert
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 2cbd681c67d..1e3b6093f0b 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -206,7 +206,7 @@ export default {
this.track('render', { category: 'default', label: ON_SHOW_TRACK_LABEL });
}
},
- onCloseModal(e) {
+ onCancel(e) {
if (this.preventCancelDefault) {
e.preventDefault();
} else {
@@ -225,6 +225,9 @@ export default {
expiresAt: this.selectedDate,
});
},
+ onClose() {
+ this.$emit('close');
+ },
},
HEADER_CLOSE_LABEL,
ACCESS_EXPIRE_DATE,
@@ -249,7 +252,8 @@ export default {
:action-cancel="actionCancel"
@shown="onShowModal"
@primary="onSubmit"
- @cancel="onCloseModal"
+ @cancel="onCancel"
+ @close="onClose"
@hidden="onReset"
>
<content-transition
@@ -267,11 +271,12 @@ export default {
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
+ <slot name="intro-text-after"></slot>
</p>
- <slot name="intro-text-after"></slot>
</div>
<slot name="alert"></slot>
+ <slot name="active-trial-alert"></slot>
<gl-form-group
:label="labelSearchField"
diff --git a/app/assets/javascripts/invite_members/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue
index b7a3918813b..c1114c240b9 100644
--- a/app/assets/javascripts/invite_members/components/project_select.vue
+++ b/app/assets/javascripts/invite_members/components/project_select.vue
@@ -1,28 +1,23 @@
<script>
-import {
- GlAvatarLabeled,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
-} from '@gitlab/ui';
+import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui';
import { debounce } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { getProjects } from '~/rest_api';
import { SEARCH_DELAY, GROUP_FILTERS } from '../constants';
+// We can have GlCollapsibleListbox dropdown panel with full
+// width once we implement
+// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2133
+// https://gitlab.com/gitlab-org/gitlab/-/issues/390411
export default {
name: 'ProjectSelect',
components: {
GlAvatarLabeled,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
},
model: {
- prop: 'selectedProject',
+ prop: 'selectedProjectId',
},
props: {
groupsFilter: {
@@ -41,18 +36,21 @@ export default {
return {
isFetching: false,
projects: [],
- selectedProject: {},
+ selectedProjectId: '',
searchTerm: '',
errorMessage: '',
};
},
computed: {
selectedProjectName() {
- return this.selectedProject.name || this.$options.i18n.dropdownText;
+ return this.selectedProject.nameWithNamespace || this.$options.i18n.dropdownText;
},
isFetchResultEmpty() {
return this.projects.length === 0 && !this.isFetching;
},
+ selectedProject() {
+ return this.projects.find((prj) => prj.id === this.selectedProjectId) || {};
+ },
},
watch: {
searchTerm() {
@@ -70,10 +68,14 @@ export default {
.then((response) => {
this.projects = response.data.map((project) => ({
...convertObjectPropsToCamelCase(project),
- name: project.name_with_namespace,
+ text: project.name_with_namespace,
+ value: project.id,
}));
})
.catch(() => {
+ // To be displayed in GlCollapsibleListbox once we implement
+ // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2132
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/389974
this.errorMessage = this.$options.i18n.errorFetchingProjects;
})
.finally(() => {
@@ -83,9 +85,7 @@ export default {
fetchProjects() {
return getProjects(this.searchTerm, this.$options.defaultFetchOptions);
},
- selectProject(project) {
- this.selectedProject = project;
-
+ selectProject() {
this.$emit('input', this.selectedProject);
},
},
@@ -104,40 +104,28 @@ export default {
};
</script>
<template>
- <div>
- <gl-dropdown
- data-testid="project-select-dropdown"
- :text="selectedProjectName"
- toggle-class="gl-mb-2"
- block
- menu-class="gl-w-full!"
- >
- <gl-search-box-by-type
- v-model="searchTerm"
- :is-loading="isFetching"
- :placeholder="$options.i18n.searchPlaceholder"
- data-qa-selector="project_select_dropdown_search_field"
+ <gl-collapsible-listbox
+ v-model="selectedProjectId"
+ searchable
+ :items="projects"
+ :searching="isFetching"
+ :toggle-text="selectedProjectName"
+ :search-placeholder="$options.i18n.searchPlaceholder"
+ :no-results-text="$options.i18n.emptySearchResult"
+ data-testid="project-select-dropdown"
+ data-qa-selector="project_select_dropdown"
+ class="gl-collapsible-listbox-w-full"
+ @search="searchTerm = $event"
+ @select="selectProject"
+ >
+ <template #list-item="{ item }">
+ <gl-avatar-labeled
+ :label="item.text"
+ :src="item.avatarUrl"
+ :entity-id="item.id"
+ :entity-name="item.name"
+ :size="32"
/>
- <gl-dropdown-item
- v-for="project in projects"
- :key="project.id"
- :name="project.name"
- @click="selectProject(project)"
- >
- <gl-avatar-labeled
- :label="project.name"
- :src="project.avatarUrl"
- :entity-id="project.id"
- :entity-name="project.name"
- :size="32"
- />
- </gl-dropdown-item>
- <gl-dropdown-text v-if="errorMessage" data-testid="error-message">
- <span class="gl-text-gray-500">{{ errorMessage }}</span>
- </gl-dropdown-text>
- <gl-dropdown-text v-else-if="isFetchResultEmpty" data-testid="empty-result-message">
- <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
- </div>
+ </template>
+ </gl-collapsible-listbox>
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index edc0ebff083..ac0b708c55e 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -20,6 +20,7 @@ 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 INVITE_MEMBER_MODAL_TRACKING_CATEGORY = 'invite_members_modal';
export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button';
export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members');
export const MEMBERS_MODAL_CELEBRATE_TITLE = s__(
@@ -138,6 +139,7 @@ export const GROUP_MODAL_LABELS = {
export const LEARN_GITLAB = 'learn_gitlab';
export const ON_SHOW_TRACK_LABEL = 'over_limit_modal_viewed';
+export const ON_CELEBRATION_TRACK_LABEL = 'invite_celebration_modal';
export const INFO_ALERT_TITLE = s__(
'InviteMembersModal|Your top-level group %{namespaceName} is over the %{dashboardLimit} user limit.',
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 842ab07f368..4f539cd8756 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -41,6 +41,9 @@ export default (function initInviteMembersModal() {
usersLimitDataset: convertObjectPropsToCamelCase(
JSON.parse(el.dataset.usersLimitDataset || '{}'),
),
+ activeTrialDataset: convertObjectPropsToCamelCase(
+ JSON.parse(el.dataset.activeTrialDataset || '{}'),
+ ),
reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit),
},
}),
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index fcebae3af71..71ec5544c34 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -9,6 +9,7 @@ import {
GlFormInputGroup,
GlIcon,
} from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { sprintf, __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -36,7 +37,7 @@ export default {
default: null,
},
issuableType: {
- default: 'issue',
+ default: TYPE_ISSUE,
},
emailsHelpPagePath: {
default: '',
@@ -54,7 +55,7 @@ export default {
data() {
return {
email: this.initialEmail,
- issuableName: this.issuableType === 'issue' ? __('issue') : __('merge request'),
+ issuableName: this.issuableType === TYPE_ISSUE ? __('issue') : __('merge request'),
};
},
computed: {
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 14325d6b64e..0e58f3793bc 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -2,7 +2,7 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { sprintf, __ } from '~/locale';
-import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue';
@@ -12,8 +12,8 @@ const NoteableTypeText = {
};
export default {
+ TYPE_ISSUE,
WorkspaceType,
- IssuableType,
components: {
GlIcon,
ConfidentialityBadge,
@@ -61,7 +61,7 @@ export default {
v-if="isConfidential"
data-testid="confidential"
:workspace-type="$options.WorkspaceType.project"
- :issuable-type="$options.IssuableType.Issue"
+ :issuable-type="$options.TYPE_ISSUE"
/>
<template v-for="meta in warningIconsMeta">
<div
diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue
index 4f1001e8c3b..c7da3e59098 100644
--- a/app/assets/javascripts/issuable/components/issue_milestone.vue
+++ b/app/assets/javascripts/issuable/components/issue_milestone.vue
@@ -82,7 +82,7 @@ export default {
<span
v-if="milestoneStart || milestoneDue"
:class="{
- 'text-danger-muted': isMilestonePastDue,
+ 'gl-text-red-300': isMilestonePastDue,
'text-tertiary': !isMilestonePastDue,
}"
><span>{{ milestoneDatesHuman }}</span
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index c815c7aaba9..608c1deac64 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -3,7 +3,7 @@ import '~/commons/bootstrap';
import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@@ -69,7 +69,7 @@ export default {
return `${this.iconClass} ic-${this.iconName}`;
},
workItemId() {
- return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey);
+ return convertToGraphQLId(TYPENAME_WORK_ITEM, this.idKey);
},
},
methods: {
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 6c4ffc44444..0c75e44443d 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -4,7 +4,7 @@ import Vue from 'vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
import { __ } from '~/locale';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export const badgeState = Vue.observable({
@@ -92,7 +92,7 @@ export default {
return STATUS[this.state];
},
badgeIcon() {
- if (this.issuableType === IssuableType.Issue) {
+ if (this.issuableType === TYPE_ISSUE) {
return ISSUE_ICONS[this.state];
}
return MERGE_REQUEST_ICONS[this.state];
diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
index c386267501a..201782a201a 100644
--- a/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js
@@ -43,10 +43,10 @@ export default {
*/
getFormDataAsObject() {
+ const assigneeIds = this.form.find('input[name="update[assignee_ids][]"]').val();
const formData = {
update: {
state_event: this.form.find('input[name="update[state_event]"]').val(),
- assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
subscription_event: this.form.find('input[name="update[subscription_event]"]').val(),
@@ -57,6 +57,9 @@ export default {
remove_label_ids: [],
},
};
+ if (assigneeIds) {
+ formData.update.assignee_ids = [assigneeIds];
+ }
if (this.willUpdateLabels) {
formData.update.add_label_ids = this.$labelDropdown.data('user-checked');
formData.update.remove_label_ids = this.$labelDropdown.data('user-unchecked');
diff --git a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js
index 095da60a583..9c891bcfc9e 100644
--- a/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js
@@ -1,9 +1,9 @@
/* eslint-disable class-methods-use-this, no-new */
-
import $ from 'jquery';
import issuableEventHub from '~/issues/list/eventhub';
import LabelsSelect from '~/labels/labels_select';
import {
+ mountAssigneesDropdown,
mountMilestoneDropdown,
mountMoveIssuesButton,
mountStatusDropdown,
@@ -64,6 +64,7 @@ export default class IssuableBulkUpdateSidebar {
mountMoveIssuesButton();
mountStatusDropdown();
mountSubscriptionsDropdown();
+ mountAssigneesDropdown();
// Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy
// the import/no-unresolved lint rule when FOSS_ONLY=1, even though at
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 99a3f76ca76..8a094d5d688 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -60,8 +60,6 @@ export default class IssuableForm {
return;
}
this.form = form;
- this.toggleWip = this.toggleWip.bind(this);
- this.renderWipExplanation = this.renderWipExplanation.bind(this);
this.resetAutosave = this.resetAutosave.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
// prettier-ignore
@@ -86,6 +84,7 @@ export default class IssuableForm {
this.fallbackKey = getFallbackKey();
this.titleField = this.form.find('input[name*="[title]"]');
this.descriptionField = this.form.find('textarea[name*="[description]"]');
+ this.draftCheck = document.querySelector('input.js-toggle-draft');
if (!(this.titleField.length && this.descriptionField.length)) {
return;
}
@@ -93,8 +92,7 @@ export default class IssuableForm {
this.autosaves = this.initAutosave();
this.form.on('submit', this.handleSubmit);
this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave);
- this.form.find('.js-unwrap-on-load').unwrap();
- this.initWip();
+ this.initDraft();
const $issuableDueDate = $('#issuable-due-date');
@@ -160,48 +158,34 @@ export default class IssuableForm {
});
}
- initWip() {
- this.$wipExplanation = this.form.find('.js-wip-explanation');
- this.$noWipExplanation = this.form.find('.js-no-wip-explanation');
- if (!(this.$wipExplanation.length && this.$noWipExplanation.length)) {
- return undefined;
+ initDraft() {
+ if (this.draftCheck) {
+ this.draftCheck.addEventListener('click', () => this.writeDraftStatus());
+ this.titleField.on('keyup blur', () => this.readDraftStatus());
+
+ this.readDraftStatus();
}
- this.form.on('click', '.js-toggle-wip', this.toggleWip);
- this.titleField.on('keyup blur', this.renderWipExplanation);
- return this.renderWipExplanation();
}
- workInProgress() {
+ isMarkedDraft() {
return this.draftRegex.test(this.titleField.val());
}
-
- renderWipExplanation() {
- if (this.workInProgress()) {
- // These strings are not "translatable" (the code is hard-coded to look for them)
- this.$wipExplanation.find('code')[0].textContent =
- 'Draft'; /* eslint-disable-line @gitlab/require-i18n-strings */
- this.$wipExplanation.show();
- return this.$noWipExplanation.hide();
- }
- this.$wipExplanation.hide();
- return this.$noWipExplanation.show();
+ readDraftStatus() {
+ this.draftCheck.checked = this.isMarkedDraft();
}
-
- toggleWip(event) {
- event.preventDefault();
- if (this.workInProgress()) {
- this.removeWip();
+ writeDraftStatus() {
+ if (this.draftCheck.checked) {
+ this.addDraft();
} else {
- this.addWip();
+ this.removeDraft();
}
- return this.renderWipExplanation();
}
- removeWip() {
+ removeDraft() {
return this.titleField.val(this.titleField.val().replace(this.draftRegex, ''));
}
- addWip() {
+ addDraft() {
this.titleField.val(`Draft: ${this.titleField.val()}`);
}
}
diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
index 945a3782642..55fb3958e82 100644
--- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
@@ -4,7 +4,7 @@ import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import IssueMilestone from '~/issuable/components/issue_milestone.vue';
import StatusBox from '~/issuable/components/status_box.vue';
-import { IssuableStatus } from '~/issues/constants';
+import { STATUS_CLOSED } from '~/issues/constants';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
@@ -57,7 +57,7 @@ export default {
return Object.keys(this.issue).length > 0;
},
isIssueClosed() {
- return this.issue?.state === IssuableStatus.Closed;
+ return this.issue?.state === STATUS_CLOSED;
},
},
apollo: {
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js
index 4b9a42da178..ba05dd731f7 100644
--- a/app/assets/javascripts/issues/constants.js
+++ b/app/assets/javascripts/issues/constants.js
@@ -1,22 +1,27 @@
import { __ } from '~/locale';
-export const IssuableStatus = {
- Closed: 'closed',
- Open: 'opened',
- Reopened: 'reopened',
-};
+export const STATUS_CLOSED = 'closed';
+export const STATUS_OPEN = 'opened';
+export const STATUS_REOPENED = 'reopened';
+
+export const TITLE_LENGTH_MAX = 255;
+
+export const TYPE_EPIC = 'epic';
+export const TYPE_ISSUE = 'issue';
export const IssuableStatusText = {
- [IssuableStatus.Closed]: __('Closed'),
- [IssuableStatus.Open]: __('Open'),
- [IssuableStatus.Reopened]: __('Open'),
+ [STATUS_CLOSED]: __('Closed'),
+ [STATUS_OPEN]: __('Open'),
+ [STATUS_REOPENED]: __('Open'),
};
+// Deprecated - use individual constants instead like `TYPE_ISSUE` above
export const IssuableType = {
Issue: 'issue',
Epic: 'epic',
MergeRequest: 'merge_request',
Alert: 'alert',
+ TestCase: 'test_case',
};
export const IssueType = {
diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
index 8edc9a08c9e..a4a2feba716 100644
--- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
+++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue
@@ -1,12 +1,14 @@
<script>
-import { GlButton, GlEmptyState, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql';
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
-import { IssuableStatus } from '~/issues/constants';
+import { STATUS_CLOSED } from '~/issues/constants';
import {
CREATED_DESC,
+ defaultTypeTokenOptions,
+ i18n,
PAGE_SIZE,
PARAM_STATE,
UPDATED_DESC,
@@ -26,21 +28,29 @@ import {
import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
import {
+ OPERATORS_IS,
+ OPERATORS_IS_NOT_OR,
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_CONFIDENTIAL,
TOKEN_TITLE_LABEL,
TOKEN_TITLE_MILESTONE,
TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_SEARCH_WITHIN,
+ TOKEN_TITLE_TYPE,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
+ TOKEN_TYPE_CONFIDENTIAL,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_SEARCH_WITHIN,
+ TOKEN_TYPE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import getIssuesCountsQuery from '../queries/get_issues_counts.query.graphql';
import { AutocompleteCache } from '../utils';
const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue');
@@ -52,17 +62,7 @@ const MilestoneToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
export default {
- i18n: {
- calendarButtonText: __('Subscribe to calendar'),
- closed: __('CLOSED'),
- closedMoved: __('CLOSED (MOVED)'),
- emptyStateWithFilterTitle: __('Sorry, your filter produced no results'),
- emptyStateWithFilterDescription: __('To widen your search, change or remove filters above'),
- emptyStateWithoutFilterTitle: __('Please select at least one filter to see results'),
- errorFetchingIssues: __('An error occurred while loading issues'),
- rssButtonText: __('Subscribe to RSS feed'),
- searchInputPlaceholder: __('Search or filter results...'),
- },
+ i18n,
IssuableListTabs,
components: {
GlButton,
@@ -105,6 +105,7 @@ export default {
return {
filterTokens: getFilterTokens(window.location.search),
issues: [],
+ issuesCounts: {},
issuesError: null,
pageInfo: {},
pageParams: getInitialPageParams(),
@@ -116,15 +117,7 @@ export default {
issues: {
query: getIssuesQuery,
variables() {
- return {
- hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
- isSignedIn: this.isSignedIn,
- search: this.searchQuery,
- sort: this.sortKey,
- state: this.state,
- ...this.pageParams,
- ...this.apiFilterParams,
- };
+ return this.queryVariables;
},
update(data) {
return data.issues.nodes ?? [];
@@ -141,13 +134,33 @@ export default {
},
debounce: 200,
},
+ issuesCounts: {
+ query: getIssuesCountsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data ?? {};
+ },
+ error(error) {
+ this.issuesError = this.$options.i18n.errorFetchingCounts;
+ Sentry.captureException(error);
+ },
+ skip() {
+ return !this.hasSearch;
+ },
+ debounce: 200,
+ context: {
+ isSingleRequest: true,
+ },
+ },
},
computed: {
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
emptyStateDescription() {
- return this.hasSearch ? this.$options.i18n.emptyStateWithFilterDescription : undefined;
+ return this.hasSearch ? this.$options.i18n.noSearchResultsDescription : undefined;
},
emptyStateSvgPath() {
return this.hasSearch
@@ -156,12 +169,23 @@ export default {
},
emptyStateTitle() {
return this.hasSearch
- ? this.$options.i18n.emptyStateWithFilterTitle
- : this.$options.i18n.emptyStateWithoutFilterTitle;
+ ? this.$options.i18n.noSearchResultsTitle
+ : this.$options.i18n.noSearchNoFilterTitle;
},
hasSearch() {
return Boolean(this.searchQuery || Object.keys(this.urlFilterParams).length);
},
+ queryVariables() {
+ return {
+ hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
+ isSignedIn: this.isSignedIn,
+ search: this.searchQuery,
+ sort: this.sortKey,
+ state: this.state,
+ ...this.pageParams,
+ ...this.apiFilterParams,
+ };
+ },
renderedIssues() {
return this.hasSearch ? this.issues : [];
},
@@ -186,6 +210,7 @@ export default {
title: TOKEN_TITLE_ASSIGNEE,
icon: 'user',
token: UserToken,
+ operators: OPERATORS_IS_NOT_OR,
fetchUsers: this.fetchUsers,
preloadedUsers,
recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-assignee',
@@ -195,6 +220,7 @@ export default {
title: TOKEN_TITLE_AUTHOR,
icon: 'pencil',
token: UserToken,
+ operators: OPERATORS_IS_NOT_OR,
fetchUsers: this.fetchUsers,
defaultUsers: [],
preloadedUsers,
@@ -205,6 +231,7 @@ export default {
title: TOKEN_TITLE_LABEL,
icon: 'labels',
token: LabelToken,
+ operators: OPERATORS_IS_NOT_OR,
fetchLabels: this.fetchLabels,
recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-label',
},
@@ -217,10 +244,46 @@ export default {
recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-milestone',
shouldSkipSort: true,
},
+ {
+ type: TOKEN_TYPE_SEARCH_WITHIN,
+ title: TOKEN_TITLE_SEARCH_WITHIN,
+ icon: 'search',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ options: [
+ { icon: 'title', value: 'TITLE', title: this.$options.i18n.titles },
+ {
+ icon: 'text-description',
+ value: 'DESCRIPTION',
+ title: this.$options.i18n.descriptions,
+ },
+ ],
+ },
+ {
+ type: TOKEN_TYPE_TYPE,
+ title: TOKEN_TITLE_TYPE,
+ icon: 'issues',
+ token: GlFilteredSearchToken,
+ options: defaultTypeTokenOptions,
+ },
];
if (this.isSignedIn) {
tokens.push({
+ type: TOKEN_TYPE_CONFIDENTIAL,
+ title: TOKEN_TITLE_CONFIDENTIAL,
+ icon: 'eye-slash',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATORS_IS,
+ options: [
+ { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes },
+ { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo },
+ ],
+ });
+
+ tokens.push({
type: TOKEN_TYPE_MY_REACTION,
title: TOKEN_TITLE_MY_REACTION,
icon: 'thumb-up',
@@ -248,6 +311,14 @@ export default {
hasIssueWeightsFeature: this.hasIssueWeightsFeature,
});
},
+ tabCounts() {
+ const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
+ return {
+ [IssuableStates.Opened]: openedIssues?.count,
+ [IssuableStates.Closed]: closedIssues?.count,
+ [IssuableStates.All]: allIssues?.count,
+ };
+ },
urlFilterParams() {
return convertToUrlParams(this.filterTokens);
},
@@ -292,10 +363,10 @@ export default {
return axios.get('/-/autocomplete/users.json', { params: { active: true, search } });
},
getStatus(issue) {
- if (issue.state === IssuableStatus.Closed && issue.moved) {
+ if (issue.state === STATUS_CLOSED && issue.moved) {
return this.$options.i18n.closedMoved;
}
- if (issue.state === IssuableStatus.Closed) {
+ if (issue.state === STATUS_CLOSED) {
return this.$options.i18n.closed;
}
return undefined;
@@ -372,12 +443,14 @@ export default {
:issuables-loading="$apollo.queries.issues.loading"
namespace="dashboard"
recent-searches-storage-key="issues"
- :search-input-placeholder="$options.i18n.searchInputPlaceholder"
+ :search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
:show-pagination-controls="showPaginationControls"
show-work-item-type-icon
:sort-options="sortOptions"
+ :tab-counts="tabCounts"
:tabs="$options.IssuableListTabs"
+ truncate-counts
:url-params="urlParams"
use-keyset-pagination
@click-tab="handleClickTab"
@@ -389,10 +462,10 @@ export default {
>
<template #nav-actions>
<gl-button :href="rssPath" icon="rss">
- {{ $options.i18n.rssButtonText }}
+ {{ $options.i18n.rssLabel }}
</gl-button>
<gl-button :href="calendarPath" icon="calendar">
- {{ $options.i18n.calendarButtonText }}
+ {{ $options.i18n.calendarLabel }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
index 43b8804108c..5625e6afad3 100644
--- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/issues/list/queries/issue.fragment.graphql"
+#import "./issue.fragment.graphql"
query getDashboardIssues(
$hideUsers: Boolean = false
@@ -10,11 +10,15 @@ query getDashboardIssues(
$assigneeId: String
$assigneeUsernames: [String!]
$authorUsername: String
+ $confidential: Boolean
$labelName: [String]
$milestoneTitle: [String]
$milestoneWildcardId: MilestoneWildcardId
$myReactionEmoji: String
+ $types: [IssueType!]
+ $in: [IssuableSearchableField!]
$not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
$afterCursor: String
$beforeCursor: String
$firstPageSize: Int
@@ -27,11 +31,15 @@ query getDashboardIssues(
assigneeId: $assigneeId
assigneeUsernames: $assigneeUsernames
authorUsername: $authorUsername
+ confidential: $confidential
labelName: $labelName
milestoneTitle: $milestoneTitle
milestoneWildcardId: $milestoneWildcardId
myReactionEmoji: $myReactionEmoji
+ types: $types
+ in: $in
not: $not
+ or: $or
after: $afterCursor
before: $beforeCursor
first: $firstPageSize
diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql
new file mode 100644
index 00000000000..b36f546e4ab
--- /dev/null
+++ b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql
@@ -0,0 +1,70 @@
+query getDashboardIssuesCount(
+ $search: String
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $confidential: Boolean
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $milestoneWildcardId: MilestoneWildcardId
+ $myReactionEmoji: String
+ $types: [IssueType!]
+ $in: [IssuableSearchableField!]
+ $not: NegatedIssueFilterInput
+ $or: UnionedIssueFilterInput
+) {
+ openedIssues: issues(
+ state: opened
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ types: $types
+ in: $in
+ not: $not
+ or: $or
+ ) {
+ count
+ }
+ closedIssues: issues(
+ state: closed
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ types: $types
+ in: $in
+ not: $not
+ or: $or
+ ) {
+ count
+ }
+ allIssues: issues(
+ state: all
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ confidential: $confidential
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ myReactionEmoji: $myReactionEmoji
+ types: $types
+ in: $in
+ not: $not
+ or: $or
+ ) {
+ count
+ }
+}
diff --git a/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql
new file mode 100644
index 00000000000..040763f2ba4
--- /dev/null
+++ b/app/assets/javascripts/issues/dashboard/queries/issue.fragment.graphql
@@ -0,0 +1,56 @@
+fragment IssueFragment on Issue {
+ id
+ iid
+ confidential
+ createdAt
+ downvotes
+ dueDate
+ hidden
+ humanTimeEstimate
+ mergeRequestsCount
+ moved
+ state
+ title
+ updatedAt
+ closedAt
+ upvotes
+ userDiscussionsCount @include(if: $isSignedIn)
+ webPath
+ webUrl
+ type
+ assignees @skip(if: $hideUsers) {
+ nodes {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ author @skip(if: $hideUsers) {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ labels {
+ nodes {
+ id
+ color
+ title
+ description
+ }
+ }
+ milestone {
+ id
+ dueDate
+ startDate
+ webPath
+ title
+ }
+ taskCompletionStatus {
+ completedCount
+ count
+ }
+}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index e3716d0e111..5b5f1d273d0 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -60,7 +60,7 @@ export function initShow() {
const { issueType, ...issuableData } = parseIssuableData(el);
if (issueType === IssueType.Incident) {
- initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId });
+ initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId }, store);
initHeaderActions(store, IssueType.Incident);
initLinkedResources();
initRelatedIssues(IssueType.Incident);
diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
index 5a37751410a..652d4e0fb42 100644
--- a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
+++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue
@@ -2,8 +2,9 @@
import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
+import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import { i18n } from '../constants';
-import NewIssueDropdown from './new_issue_dropdown.vue';
+import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin';
export default {
i18n,
@@ -14,8 +15,9 @@ export default {
GlEmptyState,
GlLink,
GlSprintf,
- NewIssueDropdown,
+ NewResourceDropdown,
},
+ mixins: [hasNewIssueDropdown()],
inject: [
'canCreateProjects',
'emptyStateSvgPath',
@@ -75,7 +77,13 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
- <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" />
+ <new-resource-dropdown
+ v-if="showNewIssueDropdown"
+ class="gl-align-self-center"
+ :query="$options.searchProjectsQuery"
+ :query-variables="newIssueDropdownQueryVariables"
+ :extract-projects="extractProjects"
+ />
</template>
</gl-empty-state>
<hr />
diff --git a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
index 1139861ae78..d11540ad3dd 100644
--- a/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues/list/components/issue_card_time_info.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { IssuableStatus } from '~/issues/constants';
+import { STATUS_CLOSED } from '~/issues/constants';
import {
dateInWords,
getTimeRemainingInWords,
@@ -43,8 +43,7 @@ export default {
},
showDueDateInRed() {
return (
- isInPast(newDateAsLocaleTime(this.issue.dueDate)) &&
- this.issue.state !== IssuableStatus.Closed
+ isInPast(newDateAsLocaleTime(this.issue.dueDate)) && this.issue.state !== STATUS_CLOSED
);
},
timeEstimate() {
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 e4000184f41..6c46013e4f9 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -7,18 +7,18 @@ import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
import { createAlert, VARIANT_INFO } from '~/flash';
-import { TYPE_USER } from '~/graphql_shared/constants';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
-import { IssuableStatus } from '~/issues/constants';
+import { STATUS_CLOSED } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
+import { fetchPolicies } from '~/lib/graphql';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
import {
- FILTERED_SEARCH_TERM,
OPERATORS_IS,
OPERATORS_IS_NOT,
OPERATORS_IS_NOT_OR,
@@ -48,6 +48,7 @@ import {
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import NewResourceDropdown from '~/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue';
import {
CREATED_DESC,
defaultTypeTokenOptions,
@@ -82,9 +83,9 @@ import {
getSortOptions,
isSortKey,
} from '../utils';
+import { hasNewIssueDropdown } from '../has_new_issue_dropdown_mixin';
import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue';
import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue';
-import NewIssueDropdown from './new_issue_dropdown.vue';
const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue');
const EmojiToken = () =>
@@ -112,12 +113,12 @@ export default {
IssuableList,
IssueCardStatistics,
IssueCardTimeInfo,
- NewIssueDropdown,
+ NewResourceDropdown,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
+ mixins: [glFeatureFlagMixin(), hasNewIssueDropdown()],
inject: [
'autocompleteAwardEmojisPath',
'calendarPath',
@@ -134,7 +135,6 @@ export default {
'hasScopedLabelsFeature',
'initialEmail',
'initialSort',
- 'isAnonymousSearchDisabled',
'isIssueRepositioningDisabled',
'isProject',
'isPublicVisibilityRestricted',
@@ -190,8 +190,15 @@ export default {
update(data) {
return data[this.namespace]?.issues.nodes ?? [];
},
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ // We need this for handling loading state when using frontend cache
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/106004#note_1217325202 for details
+ notifyOnNetworkStatusChange: true,
result({ data }) {
- this.pageInfo = data?.[this.namespace]?.issues.pageInfo ?? {};
+ if (!data) {
+ return;
+ }
+ this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
@@ -293,7 +300,7 @@ export default {
if (gon.current_user_id) {
preloadedUsers.push({
- id: convertToGraphQLId(TYPE_USER, gon.current_user_id),
+ id: convertToGraphQLId(TYPENAME_USER, gon.current_user_id),
name: gon.current_user_fullname,
username: gon.current_username,
avatar_url: gon.current_user_avatar_url,
@@ -354,6 +361,7 @@ export default {
token: LabelToken,
operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT,
fetchLabels: this.fetchLabels,
+ fetchLatestLabels: this.glFeatures.frontendCaching ? this.fetchLatestLabels : null,
recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`,
},
{
@@ -473,8 +481,16 @@ export default {
page_before: this.pageParams.beforeCursor ?? undefined,
};
},
- shouldDisableTextSearch() {
- return this.isAnonymousSearchDisabled && !this.isSignedIn;
+ // due to the issues with cache-and-network, we need this hack to check if there is any data for the query in the cache.
+ // if we have cached data, we disregard the loading state
+ isLoading() {
+ return (
+ this.$apollo.queries.issues.loading &&
+ !this.$apollo.provider.clients.defaultClient.readQuery({
+ query: getIssuesQuery,
+ variables: this.queryVariables,
+ })
+ );
},
},
watch: {
@@ -514,11 +530,12 @@ export default {
fetchReleases(search) {
return this.fetchWithCache(this.releasesPath, 'releases', 'tag', search);
},
- fetchLabels(search) {
+ fetchLabelsWithFetchPolicy(search, fetchPolicy = fetchPolicies.CACHE_FIRST) {
return this.$apollo
.query({
query: searchLabelsQuery,
variables: { fullPath: this.fullPath, search, isProject: this.isProject },
+ fetchPolicy,
})
.then(({ data }) => data[this.namespace]?.labels.nodes)
.then((labels) =>
@@ -527,6 +544,12 @@ export default {
labels.filter((label) => label.title.toLowerCase().includes(search.toLowerCase())),
);
},
+ fetchLabels(search) {
+ return this.fetchLabelsWithFetchPolicy(search);
+ },
+ fetchLatestLabels(search) {
+ return this.fetchLabelsWithFetchPolicy(search, fetchPolicies.NETWORK_ONLY);
+ },
fetchMilestones(search) {
return this.$apollo
.query({
@@ -549,10 +572,10 @@ export default {
return `${this.exportCsvPath}${window.location.search}`;
},
getStatus(issue) {
- if (issue.state === IssuableStatus.Closed && issue.moved) {
+ if (issue.state === STATUS_CLOSED && issue.moved) {
return this.$options.i18n.closedMoved;
}
- if (issue.state === IssuableStatus.Closed) {
+ if (issue.state === STATUS_CLOSED) {
return this.$options.i18n.closed;
}
return undefined;
@@ -569,9 +592,6 @@ export default {
const bulkUpdateSidebar = await import('~/issuable');
bulkUpdateSidebar.initBulkUpdateSidebar('issuable_');
- const UsersSelect = (await import('~/users_select')).default;
- new UsersSelect(); // eslint-disable-line no-new
-
this.hasInitBulkEdit = true;
}
@@ -591,7 +611,7 @@ export default {
this.issuesError = null;
},
handleFilter(tokens) {
- this.setFilterTokens(tokens);
+ this.filterTokens = tokens;
this.pageParams = getInitialPageParams(this.pageSize);
this.$router.push({ query: this.urlParams });
@@ -684,24 +704,6 @@ export default {
Sentry.captureException(error);
});
},
- setFilterTokens(tokens) {
- this.filterTokens = this.removeDisabledSearchTerms(tokens);
-
- if (this.filterTokens.length < tokens.length) {
- this.showAnonymousSearchingMessage();
- }
- },
- removeDisabledSearchTerms(filters) {
- return this.shouldDisableTextSearch
- ? filters.filter((token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data))
- : filters;
- },
- showAnonymousSearchingMessage() {
- createAlert({
- message: this.$options.i18n.anonymousSearchingMessage,
- variant: VARIANT_INFO,
- });
- },
showIssueRepositioningMessage() {
createAlert({
message: this.$options.i18n.issueRepositioningMessage,
@@ -737,7 +739,7 @@ export default {
sortKey = defaultSortKey;
}
- this.setFilterTokens(getFilterTokens(window.location.search));
+ this.filterTokens = getFilterTokens(window.location.search);
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
this.pageParams = getInitialPageParams(
@@ -773,7 +775,7 @@ export default {
:current-tab="state"
:tab-counts="tabCounts"
:truncate-counts="!isProject"
- :issuables-loading="$apollo.queries.issues.loading"
+ :issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
@@ -831,7 +833,12 @@ export default {
{{ $options.i18n.newIssueLabel }}
</gl-button>
<slot name="new-objective-button"></slot>
- <new-issue-dropdown v-if="showNewIssueDropdown" />
+ <new-resource-dropdown
+ v-if="showNewIssueDropdown"
+ :query="$options.searchProjectsQuery"
+ :query-variables="newIssueDropdownQueryVariables"
+ :extract-projects="extractProjects"
+ />
</template>
<template #timeframe="{ issuable = {} }">
diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
deleted file mode 100644
index e420c21a11f..00000000000
--- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue
+++ /dev/null
@@ -1,127 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlLoadingIcon,
- GlSearchBoxByType,
-} from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
-import { __, sprintf } from '~/locale';
-import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import searchProjectsQuery from '../queries/search_projects.query.graphql';
-
-export default {
- i18n: {
- defaultDropdownText: __('Select project to create issue'),
- noMatchesFound: __('No matches found'),
- toggleButtonLabel: __('Toggle project select'),
- },
- components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlLoadingIcon,
- GlSearchBoxByType,
- },
- inject: ['fullPath'],
- data() {
- return {
- projects: [],
- search: '',
- selectedProject: {},
- shouldSkipQuery: true,
- };
- },
- apollo: {
- projects: {
- query: searchProjectsQuery,
- variables() {
- return {
- fullPath: this.fullPath,
- search: this.search,
- };
- },
- update: ({ group }) => group.projects.nodes ?? [],
- error(error) {
- createAlert({
- message: __('An error occurred while loading projects.'),
- captureError: true,
- error,
- });
- },
- skip() {
- return this.shouldSkipQuery;
- },
- debounce: DEBOUNCE_DELAY,
- },
- },
- computed: {
- dropdownHref() {
- return this.hasSelectedProject
- ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new')
- : undefined;
- },
- dropdownText() {
- return this.hasSelectedProject
- ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name })
- : this.$options.i18n.defaultDropdownText;
- },
- hasSelectedProject() {
- return this.selectedProject.id;
- },
- projectsWithIssuesEnabled() {
- return this.projects.filter((project) => project.issuesEnabled);
- },
- showNoSearchResultsText() {
- return !this.projectsWithIssuesEnabled.length && this.search;
- },
- },
- methods: {
- handleDropdownClick() {
- if (!this.dropdownHref) {
- this.$refs.dropdown.show();
- }
- },
- handleDropdownShown() {
- if (this.shouldSkipQuery) {
- this.shouldSkipQuery = false;
- }
- this.$refs.search.focusInput();
- },
- selectProject(project) {
- this.selectedProject = project;
- },
- },
-};
-</script>
-
-<template>
- <gl-dropdown
- ref="dropdown"
- right
- split
- :split-href="dropdownHref"
- :text="dropdownText"
- :toggle-text="$options.i18n.toggleButtonLabel"
- variant="confirm"
- @click="handleDropdownClick"
- @shown="handleDropdownShown"
- >
- <gl-search-box-by-type ref="search" v-model.trim="search" />
- <gl-loading-icon v-if="$apollo.queries.projects.loading" />
- <template v-else>
- <gl-dropdown-item
- v-for="project of projectsWithIssuesEnabled"
- :key="project.id"
- @click="selectProject(project)"
- >
- {{ project.nameWithNamespace }}
- </gl-dropdown-item>
- <gl-dropdown-text v-if="showNoSearchResultsText">
- {{ $options.i18n.noMatchesFound }}
- </gl-dropdown-text>
- </template>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 87184799d5f..31a43c95f5e 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -76,7 +76,6 @@ export const SPECIAL_FILTER = 'specialFilter';
export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const i18n = {
- anonymousSearchingMessage: __('You must sign in to search for specific terms.'),
calendarLabel: __('Subscribe to calendar'),
closed: __('CLOSED'),
closedMoved: __('CLOSED (MOVED)'),
@@ -105,6 +104,7 @@ export const i18n = {
noIssuesDescription: __('Learn more about issues.'),
noIssuesTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'),
noIssuesSignedOutButtonText: __('Register / Sign In'),
+ noSearchNoFilterTitle: __('Please select at least one filter to see results'),
noSearchResultsDescription: __('To widen your search, change or remove filters above'),
noSearchResultsTitle: __('Sorry, your filter produced no results'),
relatedMergeRequests: __('Related merge requests'),
diff --git a/app/assets/javascripts/issues/list/graphql.js b/app/assets/javascripts/issues/list/graphql.js
index 5ef61727a3d..b590006929a 100644
--- a/app/assets/javascripts/issues/list/graphql.js
+++ b/app/assets/javascripts/issues/list/graphql.js
@@ -22,4 +22,6 @@ const resolvers = {
},
};
-export const gqlClient = createDefaultClient(resolvers);
+export const gqlClient = gon.features?.frontendCaching
+ ? createDefaultClient(resolvers, { localCacheKey: 'issues_list' })
+ : createDefaultClient(resolvers);
diff --git a/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js b/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js
new file mode 100644
index 00000000000..510edf9b78c
--- /dev/null
+++ b/app/assets/javascripts/issues/list/has_new_issue_dropdown_mixin.js
@@ -0,0 +1,18 @@
+import searchProjectsQuery from './queries/search_projects.query.graphql';
+
+export const hasNewIssueDropdown = () => ({
+ inject: ['fullPath'],
+ computed: {
+ newIssueDropdownQueryVariables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ },
+ methods: {
+ extractProjects(data) {
+ return data?.group?.projects?.nodes;
+ },
+ },
+ searchProjectsQuery,
+});
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index 7b68b7432c9..aca894549e4 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -78,7 +78,6 @@ export function mountIssuesListApp() {
importCsvIssuesPath,
initialEmail,
initialSort,
- isAnonymousSearchDisabled,
isIssueRepositioningDisabled,
isProject,
isPublicVisibilityRestricted,
@@ -127,7 +126,6 @@ export function mountIssuesListApp() {
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
hasOkrsFeature: parseBoolean(hasOkrsFeature),
initialSort,
- isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
isProject: parseBoolean(isProject),
isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted),
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 ee97fb6edca..1018848fb53 100644
--- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql
@@ -31,7 +31,7 @@ query getIssues(
$firstPageSize: Int
$lastPageSize: Int
) {
- group(fullPath: $fullPath) @skip(if: $isProject) {
+ group(fullPath: $fullPath) @skip(if: $isProject) @persist {
id
issues(
includeSubgroups: true
@@ -58,16 +58,18 @@ query getIssues(
first: $firstPageSize
last: $lastPageSize
) {
+ __persist
pageInfo {
...PageInfo
}
nodes {
+ __persist
...IssueFragment
reference(full: true)
}
}
}
- project(fullPath: $fullPath) @include(if: $isProject) {
+ project(fullPath: $fullPath) @include(if: $isProject) @persist {
id
issues(
iid: $iid
@@ -95,10 +97,12 @@ query getIssues(
first: $firstPageSize
last: $lastPageSize
) {
+ __persist
pageInfo {
...PageInfo
}
nodes {
+ __persist
...IssueFragment
}
}
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 040763f2ba4..3b49c0efb14 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -20,6 +20,7 @@ fragment IssueFragment on Issue {
type
assignees @skip(if: $hideUsers) {
nodes {
+ __persist
id
avatarUrl
name
@@ -28,6 +29,7 @@ fragment IssueFragment on Issue {
}
}
author @skip(if: $hideUsers) {
+ __persist
id
avatarUrl
name
@@ -36,6 +38,7 @@ fragment IssueFragment on Issue {
}
labels {
nodes {
+ __persist
id
color
title
@@ -43,6 +46,7 @@ fragment IssueFragment on Issue {
}
}
milestone {
+ __persist
id
dueDate
startDate
diff --git a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql
index 44b57317161..7c2aa19046c 100644
--- a/app/assets/javascripts/issues/list/queries/search_labels.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_labels.query.graphql
@@ -1,18 +1,22 @@
#import "./label.fragment.graphql"
query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) {
- group(fullPath: $fullPath) @skip(if: $isProject) {
+ group(fullPath: $fullPath) @skip(if: $isProject) @persist {
id
labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) {
+ __persist
nodes {
+ __persist
...Label
}
}
}
- project(fullPath: $fullPath) @include(if: $isProject) {
+ project(fullPath: $fullPath) @include(if: $isProject) @persist {
id
labels(searchTerm: $search, includeAncestorGroups: true) {
+ __persist
nodes {
+ __persist
...Label
}
}
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 bd2f9bc2340..2fd37489234 100644
--- a/app/assets/javascripts/issues/list/queries/search_projects.query.graphql
+++ b/app/assets/javascripts/issues/list/queries/search_projects.query.graphql
@@ -1,10 +1,9 @@
query searchProjects($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
id
- projects(search: $search, includeSubgroups: true) {
+ projects(search: $search, withIssuesEnabled: true, includeSubgroups: true) {
nodes {
id
- issuesEnabled
name
nameWithNamespace
webUrl
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index e5428f87095..decb559ee81 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -3,10 +3,11 @@ import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gi
import Visibility from 'visibilityjs';
import { createAlert } from '~/flash';
import {
- IssuableStatus,
IssuableStatusText,
+ STATUS_CLOSED,
+ TYPE_EPIC,
+ TYPE_ISSUE,
WorkspaceType,
- IssuableType,
} from '~/issues/constants';
import Poll from '~/lib/utils/poll';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -156,7 +157,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: IssuableType.Issue,
+ default: TYPE_ISSUE,
},
canAttachFile: {
type: Boolean,
@@ -190,6 +191,11 @@ export default {
required: false,
default: null,
},
+ issueIid: {
+ type: Number,
+ required: false,
+ default: null,
+ },
},
data() {
const store = new Store({
@@ -251,7 +257,7 @@ export default {
return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
isClosed() {
- return this.issuableStatus === IssuableStatus.Closed;
+ return this.issuableStatus === STATUS_CLOSED;
},
pinnedLinkClasses() {
return this.showTitleBorder
@@ -259,7 +265,7 @@ export default {
: '';
},
statusIcon() {
- if (this.issuableType === IssuableType.Issue) {
+ if (this.issuableType === TYPE_ISSUE) {
return this.isClosed ? 'issue-closed' : 'issues';
}
return this.isClosed ? 'epic-closed' : 'epic';
@@ -271,7 +277,7 @@ export default {
return IssuableStatusText[this.issuableStatus];
},
shouldShowStickyHeader() {
- return [IssuableType.Issue, IssuableType.Epic].includes(this.issuableType);
+ return [TYPE_ISSUE, TYPE_EPIC].includes(this.issuableType);
},
},
created() {
@@ -453,7 +459,7 @@ export default {
}
},
- handleListItemReorder(description) {
+ handleSaveDescription(description) {
this.updateFormState();
this.setFormState({ description });
this.updateIssuable();
@@ -564,6 +570,7 @@ export default {
<component
:is="descriptionComponent"
:issue-id="issueId"
+ :issue-iid="issueIid"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
:description-text="state.descriptionText"
@@ -573,7 +580,7 @@ export default {
:update-url="updateEndpoint"
:lock-version="state.lock_version"
:is-updating="formState.updateLoading"
- @listItemReorder="handleListItemReorder"
+ @saveDescription="handleSaveDescription"
@taskListUpdateStarted="taskListUpdateStarted"
@taskListUpdateSucceeded="taskListUpdateSucceeded"
@taskListUpdateFailed="taskListUpdateFailed"
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 78e729b97da..188a6f6b15e 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -1,13 +1,15 @@
<script>
-import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui';
+import { GlModalDirective, GlToast } from '@gitlab/ui';
import $ from 'jquery';
+import { uniqueId } from 'lodash';
import Sortable from 'sortablejs';
import Vue from 'vue';
+import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import { isMetaKey } from '~/lib/utils/common_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
@@ -15,22 +17,30 @@ import { __, s__, sprintf } from '~/locale';
import { getSortableDefaultOptions, isDragging } from '~/sortable/utils';
import TaskList from '~/task_list';
import Tracking from '~/tracking';
+import addHierarchyChildMutation from '~/work_items/graphql/add_hierarchy_child.mutation.graphql';
+import removeHierarchyChildMutation from '~/work_items/graphql/remove_hierarchy_child.mutation.graphql';
+import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
+import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
-import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
-
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
sprintfWorkItem,
I18N_WORK_ITEM_ERROR_CREATING,
+ I18N_WORK_ITEM_ERROR_DELETING,
TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
- WIDGET_TYPE_DESCRIPTION,
} from '~/work_items/constants';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
+import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
-import { convertDescriptionWithNewSort } from '../utils';
+import {
+ deleteTaskListItem,
+ convertDescriptionWithNewSort,
+ extractTaskTitleAndDescription,
+} from '../utils';
+import TaskListItemActions from './task_list_item_actions.vue';
Vue.use(GlToast);
@@ -44,11 +54,10 @@ export default {
GlModal: GlModalDirective,
},
components: {
- GlTooltip,
WorkItemDetailModal,
},
mixins: [animateMixin, glFeatureFlagMixin(), Tracking.mixin()],
- inject: ['fullPath'],
+ inject: ['fullPath', 'hasIterationsFeature'],
props: {
canUpdate: {
type: Boolean,
@@ -71,7 +80,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: IssuableType.Issue,
+ default: TYPE_ISSUE,
},
updateUrl: {
type: String,
@@ -88,6 +97,11 @@ export default {
required: false,
default: null,
},
+ issueIid: {
+ type: Number,
+ required: false,
+ default: null,
+ },
isUpdating: {
type: Boolean,
required: false,
@@ -98,18 +112,29 @@ export default {
const workItemId = getParameterByName('work_item_id');
return {
+ hasTaskListItemActions: false,
preAnimation: false,
pulseAnimation: false,
initialUpdate: true,
- taskButtons: [],
+ issueDetails: {},
activeTask: {},
workItemId: isPositiveInteger(workItemId)
- ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
+ ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
: undefined,
workItemTypes: [],
};
},
apollo: {
+ issueDetails: {
+ query: getIssueDetailsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.issueIid),
+ };
+ },
+ update: (data) => data.workspace?.issuable,
+ },
workItem: {
query: workItemQuery,
variables() {
@@ -118,7 +143,7 @@ export default {
};
},
skip() {
- return !this.workItemId || !this.workItemsEnabled;
+ return !this.workItemId || !this.workItemsMvcEnabled;
},
},
workItemTypes: {
@@ -132,19 +157,19 @@ export default {
return data.workspace?.workItemTypes?.nodes;
},
skip() {
- return !this.workItemsEnabled;
+ return !this.workItemsMvcEnabled;
},
},
},
computed: {
- workItemsEnabled() {
- return this.glFeatures.workItemsCreateFromMarkdown;
+ workItemsMvcEnabled() {
+ return this.glFeatures.workItemsMvc;
},
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
},
issueGid() {
- return this.issueId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issueId) : null;
+ return this.issueId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issueId) : null;
},
},
watch: {
@@ -164,10 +189,13 @@ export default {
},
},
mounted() {
+ eventHub.$on('convert-task-list-item', this.convertTaskListItem);
+ eventHub.$on('delete-task-list-item', this.deleteTaskListItem);
+
this.renderGFM();
this.updateTaskStatusText();
- if (this.workItemId && this.workItemsEnabled) {
+ if (this.workItemId && this.workItemsMvcEnabled) {
const taskLink = this.$el.querySelector(
`.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`,
);
@@ -175,6 +203,9 @@ export default {
}
},
beforeDestroy() {
+ eventHub.$off('convert-task-list-item', this.convertTaskListItem);
+ eventHub.$off('delete-task-list-item', this.deleteTaskListItem);
+
this.removeAllPointerEventListeners();
},
methods: {
@@ -197,8 +228,8 @@ export default {
this.renderSortableLists();
- if (this.workItemsEnabled) {
- this.renderTaskActions();
+ if (this.workItemsMvcEnabled) {
+ this.renderTaskListItemActions();
}
}
},
@@ -223,7 +254,7 @@ export default {
handle: '.drag-icon',
onUpdate: (event) => {
const description = convertDescriptionWithNewSort(this.descriptionText, event.to);
- this.$emit('listItemReorder', description);
+ this.$emit('saveDescription', description);
},
}),
);
@@ -232,29 +263,29 @@ export default {
createDragIconElement() {
const container = document.createElement('div');
// eslint-disable-next-line no-unsanitized/property
- container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true">
- <use href="${gon.sprite_icons}#drag-vertical"></use>
+ container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-opacity-0" role="img" aria-hidden="true">
+ <use href="${gon.sprite_icons}#grip"></use>
</svg>`;
return container.firstChild;
},
- addPointerEventListeners(listItem, iconSelector) {
+ addPointerEventListeners(listItem, elementSelector) {
const pointeroverListener = (event) => {
- const icon = event.target.closest('li').querySelector(iconSelector);
- if (!icon || isDragging() || this.isUpdating) {
+ const element = event.target.closest('li').querySelector(elementSelector);
+ if (!element || isDragging() || this.isUpdating) {
return;
}
- icon.style.visibility = 'visible';
+ element.classList.add('gl-opacity-10');
};
const pointeroutListener = (event) => {
- const icon = event.target.closest('li').querySelector(iconSelector);
- if (!icon) {
+ const element = event.target.closest('li').querySelector(elementSelector);
+ if (!element) {
return;
}
- icon.style.visibility = 'hidden';
+ element.classList.remove('gl-opacity-10');
};
// We use pointerover/pointerout instead of CSS so that when we hover over a
- // list item with children, the drag icons of its children do not become visible.
+ // list item with children, the grip icons of its children do not become visible.
listItem.addEventListener('pointerover', pointeroverListener);
listItem.addEventListener('pointerout', pointeroutListener);
@@ -279,11 +310,9 @@ export default {
taskListUpdateStarted() {
this.$emit('taskListUpdateStarted');
},
-
taskListUpdateSuccess() {
this.$emit('taskListUpdateSucceeded');
},
-
taskListUpdateError() {
createAlert({
message: sprintf(
@@ -298,7 +327,6 @@ export default {
this.$emit('taskListUpdateFailed');
},
-
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
@@ -317,22 +345,42 @@ export default {
$tasksShort.text('');
}
},
- renderTaskActions() {
+ createTaskListItemActions(provide) {
+ const app = new Vue({
+ el: document.createElement('div'),
+ provide,
+ render: (createElement) => createElement(TaskListItemActions),
+ });
+ return app.$el;
+ },
+ convertTaskListItem(sourcepos) {
+ const oldDescription = this.descriptionText;
+ const { newDescription, taskDescription, taskTitle } = deleteTaskListItem(
+ oldDescription,
+ sourcepos,
+ );
+ this.$emit('saveDescription', newDescription);
+ this.createTask({ taskTitle, taskDescription, oldDescription });
+ },
+ deleteTaskListItem(sourcepos) {
+ const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos);
+ this.$emit('saveDescription', newDescription);
+ },
+ renderTaskListItemActions() {
if (!this.$el?.querySelectorAll) {
return;
}
- this.taskButtons = [];
const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)');
- taskListFields.forEach((item, index) => {
+ taskListFields.forEach((item) => {
const taskLink = item.querySelector('.gfm-issue');
if (taskLink) {
const { issue, referenceType, issueType } = taskLink.dataset;
if (issueType !== workItemTypes.TASK) {
return;
}
- const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
+ const workItemId = convertToGraphQLId(TYPENAME_WORK_ITEM, issue);
this.addHoverListeners(taskLink, workItemId);
taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
@@ -351,31 +399,12 @@ export default {
});
return;
}
- this.addPointerEventListeners(item, '.js-add-task');
- const button = document.createElement('button');
- button.classList.add(
- 'btn',
- 'btn-default',
- 'btn-md',
- 'gl-button',
- 'btn-default-tertiary',
- 'gl-visibility-hidden',
- 'gl-p-0!',
- 'gl-mt-n1',
- 'gl-ml-3',
- 'js-add-task',
- );
- button.id = `js-task-button-${index}`;
- this.taskButtons.push(button.id);
- // eslint-disable-next-line no-unsanitized/property
- button.innerHTML = `
- <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
- <use href="${gon.sprite_icons}#doc-new"></use>
- </svg>
- `;
- button.setAttribute('aria-label', s__('WorkItem|Create task'));
- button.addEventListener('click', () => this.handleCreateTask(button));
- this.insertButtonNextToTaskText(item, button);
+
+ const toggleClass = uniqueId('task-list-item-actions-');
+ const dropdown = this.createTaskListItemActions({ canUpdate: this.canUpdate, toggleClass });
+ this.addPointerEventListeners(item, `.${toggleClass}`);
+ this.insertNextToTaskListItemText(dropdown, item);
+ this.hasTaskListItemActions = true;
});
},
addHoverListeners(taskLink, id) {
@@ -391,19 +420,20 @@ export default {
}
});
},
- insertButtonNextToTaskText(listItem, button) {
- const paragraph = Array.from(listItem.children).find((element) => element.tagName === 'P');
- const lastChild = listItem.lastElementChild;
+ insertNextToTaskListItemText(element, listItem) {
+ const children = Array.from(listItem.children);
+ const paragraph = children.find((el) => el.tagName === 'P');
+ const list = children.find((el) => el.classList.contains('task-list'));
if (paragraph) {
// If there's a `p` element, then it's a multi-paragraph task item
// and the task text exists within the `p` element as the last child
- paragraph.append(button);
- } else if (lastChild.tagName === 'OL' || lastChild.tagName === 'UL') {
+ paragraph.append(element);
+ } else if (list) {
// Otherwise, the task item can have a child list which exists directly after the task text
- lastChild.insertAdjacentElement('beforebegin', button);
+ list.insertAdjacentElement('beforebegin', element);
} else {
// Otherwise, the task item is a simple one where the task text exists as the last child
- listItem.append(button);
+ listItem.append(element);
}
},
setActiveTask(el) {
@@ -427,55 +457,90 @@ export default {
this.workItemId = undefined;
this.updateWorkItemIdUrlQuery(undefined);
},
- async handleCreateTask(el) {
- this.setActiveTask(el);
+ async createTask({ taskTitle, taskDescription, oldDescription }) {
try {
- const { data } = await this.$apollo.mutate({
- mutation: createWorkItemFromTaskMutation,
- variables: {
- input: {
- id: this.issueGid,
- workItemData: {
- lockVersion: this.lockVersion,
- title: this.activeTask.title,
- lineNumberStart: Number(this.activeTask.lineNumberStart),
- lineNumberEnd: Number(this.activeTask.lineNumberEnd),
- workItemTypeId: this.taskWorkItemType,
- },
- },
+ const { title, description } = extractTaskTitleAndDescription(taskTitle, taskDescription);
+ const iterationInput = {
+ iterationWidget: {
+ iterationId: this.issueDetails.iteration?.id ?? null,
},
- update(store, { data: { workItemCreateFromTask } }) {
- const { newWorkItem } = workItemCreateFromTask;
-
- store.writeQuery({
- query: workItemQuery,
- variables: {
- id: newWorkItem.id,
- },
- data: {
- workItem: newWorkItem,
- },
- });
+ };
+ const input = {
+ confidential: this.issueDetails.confidential,
+ description,
+ hierarchyWidget: {
+ parentId: this.issueGid,
+ },
+ ...(this.hasIterationsFeature && iterationInput),
+ milestoneWidget: {
+ milestoneId: this.issueDetails.milestone?.id ?? null,
},
+ projectPath: this.fullPath,
+ title,
+ workItemTypeId: this.taskWorkItemType,
+ };
+
+ const { data } = await this.$apollo.mutate({
+ mutation: createWorkItemMutation,
+ variables: { input },
});
- const { workItem, newWorkItem } = data.workItemCreateFromTask;
+ const { workItem, errors } = data.workItemCreate;
+
+ if (errors?.length) {
+ throw new Error(errors);
+ }
- const updatedDescription = workItem?.widgets?.find(
- (widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
- )?.descriptionHtml;
+ await this.$apollo.mutate({
+ mutation: addHierarchyChildMutation,
+ variables: { id: this.issueGid, workItem },
+ });
- this.$emit('updateDescription', updatedDescription);
- this.workItemId = newWorkItem.id;
- this.openWorkItemDetailModal(el);
+ this.$toast.show(s__('WorkItem|Converted to task'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: (_, toast) => {
+ this.undoCreateTask(oldDescription, workItem.id);
+ toast.hide();
+ },
+ },
+ });
} catch (error) {
- createAlert({
- message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK),
- error,
- captureError: true,
+ this.showAlert(I18N_WORK_ITEM_ERROR_CREATING, error);
+ }
+ },
+ async undoCreateTask(oldDescription, id) {
+ this.$emit('saveDescription', oldDescription);
+
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: deleteWorkItemMutation,
+ variables: { input: { id } },
+ });
+
+ const { errors } = data.workItemDelete;
+
+ if (errors?.length) {
+ throw new Error(errors);
+ }
+
+ await this.$apollo.mutate({
+ mutation: removeHierarchyChildMutation,
+ variables: { id: this.issueGid, workItem: { id } },
});
+
+ this.$toast.show(s__('WorkItem|Task reverted'));
+ } catch (error) {
+ this.showAlert(I18N_WORK_ITEM_ERROR_DELETING, error);
}
},
+ showAlert(message, error) {
+ createAlert({
+ message: sprintfWorkItem(message, workItemTypes.TASK),
+ error,
+ captureError: true,
+ });
+ },
handleDeleteTask(description) {
this.$emit('updateDescription', description);
this.$toast.show(s__('WorkItem|Task deleted'));
@@ -492,14 +557,7 @@ export default {
</script>
<template>
- <div
- v-if="descriptionHtml"
- :class="{
- 'js-task-list-container': canUpdate,
- 'work-items-enabled': workItemsEnabled,
- }"
- class="description"
- >
+ <div v-if="descriptionHtml" :class="{ 'js-task-list-container': canUpdate }" class="description">
<div
ref="gfm-content"
v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
@@ -507,10 +565,10 @@ export default {
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
+ 'has-task-list-item-actions': hasTaskListItemActions,
}"
class="md"
></div>
-
<textarea
v-if="descriptionText"
:value="descriptionText"
@@ -531,10 +589,5 @@ export default {
@workItemDeleted="handleDeleteTask"
@close="closeWorkItemDetailModal"
/>
- <template v-if="workItemsEnabled">
- <gl-tooltip v-for="item in taskButtons" :key="item" :target="item">
- {{ s__('WorkItem|Create task') }}
- </gl-tooltip>
- </template>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index 04c5007dbec..3bc24e8ce01 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
@@ -35,6 +36,16 @@ export default {
default: true,
},
},
+ data() {
+ return {
+ formFieldProps: {
+ id: 'issue-description',
+ name: 'issue-description',
+ placeholder: __('Write a comment or drag your files here…'),
+ 'aria-label': __('Description'),
+ },
+ };
+ },
computed: {
quickActionsDocsPath() {
return helpPagePath('user/project/quick_actions');
@@ -60,10 +71,7 @@ export default {
:value="value"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :form-field-aria-label="__('Description')"
- :form-field-placeholder="__('Write a comment or drag your files here…')"
- form-field-id="issue-description"
- form-field-name="issue-description"
+ :form-field-props="formFieldProps"
:quick-actions-docs-path="quickActionsDocsPath"
:enable-autocomplete="enableAutocomplete"
supports-quick-actions
@@ -84,15 +92,13 @@ export default {
>
<template #textarea>
<textarea
- id="issue-description"
+ v-bind="formFieldProps"
ref="textarea"
:value="value"
class="note-textarea js-gfm-input js-autosize markdown-area"
data-qa-selector="description_field"
dir="auto"
data-supports-quick-actions="true"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
@input="$emit('input', $event.target.value)"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"
diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue
index 5695efd7114..5ade1a86d30 100644
--- a/app/assets/javascripts/issues/show/components/fields/type.vue
+++ b/app/assets/javascripts/issues/show/components/fields/type.vue
@@ -1,6 +1,6 @@
<script>
-import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
-import { capitalize } from 'lodash';
+import { GlFormGroup, GlIcon, GlListbox } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import { issuableTypes, INCIDENT_TYPE } from '../../constants';
import getIssueStateQuery from '../../queries/get_issue_state.query.graphql';
@@ -16,34 +16,35 @@ export default {
components: {
GlFormGroup,
GlIcon,
- GlDropdown,
- GlDropdownItem,
+ GlListbox,
},
inject: {
canCreateIncident: {
default: false,
},
issueType: {
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
data() {
return {
issueState: {},
+ selectedIssueType: '',
};
},
apollo: {
issueState: {
query: getIssueStateQuery,
+ result({
+ data: {
+ issueState: { issueType },
+ },
+ }) {
+ this.selectedIssueType = issueType;
+ },
},
},
computed: {
- dropdownText() {
- const {
- issueState: { issueType },
- } = this;
- return issuableTypes.find((type) => type.value === issueType)?.text || capitalize(issueType);
- },
shouldShowIncident() {
return this.issueType === INCIDENT_TYPE || this.canCreateIncident;
},
@@ -72,25 +73,21 @@ export default {
label-for="issuable-type"
class="mb-2 mb-md-0"
>
- <gl-dropdown
- id="issuable-type"
- :aria-labelledby="$options.i18n.label"
- :text="dropdownText"
+ <gl-listbox
+ v-model="selectedIssueType"
+ toggle-class="gl-mb-0"
+ :items="$options.issuableTypes"
:header-text="$options.i18n.label"
- class="gl-w-full"
- toggle-class="dropdown-menu-toggle"
+ :list-aria-labelled-by="$options.i18n.label"
+ block
+ @select="updateIssueType"
>
- <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
- @click="updateIssueType(type.value)"
- >
- <gl-icon :name="type.icon" />
- {{ type.text }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <template #list-item="{ item }">
+ <span v-show="isShown(item)" data-testid="issue-type-list-item">
+ <gl-icon :name="item.icon" />
+ {{ item.text }}
+ </span>
+ </template>
+ </gl-listbox>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index b56c91d7983..bcea9cf57a7 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { getDraft, updateDraft, getLockVersion, clearDraft } from '~/lib/utils/autosave';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import eventHub from '../event_hub';
import EditActions from './edit_actions.vue';
import DescriptionField from './fields/description.vue';
@@ -98,7 +98,7 @@ export default {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
},
isIssueType() {
- return this.issuableType === IssuableType.Issue;
+ return this.issuableType === TYPE_ISSUE;
},
},
watch: {
diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue
index 56e360c75e3..9d92b5cf954 100644
--- a/app/assets/javascripts/issues/show/components/header_actions.vue
+++ b/app/assets/javascripts/issues/show/components/header_actions.vue
@@ -12,7 +12,7 @@ import {
import { mapActions, mapGetters, mapState } from 'vuex';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
-import { IssuableStatus, IssueType } from '~/issues/constants';
+import { IssueType, STATUS_CLOSED } from '~/issues/constants';
import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
@@ -98,6 +98,12 @@ export default {
submitAsSpamPath: {
default: '',
},
+ reportedUserId: {
+ default: '',
+ },
+ reportedFromUrl: {
+ default: '',
+ },
},
data() {
return {
@@ -108,7 +114,7 @@ export default {
...mapState(['isToggleStateButtonLoading']),
...mapGetters(['openState', 'getBlockedByIssues']),
isClosed() {
- return this.openState === IssuableStatus.Closed;
+ return this.openState === STATUS_CLOSED;
},
issueTypeText() {
const issueTypeTexts = {
@@ -368,7 +374,12 @@ export default {
:title="deleteButtonText"
/>
+ <!-- IMPORTANT: show this component lazily because it causes layout thrashing -->
+ <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 -->
<abuse-category-selector
+ v-if="isReportAbuseDrawerOpen"
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedFromUrl"
:show-drawer="isReportAbuseDrawerOpen"
@close-drawer="toggleReportAbuseDrawer(false)"
/>
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index 2fdae538902..c0aadf9c14e 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -47,9 +47,21 @@ export const timelineItemI18n = Object.freeze({
export const timelineEventTagsI18n = Object.freeze({
startTime: __('Start time'),
+ impactDetected: __('Impact detected'),
+ responseInitiated: __('Response initiated'),
+ impactMitigated: __('Impact mitigated'),
+ causeIdentified: __('Cause identified'),
endTime: __('End time'),
});
+export const timelineEventTagsPopover = Object.freeze({
+ title: s__('Incident|Event tag'),
+ message: s__(
+ 'Incident|Adding an event tag associates the timeline comment with specific incident metrics.',
+ ),
+ link: __('Learn more'),
+});
+
export const MAX_TEXT_LENGTH = 280;
export const TIMELINE_EVENT_TAGS = Object.values(timelineEventTagsI18n).map((item) => ({
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
index 81111d42b39..40cb7fbb0ff 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -5,7 +5,7 @@ import { GlIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import { timelineFormI18n } from './constants';
import TimelineEventsForm from './timeline_events_form.vue';
@@ -41,7 +41,7 @@ export default {
}
const variables = {
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId),
fullPath: this.fullPath,
};
@@ -71,7 +71,7 @@ export default {
mutation: CreateTimelineEvent,
variables: {
input: {
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId),
note: eventDetails.note,
occurredAt: eventDetails.occurredAt,
timelineEventTagNames: eventDetails.timelineEventTags,
@@ -113,13 +113,13 @@ export default {
>
<div
v-if="hasTimelineEvents"
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1"
>
<gl-icon name="comment" class="note-icon" />
</div>
<timeline-events-form
ref="eventForm"
- :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }"
+ :class="{ 'gl-border-gray-50 gl-border-t gl-pt-3': hasTimelineEvents }"
:is-event-processed="createTimelineEventActive"
show-save-and-add
@save-event="createIncidentTimelineEvent"
diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
index 4ef9b9c5a99..c2fb8b6f683 100644
--- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
@@ -28,9 +28,9 @@ export default {
</script>
<template>
- <div class="gl-relative gl-display-flex gl-align-items-center">
+ <div class="edit-timeline-event gl-relative gl-display-flex gl-align-items-center">
<div
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-z-index-1"
>
<gl-icon name="comment" class="note-icon" />
</div>
@@ -40,6 +40,7 @@ export default {
:is-event-processed="editTimelineEventActive"
:previous-occurred-at="event.occurredAt"
:previous-note="event.note"
+ :previous-tags="event.timelineEventTags.nodes"
is-editing
@save-event="saveEvent"
@cancel="$emit('hide-edit')"
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
index 54f036268cc..77f955c08dc 100644
--- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
@@ -7,6 +7,12 @@ mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) {
action
occurredAt
createdAt
+ timelineEventTags {
+ nodes {
+ id
+ name
+ }
+ }
}
errors
}
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index 53956fcb4b2..997fadec602 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -125,8 +125,8 @@ export default {
item.classList.toggle('gl-display-none', !isSummaryTab);
});
- editButton.classList.toggle('gl-display-none', !isSummaryTab);
- editButton.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab);
+ editButton?.classList.toggle('gl-display-none', !isSummaryTab);
+ editButton?.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab);
}
},
},
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
index 6648e20865d..7944362a40f 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -1,10 +1,11 @@
<script>
-import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlListbox } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlCollapsibleListbox } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, sprintf } from '~/locale';
+import TimelineEventsTagsPopover from './timeline_events_tags_popover.vue';
import { MAX_TEXT_LENGTH, TIMELINE_EVENT_TAGS, timelineFormI18n } from './constants';
-import { getUtcShiftedDate } from './utils';
+import { getUtcShiftedDate, getPreviousEventTags } from './utils';
export default {
name: 'TimelineEventsForm',
@@ -21,11 +22,12 @@ export default {
],
components: {
MarkdownField,
+ TimelineEventsTagsPopover,
GlDatepicker,
GlFormInput,
GlFormGroup,
GlButton,
- GlListbox,
+ GlCollapsibleListbox,
},
mixins: [glFeatureFlagsMixin()],
i18n: timelineFormI18n,
@@ -77,7 +79,7 @@ export default {
hourPickerInput: placeholderDate.getHours(),
minutePickerInput: placeholderDate.getMinutes(),
datePickerInput: placeholderDate,
- selectedTags: [...this.previousTags],
+ selectedTags: getPreviousEventTags(this.previousTags),
};
},
computed: {
@@ -101,19 +103,19 @@ export default {
timelineTextCount() {
return this.timelineText.length;
},
- dropdownText() {
+ listboxText() {
if (!this.selectedTags.length) {
return timelineFormI18n.selectTags;
}
- const dropdownText =
+ const listboxText =
this.selectedTags.length === 1
? this.selectedTags[0]
: sprintf(__('%{numberOfSelectedTags} tags'), {
numberOfSelectedTags: this.selectedTags.length,
});
- return dropdownText;
+ return listboxText;
},
},
mounted() {
@@ -164,11 +166,11 @@ export default {
<template>
<form class="gl-flex-grow-1 gl-border-gray-50">
- <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row">
- <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
+ <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-mt-3">
+ <gl-form-group :label="__('Date')" class="gl-mr-5">
<gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" />
</gl-form-group>
- <div class="gl-display-flex gl-mt-5">
+ <div class="gl-display-flex">
<gl-form-group :label="__('Time')">
<div class="gl-display-flex">
<label label-for="timeline-input-hours" class="sr-only"></label>
@@ -197,10 +199,15 @@ export default {
<p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
</div>
- <gl-form-group v-if="glFeatures.incidentEventTags" :label="$options.i18n.tagsLabel">
- <gl-listbox
+ <gl-form-group v-if="glFeatures.incidentEventTags">
+ <label class="gl-display-flex gl-align-items-center gl-gap-3" for="timeline-input-tags">
+ {{ $options.i18n.tagsLabel }}
+ <timeline-events-tags-popover />
+ </label>
+ <gl-collapsible-listbox
+ id="timeline-input-tags"
:selected="selectedTags"
- :toggle-text="dropdownText"
+ :toggle-text="listboxText"
:items="tags"
:is-check-centered="true"
:multiple="true"
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
index 90ee4351e39..d33f3146d64 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
@@ -32,16 +32,19 @@ export default {
type: String,
required: true,
},
- eventTag: {
- type: String,
+ eventTags: {
+ type: Array,
required: false,
- default: null,
+ default: () => [],
},
},
computed: {
time() {
return formatDate(this.occurredAt, 'HH:MM', true);
},
+ canEditEvent() {
+ return this.action === 'comment';
+ },
},
methods: {
getEventIcon,
@@ -51,19 +54,24 @@ export default {
<template>
<div class="timeline-event gl-display-grid">
<div
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
>
<gl-icon :name="getEventIcon(action)" class="note-icon" />
</div>
<div class="timeline-event-note timeline-event-border" data-testid="event-text-container">
- <div class="gl-display-flex gl-align-items-center gl-mb-3">
- <strong class="gl-font-lg" data-testid="event-time">
+ <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-3 gl-mb-2">
+ <h3
+ class="timeline-event-note-date gl-font-weight-bold gl-font-sm gl-my-0"
+ data-testid="event-time"
+ >
<gl-sprintf :message="$options.i18n.timeUTC">
- <template #time>{{ time }}</template>
+ <template #time>
+ <span class="gl-font-lg">{{ time }}</span>
+ </template>
</gl-sprintf>
- </strong>
- <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3">
- {{ eventTag }}
+ </h3>
+ <gl-badge v-for="tag in eventTags" :key="tag.key" variant="muted" icon="tag">
+ {{ tag.name }}
</gl-badge>
</div>
<div v-safe-html="noteHtml" class="md"></div>
@@ -78,7 +86,7 @@ export default {
category="tertiary"
no-caret
>
- <gl-dropdown-item @click="$emit('edit')">
+ <gl-dropdown-item v-if="canEditEvent" @click="$emit('edit')">
{{ $options.i18n.edit }}
</gl-dropdown-item>
<gl-dropdown-item @click="$emit('delete')">
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index c6b93201c97..10b80529a66 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -50,9 +50,6 @@ export default {
},
},
methods: {
- getFirstTag(eventTag) {
- return eventTag.nodes?.[0]?.name;
- },
handleEditSelection(event) {
this.eventToEdit = event.id;
this.$emit('hide-new-incident-timeline-event-form');
@@ -105,6 +102,7 @@ export default {
id: eventDetails.id,
note: eventDetails.note,
occurredAt: eventDetails.occurredAt,
+ timelineEventTagNames: eventDetails.timelineEventTags,
},
},
})
@@ -132,21 +130,25 @@ export default {
</script>
<template>
- <div class="issuable-discussion incident-timeline-events">
+ <div class="issuable-discussion incident-timeline-events gl-mt-n3">
<div
v-for="[eventDate, events] in dateGroupedEvents"
:key="eventDate"
data-testid="timeline-group"
class="timeline-group"
>
- <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
- <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
- </div>
+ <h2
+ class="gl-font-size-h2 gl-my-0 gl-py-5 gl-border-gray-50 gl-border-1 gl-border-b-solid"
+ data-testid="event-date"
+ >
+ {{ eventDate }}
+ </h2>
+
<ul class="notes main-notes-list">
<li
v-for="(event, eventIndex) in events"
:key="eventIndex"
- class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!"
+ class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-0! gl-pr-0!"
>
<edit-timeline-event
v-if="eventToEdit === event.id"
@@ -164,7 +166,7 @@ export default {
:action="event.action"
:occurred-at="event.occurredAt"
:note-html="event.noteHtml"
- :event-tag="getFirstTag(event.timelineEventTags)"
+ :event-tags="event.timelineEventTags.nodes"
@delete="handleDelete(event)"
@edit="handleEditSelection(event)"
/>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
index c8237766505..cb18d34b70b 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import { fetchPolicies } from '~/lib/graphql';
import notesEventHub from '~/notes/event_hub';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
@@ -33,7 +33,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ incidentId: convertToGraphQLId(TYPENAME_ISSUE, this.issuableId),
};
},
update(data) {
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue
new file mode 100644
index 00000000000..772a16e9ba2
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tags_popover.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlIcon, GlPopover, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { timelineEventTagsPopover } from './constants';
+
+export default {
+ name: 'TimelineEventsTagsPopover',
+ components: {
+ GlIcon,
+ GlPopover,
+ GlLink,
+ },
+ i18n: timelineEventTagsPopover,
+ learnMoreLink: helpPagePath('ee/operations/incident_management/incident_timeline_events', {
+ anchor: 'incident-tags',
+ }),
+};
+</script>
+
+<template>
+ <span>
+ <gl-icon id="timeline-events-tag-question" name="question-o" class="gl-text-blue-600" />
+
+ <gl-popover
+ target="timeline-events-tag-question"
+ triggers="hover focus"
+ placement="top"
+ container="viewport"
+ :title="$options.i18n.title"
+ >
+ <div>
+ <p class="gl-mb-0">
+ {{ $options.i18n.message }}
+ </p>
+ <gl-link target="_blank" class="gl-font-sm" :href="$options.learnMoreLink">{{
+ $options.i18n.link
+ }}</gl-link
+ >.
+ </div>
+ </gl-popover>
+ </span>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index 5a009debd75..ce33e91c3b8 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -32,3 +32,11 @@ export const getUtcShiftedDate = (ISOString = null) => {
return date;
};
+
+/**
+ * Returns an array of previously set event tags
+ * @param {array} timelineEventTagsNodes
+ * @returns {array}
+ */
+export const getPreviousEventTags = (timelineEventTagsNodes = []) =>
+ timelineEventTagsNodes.map(({ name }) => name);
diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
new file mode 100644
index 00000000000..d0beb0f39b3
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ i18n: {
+ convertToTask: s__('WorkItem|Convert to task'),
+ delete: __('Delete'),
+ taskActions: s__('WorkItem|Task actions'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ inject: ['canUpdate', 'toggleClass'],
+ methods: {
+ convertToTask() {
+ eventHub.$emit('convert-task-list-item', this.$el.closest('li').dataset.sourcepos);
+ },
+ deleteTaskListItem() {
+ eventHub.$emit('delete-task-list-item', this.$el.closest('li').dataset.sourcepos);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ class="task-list-item-actions-wrapper"
+ category="tertiary"
+ icon="ellipsis_v"
+ lazy
+ no-caret
+ right
+ :text="$options.i18n.taskActions"
+ text-sr-only
+ :toggle-class="`task-list-item-actions gl-opacity-0 gl-p-2! ${toggleClass}`"
+ >
+ <gl-dropdown-item v-if="canUpdate" @click="convertToTask">
+ {{ $options.i18n.convertToTask }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canUpdate" variant="danger" @click="deleteTaskListItem">
+ {{ $options.i18n.delete }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js
deleted file mode 100644
index deee034f9d1..00000000000
--- a/app/assets/javascripts/issues/show/graphql.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { defaultClient } from '~/graphql_shared/issuable_client';
-
-Vue.use(VueApollo);
-
-export default new VueApollo({
- defaultClient,
-});
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 21d877c5fe6..1793ce66ad4 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import { mapGetters } from 'vuex';
import errorTrackingStore from '~/error_tracking/store';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import { parseBoolean } from '~/lib/utils/common_utils';
import { scrollToTargetOnResize } from '~/lib/utils/resize_observer';
import IssueApp from './components/app.vue';
@@ -8,7 +9,6 @@ import HeaderActions from './components/header_actions.vue';
import IncidentTabs from './components/incidents/incident_tabs.vue';
import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue';
import { INCIDENT_TYPE, issueState } from './constants';
-import apolloProvider from './graphql';
import getIssueStateQuery from './queries/get_issue_state.query.graphql';
const bootstrapApollo = (state = {}) => {
@@ -20,7 +20,7 @@ const bootstrapApollo = (state = {}) => {
});
};
-export function initIncidentApp(issueData = {}) {
+export function initIncidentApp(issueData = {}, store) {
const el = document.getElementById('js-issuable-app');
if (!el) {
@@ -49,6 +49,7 @@ export function initIncidentApp(issueData = {}) {
el,
name: 'DescriptionRoot',
apolloProvider,
+ store,
provide: {
issueType: INCIDENT_TYPE,
canCreateIncident,
@@ -62,6 +63,9 @@ export function initIncidentApp(issueData = {}) {
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
contentEditorOnIssues: gon.features.contentEditorOnIssues,
},
+ computed: {
+ ...mapGetters(['getNoteableData']),
+ },
render(createElement) {
return createElement(IssueApp, {
props: {
@@ -70,6 +74,7 @@ export function initIncidentApp(issueData = {}) {
issuableStatus: state,
descriptionComponent: IncidentTabs,
showTitleBorder: false,
+ isConfidential: this.getNoteableData?.confidential,
},
});
},
@@ -89,7 +94,12 @@ export function initIssueApp(issueData, store) {
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
- const { canCreateIncident, hasIssueWeightsFeature, ...issueProps } = issueData;
+ const {
+ canCreateIncident,
+ hasIssueWeightsFeature,
+ hasIterationsFeature,
+ ...issueProps
+ } = issueData;
return new Vue({
el,
@@ -102,6 +112,7 @@ export function initIssueApp(issueData, store) {
registerPath,
signInPath,
hasIssueWeightsFeature,
+ hasIterationsFeature,
},
computed: {
...mapGetters(['getNoteableData']),
@@ -114,6 +125,7 @@ export function initIssueApp(issueData, store) {
isLocked: this.getNoteableData?.discussion_locked,
issuableStatus: this.getNoteableData?.state,
issueId: this.getNoteableData?.id,
+ issueIid: this.getNoteableData?.iid,
},
});
},
@@ -152,7 +164,7 @@ export function initHeaderActions(store, type = '') {
projectPath: el.dataset.projectPath,
projectId: el.dataset.projectId,
reportAbusePath: el.dataset.reportAbusePath,
- reportedUserId: el.dataset.reportedUserId,
+ reportedUserId: parseInt(el.dataset.reportedUserId, 10),
reportedFromUrl: el.dataset.reportedFromUrl,
submitAsSpamPath: el.dataset.submitAsSpamPath,
},
diff --git a/app/assets/javascripts/issues/show/utils.js b/app/assets/javascripts/issues/show/utils.js
index 05b06586362..7742a015836 100644
--- a/app/assets/javascripts/issues/show/utils.js
+++ b/app/assets/javascripts/issues/show/utils.js
@@ -1,4 +1,6 @@
+import { TITLE_LENGTH_MAX } from '~/issues/constants';
import { COLON, HYPHEN, NEWLINE } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
/**
* Returns the start and end `sourcepos` rows, converted to zero-based numbering.
@@ -93,3 +95,136 @@ export const convertDescriptionWithNewSort = (description, list) => {
return descriptionLines.join(NEWLINE);
};
+
+const bulletTaskListItemRegex = /^\s*[-*]\s+\[.]\s+/;
+const numericalTaskListItemRegex = /^\s*[0-9]\.\s+\[.]\s+/;
+const codeMarkdownRegex = /^\s*`.*`\s*$/;
+const imageOrLinkMarkdownRegex = /^\s*!?\[.*\)\s*$/;
+
+/**
+ * Checks whether the line of markdown contains a task list item,
+ * i.e. `- [ ]`, `* [ ]`, or `1. [ ]`.
+ *
+ * @param {String} line A line of markdown
+ * @returns {boolean} `true` if the line contains a task list item, otherwise `false`
+ */
+const containsTaskListItem = (line) =>
+ bulletTaskListItemRegex.test(line) || numericalTaskListItemRegex.test(line);
+
+/**
+ * Deletes a task list item from the description.
+ *
+ * Starting from the task list item, it deletes each line until it hits a nested
+ * task list item and reduces the indentation of each line from this line onwards.
+ *
+ * For example, for a given description like:
+ *
+ * <pre>
+ * 1. [ ] item 1
+ *
+ * paragraph text
+ *
+ * 1. [ ] item 2
+ *
+ * paragraph text
+ *
+ * 1. [ ] item 3
+ * </pre>
+ *
+ * Then when prompted to delete item 1, this function will return:
+ *
+ * <pre>
+ * 1. [ ] item 2
+ *
+ * paragraph text
+ *
+ * 1. [ ] item 3
+ * </pre>
+ *
+ * @param {String} description Description in markdown format
+ * @param {String} sourcepos Source position in format `23:3-23:14`
+ * @returns {{newDescription: String, taskDescription: String, taskTitle: String}} Object with:
+ *
+ * - `newDescription` property that contains markdown with the deleted task list item omitted
+ * - `taskDescription` property that contains the description of the deleted task list item
+ * - `taskTitle` property that contains the title of the deleted task list item
+ */
+export const deleteTaskListItem = (description, sourcepos) => {
+ const descriptionLines = description.split(NEWLINE);
+ const [startIndex, endIndex] = getSourceposRows(sourcepos);
+
+ const firstLine = descriptionLines[startIndex];
+ const firstLineIndentation = firstLine.length - firstLine.trimStart().length;
+
+ const taskTitle = firstLine
+ .replace(bulletTaskListItemRegex, '')
+ .replace(numericalTaskListItemRegex, '');
+ const taskDescription = [];
+
+ let indentation = 0;
+ let linesToDelete = 1;
+ let reduceIndentation = false;
+
+ for (let i = startIndex + 1; i <= endIndex; i += 1) {
+ if (reduceIndentation) {
+ descriptionLines[i] = descriptionLines[i].slice(indentation);
+ } else if (containsTaskListItem(descriptionLines[i])) {
+ reduceIndentation = true;
+ const currentLine = descriptionLines[i];
+ const currentLineIndentation = currentLine.length - currentLine.trimStart().length;
+ indentation = currentLineIndentation - firstLineIndentation;
+ descriptionLines[i] = descriptionLines[i].slice(indentation);
+ } else {
+ taskDescription.push(descriptionLines[i].trimStart());
+ linesToDelete += 1;
+ }
+ }
+
+ descriptionLines.splice(startIndex, linesToDelete);
+
+ return {
+ newDescription: descriptionLines.join(NEWLINE),
+ taskDescription: taskDescription.join(NEWLINE) || undefined,
+ taskTitle,
+ };
+};
+
+/**
+ * Given a title and description for a task:
+ *
+ * - Moves characters beyond the 255 character limit from the title to the description
+ * - Moves a pure markdown title to the description and gives the title the value `Untitled`
+ *
+ * @param {String} taskTitle The task title
+ * @param {String} taskDescription The task description
+ * @returns {{description: String, title: String}} An object with the formatted task title and description
+ */
+export const extractTaskTitleAndDescription = (taskTitle, taskDescription) => {
+ const isTitleOnlyMarkdown =
+ codeMarkdownRegex.test(taskTitle) || imageOrLinkMarkdownRegex.test(taskTitle);
+
+ if (isTitleOnlyMarkdown) {
+ return {
+ title: __('Untitled'),
+ description: taskDescription
+ ? taskTitle.concat(NEWLINE, NEWLINE, taskDescription)
+ : taskTitle,
+ };
+ }
+
+ const isTitleTooLong = taskTitle.length > TITLE_LENGTH_MAX;
+
+ if (isTitleTooLong) {
+ return {
+ title: taskTitle.slice(0, TITLE_LENGTH_MAX),
+ description: taskDescription
+ ? taskTitle.slice(TITLE_LENGTH_MAX).concat(NEWLINE, NEWLINE, taskDescription)
+ : taskTitle.slice(TITLE_LENGTH_MAX),
+ };
+ }
+
+ return {
+ title: taskTitle,
+ description: taskDescription,
+ };
+};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js
index c79d7002111..8c5dc88f183 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/api.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/api.js
@@ -35,13 +35,16 @@ export const removeSubscription = async (removePath) => {
});
};
-export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
+export const fetchGroups = async (groupsPath, { page, perPage, search }, accessToken = null) => {
return axiosInstance.get(groupsPath, {
params: {
page,
per_page: perPage,
search,
},
+ headers: {
+ ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
+ },
});
};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index a9ec7bd971e..a4b728335c5 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
@@ -1,4 +1,5 @@
<script>
+import { mapState } from 'vuex';
import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui';
import { fetchGroups } from '~/jira_connect/subscriptions/api';
import {
@@ -38,6 +39,7 @@ export default {
showPagination() {
return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0;
},
+ ...mapState(['accessToken']),
},
mounted() {
return this.loadGroups().finally(() => {
@@ -47,11 +49,15 @@ export default {
methods: {
loadGroups() {
this.isLoadingMore = true;
- return fetchGroups(this.groupsPath, {
- page: this.page,
- perPage: this.$options.DEFAULT_GROUPS_PER_PAGE,
- search: this.searchValue,
- })
+ return fetchGroups(
+ this.groupsPath,
+ {
+ page: this.page,
+ perPage: this.$options.DEFAULT_GROUPS_PER_PAGE,
+ search: this.searchValue,
+ },
+ this.accessToken,
+ )
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
this.page = page;
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 44575455a34..ec42b533dd4 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -31,6 +31,9 @@ export default {
subscriptionsPath: {
default: '',
},
+ publicKeyStorageEnabled: {
+ default: false,
+ },
},
computed: {
...mapState(['currentUser']),
@@ -144,6 +147,7 @@ export default {
<sign-in-page
v-show="!userSignedIn"
:has-subscriptions="hasSubscriptions"
+ :public-key-storage-enabled="publicKeyStorageEnabled"
@sign-in-oauth="onSignInOauth"
@error="onSignInError"
/>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 01bc5dfc66b..bb22a4ef252 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -38,7 +38,7 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_
anchor: 'use-the-integration',
});
export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', {
- anchor: 'connect-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances',
+ anchor: 'connect-the-gitlab-for-jira-cloud-app-for-self-managed-instances',
});
export const GITLAB_COM_BASE_PATH = 'https://gitlab.com';
diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js
index 8e9f73538b9..21ff85e58e2 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/index.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/index.js
@@ -27,6 +27,7 @@ export function initJiraConnect() {
usersPath,
gitlabUserPath,
oauthMetadata,
+ publicKeyStorageEnabled,
} = el.dataset;
sizeToParent();
@@ -42,6 +43,7 @@ export function initJiraConnect() {
usersPath,
gitlabUserPath,
oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null,
+ publicKeyStorageEnabled,
},
render(createElement) {
return createElement(JiraConnectApp);
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
index 782e8a625a9..6de3f507a39 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_com.vue
@@ -27,7 +27,7 @@ export default {
},
i18n: {
signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'),
- signInText: s__('JiraService|Sign in to GitLab.com to get started.'),
+ signInText: s__('JiraService|Sign in to GitLab to get started.'),
},
GITLAB_COM_BASE_PATH,
methods: {
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
index f4c59b2184e..e6a94ffbaa4 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_page.vue
@@ -12,10 +12,14 @@ export default {
type: Boolean,
required: true,
},
+ publicKeyStorageEnabled: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
isOauthSelfManagedEnabled() {
- return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged;
+ return this.glFeatures.jiraConnectOauth && this.publicKeyStorageEnabled;
},
},
};
diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue
index c6d900ef13e..d93b8a8de29 100644
--- a/app/assets/javascripts/jobs/components/job/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job/job_app.vue
@@ -9,6 +9,7 @@ import { __, sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import Log from '~/jobs/components/log/log.vue';
+import { MANUAL_STATUS } from '~/jobs/constants';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue';
@@ -144,6 +145,12 @@ export default {
this.fetchJobsForStage(defaultStage);
}
}
+
+ // Only poll for job log if we are not in the manual variables form empty state.
+ // This will be handled more elegantly in the future with GraphQL in https://gitlab.com/gitlab-org/gitlab/-/issues/389597
+ if (newVal?.status?.group !== MANUAL_STATUS && !this.showUpdateVariablesState) {
+ this.fetchJobLog();
+ }
},
},
created() {
@@ -163,6 +170,7 @@ export default {
},
methods: {
...mapActions([
+ 'fetchJobLog',
'fetchJobsForStage',
'hideSidebar',
'showSidebar',
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
index 734d3ca0d49..763eb6705aa 100644
--- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -13,8 +13,9 @@ import { cloneDeep, uniqueId } from 'lodash';
import { mapActions } from 'vuex';
import { fetchPolicies } from '~/lib/graphql';
import { createAlert } from '~/flash';
+import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { JOB_GRAPHQL_ERRORS, GRAPHQL_ID_TYPES } from '~/jobs/constants';
+import { JOB_GRAPHQL_ERRORS } from '~/jobs/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -45,7 +46,7 @@ export default {
variables() {
return {
fullPath: this.projectPath,
- id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId),
+ id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId),
};
},
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
@@ -76,13 +77,16 @@ export default {
i18n: {
clearInputs: s__('CiVariables|Clear inputs'),
formHelpText: s__(
- 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
+ 'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.',
+ ),
+ overrideNoteText: s__(
+ 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}',
),
header: s__('CiVariables|Variables'),
keyLabel: s__('CiVariables|Key'),
keyPlaceholder: s__('CiVariables|Input variable key'),
runAgainButtonText: s__('CiVariables|Run job again'),
- triggerButtonText: s__('CiVariables|Trigger this manual action'),
+ triggerButtonText: s__('CiVariables|Run job'),
valueLabel: s__('CiVariables|Value'),
valuePlaceholder: s__('CiVariables|Input variable value'),
},
@@ -157,7 +161,7 @@ export default {
const { data } = await this.$apollo.mutate({
mutation: retryJobWithVariablesMutation,
variables: {
- id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, this.jobId),
+ id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId),
// we need to ensure no empty variables are passed to the API
variables: this.preparedVariables,
},
@@ -258,6 +262,15 @@ export default {
</template>
</gl-sprintf>
</div>
+ <div class="gl-text-center gl-mt-3">
+ <gl-sprintf :message="$options.i18n.overrideNoteText">
+ <template #bold="{ content }">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </div>
<div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-button
class="gl-mt-5"
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
index 40aec0b0536..8100bc2d87a 100644
--- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -2,11 +2,11 @@
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
import { createAlert } from '~/flash';
+import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
JOB_GRAPHQL_ERRORS,
- GRAPHQL_ID_TYPES,
JOB_SIDEBAR_COPY,
forwardDeploymentFailureModalId,
PASSED_STATUS,
@@ -35,7 +35,7 @@ export default {
variables() {
return {
fullPath: this.projectPath,
- id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId),
+ id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId),
};
},
update(data) {
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 6f351d91165..17766b4d162 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -8,9 +8,11 @@ import {
ACTIONS_UNSCHEDULE,
ACTIONS_PLAY,
ACTIONS_RETRY,
+ ACTIONS_RUN_AGAIN,
CANCEL,
GENERIC_ERROR,
JOB_SCHEDULED,
+ JOB_SUCCESS,
PLAY_JOB_CONFIRMATION_MESSAGE,
RUN_JOB_NOW_HEADER_TITLE,
FILE_TYPE_ARCHIVE,
@@ -107,6 +109,9 @@ export default {
shouldDisplayArtifacts() {
return this.canReadArtifacts && this.hasArtifacts;
},
+ retryButtonTitle() {
+ return this.job.status === JOB_SUCCESS ? ACTIONS_RUN_AGAIN : ACTIONS_RETRY;
+ },
},
methods: {
async postJobAction(name, mutation, redirect = false) {
@@ -223,8 +228,8 @@ export default {
<gl-button
v-else-if="isRetryable"
icon="retry"
- :title="$options.ACTIONS_RETRY"
- :aria-label="$options.ACTIONS_RETRY"
+ :title="retryButtonTitle"
+ :aria-label="retryButtonTitle"
:method="currentJobMethod"
:disabled="retryBtnDisabled"
data-testid="retry"
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index f73241aed6b..41ce6e4d64d 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -9,6 +9,7 @@ export const RAW_TEXT_WARNING = s__(
/* Job Status Constants */
export const JOB_SCHEDULED = 'SCHEDULED';
+export const JOB_SUCCESS = 'SUCCESS';
/* Artifact file types */
export const FILE_TYPE_ARCHIVE = 'ARCHIVE';
@@ -19,6 +20,7 @@ export const ACTIONS_START_NOW = s__('DelayedJobs|Start now');
export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule');
export const ACTIONS_PLAY = __('Play');
export const ACTIONS_RETRY = __('Retry');
+export const ACTIONS_RUN_AGAIN = __('Run again');
export const CANCEL = __('Cancel');
export const GENERIC_ERROR = __('An error occurred while making the request.');
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 405aea11181..027d896ba0e 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -5,11 +5,6 @@ const moreInfo = __('More information');
export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
-export const GRAPHQL_ID_TYPES = {
- commitStatus: 'CommitStatus',
- ciBuild: 'Ci::Build',
-};
-
export const JOB_SIDEBAR_COPY = {
cancel,
cancelJobButtonLabel: s__('Job|Cancel'),
@@ -42,3 +37,4 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
export const SUCCESS_STATUS = 'SUCCESS';
export const PASSED_STATUS = 'passed';
+export const MANUAL_STATUS = 'manual';
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index a81edb240ad..af2d720643f 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -8,7 +8,6 @@ import {
canScroll,
isScrolledToBottom,
isScrolledToTop,
- isScrolledToMiddle,
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
@@ -23,7 +22,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
pagePath,
});
- return Promise.all([dispatch('fetchJob'), dispatch('fetchJobLog')]);
+ return Promise.all([dispatch('fetchJob')]);
};
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
@@ -124,15 +123,15 @@ export const scrollBottom = ({ dispatch }) => {
*/
export const toggleScrollButtons = ({ dispatch }) => {
if (canScroll()) {
- if (isScrolledToMiddle()) {
- dispatch('enableScrollTop');
- dispatch('enableScrollBottom');
- } else if (isScrolledToTop()) {
+ if (isScrolledToTop()) {
dispatch('disableScrollTop');
dispatch('enableScrollBottom');
} else if (isScrolledToBottom()) {
dispatch('disableScrollBottom');
dispatch('enableScrollTop');
+ } else {
+ dispatch('enableScrollTop');
+ dispatch('enableScrollBottom');
}
} else {
dispatch('disableScrollBottom');
diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue
index 4d3fe22e247..a2012f95fd6 100644
--- a/app/assets/javascripts/language_switcher/components/app.vue
+++ b/app/assets/javascripts/language_switcher/components/app.vue
@@ -45,7 +45,7 @@ export default {
:toggle-text="preferredLocale.text"
:items="locales"
category="tertiary"
- right
+ placement="right"
icon="earth"
size="small"
toggle-class="py-0 gl-h-6"
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index 90c1b31286a..b8138f34d45 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -56,7 +56,11 @@ function initDeferred() {
if (!appEl) return;
setNotification(appEl);
- document.querySelector('.js-whats-new-trigger').addEventListener('click', () => {
+
+ const triggerEl = document.querySelector('.js-whats-new-trigger');
+ if (!triggerEl) return;
+
+ triggerEl.addEventListener('click', () => {
import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new')
.then(({ default: initWhatsNew }) => {
initWhatsNew(appEl);
diff --git a/app/assets/javascripts/lib/apollo/persist_link.js b/app/assets/javascripts/lib/apollo/persist_link.js
new file mode 100644
index 00000000000..9d95409d96c
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/persist_link.js
@@ -0,0 +1,141 @@
+// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistLink.ts
+// with some heavy refactororing
+
+/* eslint-disable consistent-return */
+/* eslint-disable @gitlab/require-i18n-strings */
+/* eslint-disable no-param-reassign */
+import { visit } from 'graphql';
+import { ApolloLink } from '@apollo/client/core';
+import traverse from 'traverse';
+
+const extractPersistDirectivePaths = (originalQuery, directive = 'persist') => {
+ const paths = [];
+ const fragmentPaths = {};
+ const fragmentPersistPaths = {};
+
+ const query = visit(originalQuery, {
+ FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => {
+ const root = ancestors.find(
+ ({ kind }) => kind === 'OperationDefinition' || kind === 'FragmentDefinition',
+ );
+
+ const rootKey = root.kind === 'FragmentDefinition' ? root.name.value : '$ROOT';
+
+ const fieldPath = ancestors
+ .filter(({ kind }) => kind === 'Field')
+ .map(({ name: { value } }) => value);
+
+ fragmentPaths[name] = [rootKey].concat(fieldPath);
+ },
+ Directive: ({ name: { value: name } }, _key, _parent, _path, ancestors) => {
+ if (name === directive) {
+ const fieldPath = ancestors
+ .filter(({ kind }) => kind === 'Field')
+ .map(({ name: { value } }) => value);
+
+ const fragmentDefinition = ancestors.find(({ kind }) => kind === 'FragmentDefinition');
+
+ // If we are inside a fragment, we must save the reference.
+ if (fragmentDefinition) {
+ fragmentPersistPaths[fragmentDefinition.name.value] = fieldPath;
+ } else if (fieldPath.length) {
+ paths.push(fieldPath);
+ }
+ return null;
+ }
+ },
+ });
+
+ // In case there are any FragmentDefinition items, we need to combine paths.
+ if (Object.keys(fragmentPersistPaths).length) {
+ visit(originalQuery, {
+ FragmentSpread: ({ name: { value: name } }, _key, _parent, _path, ancestors) => {
+ if (fragmentPersistPaths[name]) {
+ let fieldPath = ancestors
+ .filter(({ kind }) => kind === 'Field')
+ .map(({ name: { value } }) => value);
+
+ fieldPath = fieldPath.concat(fragmentPersistPaths[name]);
+
+ const fragment = name;
+ let parent = fragmentPaths[fragment][0];
+
+ while (parent && parent !== '$ROOT' && fragmentPaths[parent]) {
+ fieldPath = fragmentPaths[parent].slice(1).concat(fieldPath);
+ // eslint-disable-next-line prefer-destructuring
+ parent = fragmentPaths[parent][0];
+ }
+
+ paths.push(fieldPath);
+ }
+ },
+ });
+ }
+
+ return { query, paths };
+};
+
+/**
+ * Given a data result object path, return the equivalent query selection path.
+ *
+ * @param {Array} path The data result object path. i.e.: ["a", 0, "b"]
+ * @return {String} the query selection path. i.e.: "a.b"
+ */
+const toQueryPath = (path) => path.filter((key) => Number.isNaN(Number(key))).join('.');
+
+const attachPersists = (paths, object) => {
+ const queryPaths = paths.map(toQueryPath);
+ function mapperFunction() {
+ if (
+ !this.isRoot &&
+ this.node &&
+ typeof this.node === 'object' &&
+ Object.keys(this.node).length &&
+ !Array.isArray(this.node)
+ ) {
+ const path = toQueryPath(this.path);
+
+ this.update({
+ __persist: Boolean(
+ queryPaths.find(
+ (queryPath) => queryPath.indexOf(path) === 0 || path.indexOf(queryPath) === 0,
+ ),
+ ),
+ ...this.node,
+ });
+ }
+ }
+
+ return traverse(object).map(mapperFunction);
+};
+
+export const getPersistLink = () => {
+ return new ApolloLink((operation, forward) => {
+ const { query, paths } = extractPersistDirectivePaths(operation.query);
+
+ // Noop if not a persist query
+ if (!paths.length) {
+ return forward(operation);
+ }
+
+ // Replace query with one without @persist directives.
+ operation.query = query;
+
+ // Remove requesting __persist fields.
+ operation.query = visit(operation.query, {
+ Field: ({ name: { value: name } }) => {
+ if (name === '__persist') {
+ return null;
+ }
+ },
+ });
+
+ return forward(operation).map((result) => {
+ if (result.data) {
+ result.data = attachPersists(paths, result.data);
+ }
+
+ return result;
+ });
+ });
+};
diff --git a/app/assets/javascripts/lib/apollo/persistence_mapper.js b/app/assets/javascripts/lib/apollo/persistence_mapper.js
new file mode 100644
index 00000000000..8fc7c69c79d
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/persistence_mapper.js
@@ -0,0 +1,67 @@
+// this file is based on https://github.com/apollographql/apollo-cache-persist/blob/master/examples/react-native/src/utils/persistence/persistenceMapper.ts
+// with some heavy refactororing
+
+/* eslint-disable @gitlab/require-i18n-strings */
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable no-param-reassign */
+/* eslint-disable dot-notation */
+export const persistenceMapper = async (data) => {
+ const parsed = JSON.parse(data);
+
+ const mapped = {};
+ const persistEntities = [];
+ const rootQuery = parsed['ROOT_QUERY'];
+
+ // cache entities that have `__persist: true`
+ Object.keys(parsed).forEach((key) => {
+ if (parsed[key]['__persist']) {
+ persistEntities.push(key);
+ }
+ });
+
+ // cache root queries that have `@persist` directive
+ mapped['ROOT_QUERY'] = Object.keys(rootQuery).reduce(
+ (obj, key) => {
+ if (key === '__typename') return obj;
+
+ if (/@persist$/.test(key)) {
+ obj[key] = rootQuery[key];
+
+ if (Array.isArray(rootQuery[key])) {
+ const entities = rootQuery[key].map((item) => item.__ref);
+ persistEntities.push(...entities);
+ } else {
+ const entity = rootQuery[key].__ref;
+ persistEntities.push(entity);
+ }
+ }
+
+ return obj;
+ },
+ { __typename: 'Query' },
+ );
+
+ persistEntities.reduce((obj, key) => {
+ const parsedEntity = parsed[key];
+
+ // check for root queries and only cache root query properties that have `__persist: true`
+ // we need this to prevent overcaching when we fetch the same entity (e.g. project) more than once
+ // with different set of fields
+
+ if (Object.values(rootQuery).some((value) => value.__ref === key)) {
+ const mappedEntity = {};
+ Object.entries(parsedEntity).forEach(([parsedKey, parsedValue]) => {
+ if (!parsedValue || typeof parsedValue !== 'object' || parsedValue['__persist']) {
+ mappedEntity[parsedKey] = parsedValue;
+ }
+ });
+ obj[key] = mappedEntity;
+ } else {
+ obj[key] = parsed[key];
+ }
+
+ return obj;
+ }, mapped);
+
+ return JSON.stringify(mapped);
+};
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 98e45f95b38..c0e923b2670 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,6 +1,7 @@
import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core';
import { BatchHttpLink } from '@apollo/client/link/batch-http';
import { createUploadLink } from 'apollo-upload-client';
+import { persistCacheSync, LocalStorageWrapper } from 'apollo3-cache-persist';
import ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import possibleTypes from '~/graphql_shared/possible_types.json';
@@ -10,6 +11,8 @@ import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
import { getInstrumentationLink } from './apollo/instrumentation_link';
import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link';
+import { getPersistLink } from './apollo/persist_link';
+import { persistenceMapper } from './apollo/persistence_mapper';
export const fetchPolicies = {
CACHE_FIRST: 'cache-first',
@@ -110,6 +113,7 @@ export default (resolvers = {}, config = {}) => {
typeDefs,
path = '/api/graphql',
useGet = false,
+ localCacheKey = null,
} = config;
let ac = null;
let uri = `${gon.relative_url_root || ''}${path}`;
@@ -201,6 +205,8 @@ export default (resolvers = {}, config = {}) => {
});
});
+ const persistLink = getPersistLink();
+
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
@@ -212,27 +218,40 @@ export default (resolvers = {}, config = {}) => {
performanceBarLink,
new StartupJSLink(),
apolloCaptchaLink,
+ persistLink,
uploadsLink,
requestLink,
].filter(Boolean),
),
);
+ const newCache = new InMemoryCache({
+ ...cacheConfig,
+ typePolicies: {
+ ...typePolicies,
+ ...cacheConfig.typePolicies,
+ },
+ possibleTypes: {
+ ...possibleTypes,
+ ...cacheConfig.possibleTypes,
+ },
+ });
+
+ if (localCacheKey) {
+ persistCacheSync({
+ cache: newCache,
+ // we leave NODE_ENV here temporarily for visibility so developers can easily see caching happening in dev mode
+ debug: process.env.NODE_ENV === 'development',
+ storage: new LocalStorageWrapper(window.localStorage),
+ persistenceMapper,
+ });
+ }
+
ac = new ApolloClient({
typeDefs,
link: appLink,
connectToDevTools: process.env.NODE_ENV !== 'production',
- cache: new InMemoryCache({
- ...cacheConfig,
- typePolicies: {
- ...typePolicies,
- ...cacheConfig.typePolicies,
- },
- possibleTypes: {
- ...possibleTypes,
- ...cacheConfig.possibleTypes,
- },
- }),
+ cache: newCache,
resolvers,
defaultOptions: {
query: {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 241488c8039..9bf382c41e7 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -4,7 +4,7 @@
import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
-import { isFunction, defer, escape } from 'lodash';
+import { isFunction, defer, escape, partial, toLower } from 'lodash';
import Cookies from '~/lib/utils/cookies';
import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants';
import { convertToCamelCase, convertToSnakeCase } from './text_utility';
@@ -552,6 +552,22 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) =>
convertObjectProps(convertToCamelCase, obj, options);
/**
+ * This method returns a new object with lowerCase property names
+ *
+ * Reasoning for this method is to ensure consistent access for some
+ * sort of objects
+ *
+ * This method also supports additional params in `options` object
+ *
+ * @param {Object} obj - Object to be converted.
+ * @param {Object} options - Object containing additional options.
+ * @param {boolean} options.deep - FLag to allow deep object converting
+ * @param {Array[]} options.dropKeys - List of properties to discard while building new object
+ * @param {Array[]} options.ignoreKeyNames - List of properties to leave intact while building new object
+ */
+export const convertObjectPropsToLowerCase = partial(convertObjectProps, toLower);
+
+/**
* Converts all the object keys to snake case
*
* This method also supports additional params in `options` object
@@ -717,16 +733,3 @@ export const getFirstPropertyValue = (data) => {
return data[key];
};
-
-// TODO: remove when FF `new_fonts` is removed https://gitlab.com/gitlab-org/gitlab/-/issues/379147
-/**
- * This method checks the FF `new_fonts`
- * as well as a query parameter `new_fonts`.
- * If either of them is enabled, new fonts will be applied.
- *
- * @returns Boolean Whether to apply new fonts
- */
-export const useNewFonts = () => {
- const hasQueryParam = new URLSearchParams(window.location.search).has('new_fonts');
- return window?.gon.features?.newFonts || hasQueryParam;
-};
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 678ebc35565..61c2ecfecd9 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -9,16 +9,17 @@ export const HTTP_STATUS_PARTIAL_CONTENT = 206;
export const HTTP_STATUS_MULTI_STATUS = 207;
export const HTTP_STATUS_ALREADY_REPORTED = 208;
export const HTTP_STATUS_IM_USED = 226;
+export const HTTP_STATUS_BAD_REQUEST = 400;
+export const HTTP_STATUS_UNAUTHORIZED = 401;
+export const HTTP_STATUS_FORBIDDEN = 403;
+export const HTTP_STATUS_NOT_FOUND = 404;
export const HTTP_STATUS_METHOD_NOT_ALLOWED = 405;
export const HTTP_STATUS_CONFLICT = 409;
export const HTTP_STATUS_GONE = 410;
export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413;
+export const HTTP_STATUS_IM_A_TEAPOT = 418;
export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422;
export const HTTP_STATUS_TOO_MANY_REQUESTS = 429;
-export const HTTP_STATUS_BAD_REQUEST = 400;
-export const HTTP_STATUS_UNAUTHORIZED = 401;
-export const HTTP_STATUS_FORBIDDEN = 403;
-export const HTTP_STATUS_NOT_FOUND = 404;
export const HTTP_STATUS_INTERNAL_SERVER_ERROR = 500;
export const HTTP_STATUS_SERVICE_UNAVAILABLE = 503;
diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js
index 01e43fd3b93..bab84448657 100644
--- a/app/assets/javascripts/lib/utils/scroll_utils.js
+++ b/app/assets/javascripts/lib/utils/scroll_utils.js
@@ -7,14 +7,11 @@ export const canScroll = () => $(document).height() > $(window).height();
* @returns {Boolean}
*/
export const isScrolledToBottom = () => {
- const $document = $(document);
-
- const currentPosition = $document.scrollTop();
- const scrollHeight = $document.height();
-
- const windowHeight = $(window).height();
+ // Use clientHeight to account for any horizontal scrollbar.
+ const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
- return scrollHeight - currentPosition === windowHeight;
+ // scrollTop can be a float, so round up to next integer.
+ return Math.ceil(scrollTop + clientHeight) >= scrollHeight;
};
/**
@@ -31,21 +28,3 @@ export const scrollDown = () => {
export const scrollUp = () => {
$(document).scrollTop(0);
};
-
-/**
- * Checks if scroll position is in the middle of the page
- * @returns {Boolean}
- */
-export const isScrolledToMiddle = () => {
- const $document = $(document);
- const currentPosition = $document.scrollTop();
- const scrollHeight = $document.height();
- const windowHeight = $(window).height();
-
- return currentPosition > 0 && scrollHeight - currentPosition !== windowHeight;
-};
-
-export const toggleDisableButton = ($button, disable) => {
- if (disable && $button.prop('disabled')) return;
- $button.prop('disabled', disable);
-};
diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js
deleted file mode 100644
index 03c0e608b79..00000000000
--- a/app/assets/javascripts/lib/utils/select2_utils.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import axios from './axios_utils';
-import { normalizeHeaders, parseIntPagination } from './common_utils';
-
-// This is used in the select2 config to replace jQuery.ajax with axios
-export const select2AxiosTransport = (params) => {
- axios({
- method: params.type?.toLowerCase() || 'get',
- url: params.url,
- params: params.data,
- })
- .then((res) => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const pagination = parseIntPagination(headers);
- const more = pagination.nextPage > pagination.page;
-
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
-};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 3894ec36a0b..05ed08931bb 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -522,15 +522,23 @@ function handleContinueList(e, textArea) {
if (!(e.key === 'Enter')) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (textArea.selectionStart !== textArea.selectionEnd) return;
+
// prevent unintended line breaks inserted using Japanese IME on MacOS
if (compositioningNoteText) return;
- const firstSelectedLine = linesFromSelection(textArea).lines[0];
+ const selectedLines = linesFromSelection(textArea);
+ const firstSelectedLine = selectedLines.lines[0];
const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN);
if (listLineMatch) {
const { leader, indent, content, isOl } = listLineMatch.groups;
const emptyListItem = !content;
+ const prefixLength = leader.length + indent.length;
+
+ if (selectedLines.selectionStart - selectedLines.startPos < prefixLength) {
+ // cursor in the indent/leader area, allow the natural line feed to be added
+ return;
+ }
if (emptyListItem) {
// erase empty list item - select the text and allow the
diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js
index 7e8fc4b637b..e3d26d1464e 100644
--- a/app/assets/javascripts/listbox/index.js
+++ b/app/assets/javascripts/listbox/index.js
@@ -1,22 +1,20 @@
import { GlCollapsibleListbox } from '@gitlab/ui';
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
export function parseAttributes(el) {
- const { items: itemsString, selected, right: rightString } = el.dataset;
+ const { items: itemsString, selected, placement } = el.dataset;
const items = JSON.parse(itemsString);
- const right = parseBoolean(rightString);
const { className } = el;
- return { items, selected, right, className };
+ return { items, selected, placement, className };
}
export function initListbox(el, { onChange } = {}) {
if (!el) return null;
- const { items, selected, right, className } = parseAttributes(el);
+ const { items, selected, placement, className } = parseAttributes(el);
return new Vue({
el,
@@ -34,7 +32,7 @@ export function initListbox(el, { onChange } = {}) {
return h(GlCollapsibleListbox, {
props: {
items,
- right,
+ placement,
selected: this.selected,
toggleText: this.text,
},
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index c1afabf1e35..600654794a5 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -6,8 +6,17 @@ const GITLAB_FALLBACK_LANGUAGE = 'en';
const languageCode = () =>
document.querySelector('html').getAttribute('lang') || GITLAB_FALLBACK_LANGUAGE;
-const locale = new Jed(window.translations || {});
-delete window.translations;
+
+/**
+ * This file might be imported into a web worker indirectly, the `window` object
+ * won't be defined in the web worker context so we need to check if it is defined
+ * before we access the `translations` property.
+ */
+const hasTranslations = typeof window !== 'undefined' && window.translations;
+const locale = new Jed(hasTranslations ? window.translations : {});
+if (hasTranslations) {
+ delete window.translations;
+}
/**
Translates `text`
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index fd5c4abe729..4c715c4993f 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -16,7 +16,6 @@ import * as tooltips from '~/tooltips';
import { initPrefetchLinks } from '~/lib/utils/navigation_utility';
import { logHelloDeferred } from 'jh_else_ce/lib/logger/hello_deferred';
import initAlertHandler from './alert_handler';
-import { addDismissFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
@@ -253,16 +252,6 @@ $('form.filter-form').on('submit', function filterFormSubmitCallback(event) {
visitUrl(action);
});
-const flashContainer = document.querySelector('.flash-container');
-
-if (flashContainer && flashContainer.children.length) {
- flashContainer
- .querySelectorAll('.flash-alert, .flash-notice, .flash-success')
- .forEach((flashEl) => {
- addDismissFlashClickListener(flashEl);
- });
-}
-
// initialize field errors
$('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form));
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 90034f46e7c..88d5384c9d5 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -39,6 +39,7 @@ export default {
v-gl-tooltip.hover
:title="$options.title"
:aria-label="$options.title"
+ data-qa-selector="approve_access_request_button"
icon="check"
type="submit"
/>
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index 24500fbe44d..3b4b7516934 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
@@ -35,7 +35,7 @@ export default {
:title="$options.i18n.buttonTitle"
:aria-label="$options.i18n.buttonTitle"
icon="remove"
- data-qa-selector="delete_group_access_link"
+ data-qa-selector="remove_group_link_button"
@click="showRemoveGroupLinkModal(groupLink)"
/>
</template>
diff --git a/app/assets/javascripts/members/components/action_dropdowns/constants.js b/app/assets/javascripts/members/components/action_dropdowns/constants.js
index 8ccfc57dc28..ce6865a8f0a 100644
--- a/app/assets/javascripts/members/components/action_dropdowns/constants.js
+++ b/app/assets/javascripts/members/components/action_dropdowns/constants.js
@@ -19,4 +19,5 @@ export const I18N = {
lastGroupOwnerCannotBeRemoved: s__(
'Members|A group must have at least one owner. To remove the member, assign a new owner.',
),
+ banMember: s__('Members|Ban member'),
};
diff --git a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
index 8f5c32956a2..c82ebadea6e 100644
--- a/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
+++ b/app/assets/javascripts/members/components/action_dropdowns/user_action_dropdown.vue
@@ -20,9 +20,11 @@ export default {
'ee_component/members/components/action_dropdowns/disable_two_factor_dropdown_item.vue'
),
LdapOverrideDropdownItem: () =>
- import('ee_component/members/components/ldap/ldap_override_dropdown_item.vue'),
+ import('ee_component/members/components/action_dropdowns/ldap_override_dropdown_item.vue'),
LeaveGroupDropdownItem,
RemoveMemberDropdownItem,
+ BanMemberDropdownItem: () =>
+ import('ee_component/members/components/action_dropdowns/ban_member_dropdown_item.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -77,7 +79,10 @@ export default {
},
showDropdown() {
return (
- this.permissions.canDisableTwoFactor || this.showLeaveOrRemove || this.showLdapOverride
+ this.permissions.canDisableTwoFactor ||
+ this.showLeaveOrRemove ||
+ this.showLdapOverride ||
+ this.showBan
);
},
showLeaveOrRemove() {
@@ -86,6 +91,9 @@ export default {
showLdapOverride() {
return this.permissions.canOverride && !this.member.isOverridden;
},
+ showBan() {
+ return !this.isCurrentUser && this.permissions.canBan;
+ },
},
};
</script>
@@ -130,5 +138,8 @@ export default {
<ldap-override-dropdown-item v-else-if="showLdapOverride" :member="member">{{
$options.i18n.editPermissions
}}</ldap-override-dropdown-item>
+ <ban-member-dropdown-item v-if="showBan" :member="member">{{
+ $options.i18n.banMember
+ }}</ban-member-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
index b179ced46e1..b28ca6e385b 100644
--- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
@@ -14,6 +14,7 @@ export default {
text: s__('Members|Remove group'),
attributes: {
variant: 'danger',
+ 'data-qa-selector': 'remove_group_button',
},
},
csrf,
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index 337379d8b4e..f1da1cd8ffc 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -70,6 +70,7 @@ export default {
text: this.actionText,
attributes: {
variant: 'danger',
+ 'data-qa-selector': 'remove_member_button',
},
};
},
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_actions.vue
index 6ec7be608ba..61a6f37687a 100644
--- a/app/assets/javascripts/members/components/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_actions.vue
@@ -6,7 +6,7 @@ import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
import UserActionDropdown from '../action_dropdowns/user_action_dropdown.vue';
export default {
- name: 'MemberActionButtons',
+ name: 'MemberActions',
components: {
UserActionDropdown,
GroupActionButtons,
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 8f03a298e63..c973d58fcd2 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -26,7 +26,7 @@ import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import RemoveMemberModal from '../modals/remove_member_modal.vue';
import CreatedAt from './created_at.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
-import MemberActionButtons from './member_action_buttons.vue';
+import MemberActions from './member_actions.vue';
import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
import MemberActivity from './member_activity.vue';
@@ -42,7 +42,7 @@ export default {
CreatedAt,
MembersTableCell,
MemberSource,
- MemberActionButtons,
+ MemberActions,
RoleDropdown,
RemoveGroupLinkModal,
RemoveMemberModal,
@@ -51,7 +51,7 @@ export default {
DisableTwoFactorModal: () =>
import('ee_component/members/components/modals/disable_two_factor_modal.vue'),
LdapOverrideConfirmationModal: () =>
- import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
+ import('ee_component/members/components/modals/ldap_override_confirmation_modal.vue'),
},
inject: ['namespace', 'currentUserId', 'canManageMembers'],
props: {
@@ -135,7 +135,10 @@ export default {
tbodyTrAttr(member) {
return {
...this.tableAttrs.tr,
- ...(member?.id && { 'data-testid': `members-table-row-${member.id}` }),
+ ...(member?.id && {
+ 'data-testid': `members-table-row-${member.id}`,
+ 'data-qa-selector': 'member_row',
+ }),
};
},
paginationLinkGenerator(page) {
@@ -299,7 +302,7 @@ export default {
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
- <member-action-buttons
+ <member-actions
:member-type="memberType"
:is-current-user="isCurrentUser"
:permissions="permissions"
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 70808587d56..e066b023fbb 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -11,7 +11,8 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
- LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
+ LdapDropdownItem: () =>
+ import('ee_component/members/components/action_dropdowns/ldap_dropdown_item.vue'),
},
inject: ['namespace', 'group'],
props: {
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 80eb94a5364..61abdca0a5b 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -95,10 +95,6 @@ MergeRequest.prototype.initMRBtnListeners = function () {
.then(({ data }) => {
draftToggle.removeAttribute('disabled');
- if (!window.gon?.features?.realtimeMrStatusChange) {
- eventHub.$emit('MRWidgetUpdateRequested');
- }
-
MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready');
})
.catch(() => {
@@ -155,6 +151,10 @@ MergeRequest.hideCloseButton = function () {
};
MergeRequest.toggleDraftStatus = function (title, isReady) {
+ if (!window.gon?.features?.realtimeMrStatusChange) {
+ eventHub.$emit('MRWidgetUpdateRequested');
+ }
+
if (isReady) {
toast(__('Marked as ready. Merging is now allowed.'));
} else {
diff --git a/app/assets/javascripts/merge_requests/components/compare_app.vue b/app/assets/javascripts/merge_requests/components/compare_app.vue
new file mode 100644
index 00000000000..8e02048f494
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/components/compare_app.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import axios from '~/lib/utils/axios_utils';
+import CompareDropdown from '~/merge_requests/components/compare_dropdown.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ CompareDropdown,
+ },
+ directives: {
+ SafeHtml,
+ },
+ inject: {
+ projectsPath: {
+ default: '',
+ },
+ branchCommitPath: {
+ default: '',
+ },
+ currentProject: {
+ default: () => ({}),
+ },
+ currentBranch: {
+ default: () => ({}),
+ },
+ inputs: {
+ default: () => ({}),
+ },
+ i18n: {
+ default: () => ({}),
+ },
+ toggleClass: {
+ default: () => ({}),
+ },
+ branchQaSelector: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ selectedProject: this.currentProject,
+ selectedBranch: this.currentBranch,
+ loading: false,
+ commitHtml: null,
+ };
+ },
+ computed: {
+ staticProjectData() {
+ if (this.projectsPath) return undefined;
+
+ return [this.currentProject];
+ },
+ showCommitBox() {
+ return this.commitHtml || this.loading || !this.selectedBranch.value;
+ },
+ },
+ mounted() {
+ this.fetchCommit();
+ },
+ methods: {
+ selectProject(p) {
+ this.selectedProject = p;
+ },
+ selectBranch(branch) {
+ this.selectedBranch = branch;
+ this.fetchCommit();
+ },
+ async fetchCommit() {
+ if (!this.selectedBranch.value) return;
+
+ this.loading = true;
+
+ const { data } = await axios.get(this.branchCommitPath, {
+ params: { target_project_id: this.selectedProject.value, ref: this.selectedBranch.value },
+ });
+
+ this.loading = false;
+ this.commitHtml = data;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="clearfix">
+ <div class="merge-request-select gl-pl-0">
+ <compare-dropdown
+ :static-data="staticProjectData"
+ :endpoint="projectsPath"
+ :default="currentProject"
+ :dropdown-header="i18n.projectHeaderText"
+ :input-id="inputs.project.id"
+ :input-name="inputs.project.name"
+ :toggle-class="toggleClass.project"
+ is-project
+ @selected="selectProject"
+ />
+ </div>
+ <div class="merge-request-select merge-request-branch-select gl-pr-0">
+ <compare-dropdown
+ :endpoint="selectedProject.refsUrl"
+ :dropdown-header="i18n.branchHeaderText"
+ :input-id="inputs.branch.id"
+ :input-name="inputs.branch.name"
+ :default="currentBranch"
+ :toggle-class="toggleClass.branch"
+ :qa-selector="branchQaSelector"
+ @selected="selectBranch"
+ />
+ </div>
+ </div>
+ <div
+ v-if="showCommitBox"
+ class="gl-bg-gray-50 gl-rounded-base gl-my-4"
+ data-testid="commit-box"
+ >
+ <gl-loading-icon v-if="loading" class="gl-py-3" />
+ <template v-else>
+ <div
+ v-if="!selectedBranch.value"
+ class="compare-commit-empty gl-display-flex gl-align-items-center gl-p-5"
+ >
+ <gl-icon name="branch" class="gl-mr-3" />
+ {{ __('Select a branch to compare') }}
+ </div>
+ <ul v-safe-html="commitHtml" class="list-unstyled mr_source_commit"></ul>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/merge_requests/components/compare_dropdown.vue b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
new file mode 100644
index 00000000000..1590e693c07
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/components/compare_dropdown.vue
@@ -0,0 +1,145 @@
+<script>
+import { GlListbox } from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { createAlert } from '~/flash';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ components: {
+ GlListbox,
+ },
+ props: {
+ staticData: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ endpoint: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ default: {
+ type: Object,
+ required: true,
+ },
+ dropdownHeader: {
+ type: String,
+ required: true,
+ },
+ isProject: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ inputName: {
+ type: String,
+ required: true,
+ },
+ toggleClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ qaSelector: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ current: this.default,
+ selected: this.default.value,
+ isLoading: false,
+ data: this.staticData,
+ searchStr: '',
+ };
+ },
+ computed: {
+ filteredData() {
+ if (this.endpoint) return this.data;
+
+ return this.data.filter(
+ (d) => d.text.toLowerCase().indexOf(this.searchStr.toLowerCase()) >= 0,
+ );
+ },
+ },
+ methods: {
+ async fetchData() {
+ if (!this.endpoint) return;
+
+ this.isLoading = true;
+
+ try {
+ const { data } = await axios.get(this.endpoint, {
+ params: { search: this.searchStr },
+ });
+
+ if (this.isProject) {
+ this.data = data.map((p) => ({
+ value: `${p.id}`,
+ text: p.full_path.replace(/^\//, ''),
+ refsUrl: p.refs_url,
+ }));
+ } else {
+ this.data = data.Branches.map((d) => ({
+ value: d,
+ text: d,
+ }));
+ }
+
+ this.isLoading = false;
+ } catch {
+ createAlert({
+ message: __('Error fetching data. Please try again.'),
+ primaryButton: { text: __('Try again'), clickHandler: () => this.fetchData() },
+ });
+ }
+ },
+ searchData: debounce(function searchData(search) {
+ this.searchStr = search;
+ this.fetchData();
+ }, 500),
+ selectItem(id) {
+ this.current = this.data.find((d) => d.value === id);
+
+ this.$emit('selected', this.current);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input
+ :id="inputId"
+ type="hidden"
+ :value="current.value"
+ :name="inputName"
+ data-testid="target-project-input"
+ />
+ <gl-listbox
+ v-model="selected"
+ :items="filteredData"
+ :toggle-text="current.text || dropdownHeader"
+ :header-text="dropdownHeader"
+ :searching="isLoading"
+ searchable
+ class="gl-w-full dropdown-target-project"
+ :toggle-class="[
+ 'gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown',
+ toggleClass,
+ ]"
+ :data-qa-selector="qaSelector"
+ @shown="fetchData"
+ @search="searchData"
+ @select="selectItem"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
index 6af1baaa37e..525094271d9 100644
--- a/app/assets/javascripts/merge_requests/components/sticky_header.vue
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -2,7 +2,7 @@
import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isLoggedIn } from '~/lib/utils/common_utils';
@@ -45,7 +45,7 @@ export default {
doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions,
}),
issuableId() {
- return convertToGraphQLId(TYPE_MERGE_REQUEST, this.getNoteableData.id);
+ return convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.getNoteableData.id);
},
issuableIid() {
return `${this.getNoteableData.iid}`;
@@ -77,7 +77,7 @@ export default {
<template>
<gl-intersection-observer
- class="gl-relative gl-top-2"
+ class="gl-relative gl-top-n5"
@appear="setStickyHeaderVisible(false)"
@disappear="setStickyHeaderVisible(true)"
>
diff --git a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue
deleted file mode 100644
index cd2e25793f4..00000000000
--- a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue
+++ /dev/null
@@ -1,87 +0,0 @@
-<script>
-import { GlListbox } from '@gitlab/ui';
-import { debounce } from 'lodash';
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
-import axios from '~/lib/utils/axios_utils';
-
-export default {
- components: {
- GlListbox,
- },
- inject: {
- targetProjectsPath: {
- type: String,
- required: true,
- },
- currentProject: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- currentProject: this.currentProject,
- selected: this.currentProject.value,
- isLoading: false,
- projects: [],
- };
- },
- methods: {
- async fetchProjects(search = '') {
- this.isLoading = true;
-
- try {
- const { data } = await axios.get(this.targetProjectsPath, {
- params: { search },
- });
-
- this.projects = data.map((p) => ({
- value: `${p.id}`,
- text: p.full_path.replace(/^\//, ''),
- refsUrl: p.refs_url,
- }));
- this.isLoading = false;
- } catch {
- createAlert({
- message: __('Error fetching target projects. Please try again.'),
- primaryButton: { text: __('Try again'), clickHandler: () => this.fetchProjects(search) },
- });
- }
- },
- searchProjects: debounce(function searchProjects(search) {
- this.fetchProjects(search);
- }, 500),
- selectProject(projectId) {
- this.currentProject = this.projects.find((p) => p.value === projectId);
-
- this.$emit('project-selected', this.currentProject.refsUrl);
- },
- },
-};
-</script>
-
-<template>
- <div>
- <input
- id="merge_request_target_project_id"
- type="hidden"
- :value="currentProject.value"
- name="merge_request[target_project_id]"
- data-testid="target-project-input"
- />
- <gl-listbox
- v-model="selected"
- :items="projects"
- :toggle-text="currentProject.text"
- :header-text="__('Select target project')"
- :searching="isLoading"
- searchable
- class="gl-w-full dropdown-target-project"
- toggle-class="gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown js-target-project"
- @shown="fetchProjects"
- @search="searchProjects"
- @select="selectProject"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
index 3a13c123d77..4b3c1bd7d10 100644
--- a/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/delete_milestone_modal.vue
@@ -2,7 +2,7 @@
import { GlSprintf, GlModal } from '@gitlab/ui';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
-
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
import { __, n__, s__, sprintf } from '~/locale';
import eventHub from '../event_hub';
@@ -84,7 +84,7 @@ Once deleted, it cannot be undone or recovered.`),
successful: false,
});
- if (error.response && error.response.status === 404) {
+ if (error.response && error.response.status === HTTP_STATUS_NOT_FOUND) {
createAlert({
message: sprintf(s__('Milestones|Milestone %{milestoneTitle} was not found'), {
milestoneTitle: this.milestoneTitle,
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 3b7e5a5f2ee..037120a0d81 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -3,6 +3,7 @@ import { escape } from 'lodash';
import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
+import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
import { __ } from '~/locale';
import AUTH_METHOD from './constants';
@@ -87,7 +88,7 @@ export default class SSHMirror {
)}`,
)
.then(({ data, status }) => {
- if (status === 204) {
+ if (status === HTTP_STATUS_NO_CONTENT) {
this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < 3) {
next();
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue
deleted file mode 100644
index 42f6394ed68..00000000000
--- a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlAlert, GlLink } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- i18n: {
- titleLabel: __('Machine Learning Experiment Tracking is in Incubating Phase'),
- contentLabel: __(
- 'GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited',
- ),
- learnMoreLabel: __('Learn more'),
- feedbackLabel: __('Feedback'),
- },
- name: 'MlopsIncubationAlert',
- components: { GlAlert, GlLink },
- data() {
- return {
- isAlertDismissed: false,
- };
- },
- computed: {
- shouldShowAlert() {
- return !this.isAlertDismissed;
- },
- },
- methods: {
- dismissAlert() {
- this.isAlertDismissed = true;
- },
- },
-};
-</script>
-
-<template>
- <gl-alert
- v-if="shouldShowAlert"
- :title="$options.i18n.titleLabel"
- variant="warning"
- :primary-button-text="$options.i18n.feedbackLabel"
- primary-button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/381660"
- @dismiss="dismissAlert"
- >
- {{ $options.i18n.contentLabel }}
- <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{
- $options.i18n.learnMoreLabel
- }}</gl-link>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
index 0bb2a913dec..d0c42905ee2 100644
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue
@@ -1,7 +1,8 @@
<script>
import { GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
-import IncubationAlert from './incubation_alert.vue';
+import { FEATURE_NAME, FEATURE_FEEDBACK_ISSUE } from '~/ml/experiment_tracking/constants';
+import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
export default {
name: 'MlCandidate',
@@ -9,7 +10,12 @@ export default {
IncubationAlert,
GlLink,
},
- inject: ['candidate'],
+ props: {
+ candidate: {
+ type: Object,
+ required: true,
+ },
+ },
i18n: {
titleLabel: __('Model candidate details'),
infoLabel: __('Info'),
@@ -39,12 +45,17 @@ export default {
];
},
},
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
};
</script>
<template>
<div>
- <incubation-alert />
+ <incubation-alert
+ :feature-name="$options.FEATURE_NAME"
+ :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
+ />
<h3>
{{ $options.i18n.titleLabel }}
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
index 5d13122765a..c09aabb0d40 100644
--- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
+++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue
@@ -1,9 +1,20 @@
<script>
-import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { getParameterValues, setUrlParams } from '~/lib/utils/url_utility';
+import { GlTable, GlLink, GlTooltipDirective } from '@gitlab/ui';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
-import IncubationAlert from './incubation_alert.vue';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ LIST_KEY_CREATED_AT,
+ BASE_SORT_FIELDS,
+ METRIC_KEY_PREFIX,
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
+} from '~/ml/experiment_tracking/constants';
+import { s__ } from '~/locale';
+import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue';
+import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
export default {
name: 'MlExperiment',
@@ -12,19 +23,36 @@ export default {
GlLink,
TimeAgo,
IncubationAlert,
- GlPagination,
+ RegistrySearch,
+ KeysetPagination,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['candidates', 'metricNames', 'paramNames', 'pagination'],
+ inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'],
data() {
+ const query = queryToObject(window.location.search);
+
+ const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : [];
+
+ let orderBy = query.orderBy || LIST_KEY_CREATED_AT;
+
+ if (query.orderByType === 'metric') {
+ orderBy = `${METRIC_KEY_PREFIX}${orderBy}`;
+ }
+
return {
- page: parseInt(getParameterValues('page')[0], 10) || 1,
+ filters: filter,
+ sorting: {
+ orderBy,
+ sort: (query.sort || 'desc').toLowerCase(),
+ },
};
},
computed: {
fields() {
+ if (this.candidates.length === 0) return [];
+
return [
{ key: 'name', label: this.$options.i18n.nameLabel },
{ key: 'created_at', label: this.$options.i18n.createdAtLabel },
@@ -38,39 +66,87 @@ export default {
displayPagination() {
return this.candidates.length > 0;
},
- prevPage() {
- return this.pagination.page > 1 ? this.pagination.page - 1 : null;
+ sortableFields() {
+ return [
+ ...BASE_SORT_FIELDS,
+ ...this.metricNames.map((name) => ({
+ orderBy: `${METRIC_KEY_PREFIX}${name}`,
+ label: capitalizeFirstCharacter(name),
+ })),
+ ];
},
- nextPage() {
- return !this.pagination.isLastPage ? this.pagination.page + 1 : null;
+ parsedQuery() {
+ const name = this.filters
+ .map((f) => f.value.data)
+ .join(' ')
+ .trim();
+
+ const filterByQuery = name === '' ? {} : { name };
+
+ let orderByType = 'column';
+ let { orderBy } = this.sorting;
+ const { sort } = this.sorting;
+
+ if (orderBy.startsWith(METRIC_KEY_PREFIX)) {
+ orderBy = this.sorting.orderBy.slice(METRIC_KEY_PREFIX.length);
+ orderByType = 'metric';
+ }
+
+ return { ...filterByQuery, orderBy, orderByType, sort };
},
},
methods: {
- generateLink(page) {
- return setUrlParams({ page });
+ submitFilters() {
+ return visitUrl(setUrlParams({ ...this.parsedQuery }));
+ },
+ updateFilters(newValue) {
+ this.filters = newValue;
+ },
+ updateSorting(newValue) {
+ this.sorting = { ...this.sorting, ...newValue };
+ },
+ updateSortingAndEmitUpdate(newValue) {
+ this.updateSorting(newValue);
+ this.submitFilters();
},
},
i18n: {
- titleLabel: __('Experiment candidates'),
- emptyStateLabel: __('This experiment has no logged candidates'),
- artifactsLabel: __('Artifacts'),
- detailsLabel: __('Details'),
- userLabel: __('User'),
- createdAtLabel: __('Created at'),
- nameLabel: __('Name'),
- noDataContent: __('-'),
+ titleLabel: s__('MlExperimentTracking|Experiment candidates'),
+ emptyStateLabel: s__('MlExperimentTracking|No candidates to display'),
+ artifactsLabel: s__('MlExperimentTracking|Artifacts'),
+ detailsLabel: s__('MlExperimentTracking|Details'),
+ userLabel: s__('MlExperimentTracking|User'),
+ createdAtLabel: s__('MlExperimentTracking|Created at'),
+ nameLabel: s__('MlExperimentTracking|Name'),
+ noDataContent: s__('MlExperimentTracking|-'),
+ filterCandidatesLabel: s__('MlExperimentTracking|Filter candidates'),
},
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
};
</script>
<template>
<div>
- <incubation-alert />
+ <incubation-alert
+ :feature-name="$options.FEATURE_NAME"
+ :link-to-feedback-issue="$options.FEATURE_FEEDBACK_ISSUE"
+ />
<h3>
{{ $options.i18n.titleLabel }}
</h3>
+ <registry-search
+ :filters="filters"
+ :sorting="sorting"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="submitFilters"
+ @filter:clear="filters = []"
+ />
+
<gl-table
:fields="fields"
:items="candidates"
@@ -119,16 +195,6 @@ export default {
</template>
</gl-table>
- <gl-pagination
- v-if="displayPagination"
- v-model="pagination.page"
- :prev-page="prevPage"
- :next-page="nextPage"
- :total-items="pagination.totalItems"
- :per-page="pagination.perPage"
- :link-gen="generateLink"
- align="center"
- class="w-100"
- />
+ <keyset-pagination v-if="displayPagination" v-bind="pageInfo" />
</div>
</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/constants.js b/app/assets/javascripts/ml/experiment_tracking/constants.js
new file mode 100644
index 00000000000..15462b519e1
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/constants.js
@@ -0,0 +1,22 @@
+import { __, s__ } from '~/locale';
+
+export const METRIC_KEY_PREFIX = 'metric.';
+
+export const LIST_KEY_CREATED_AT = 'created_at';
+
+export const BASE_SORT_FIELDS = Object.freeze([
+ {
+ orderBy: 'name',
+ label: __('Name'),
+ },
+ {
+ orderBy: LIST_KEY_CREATED_AT,
+ label: __('Created at'),
+ },
+]);
+
+export const EMPTY_STATE_SVG = '/assets/illustrations/empty-state/empty-dag-md.svg';
+
+export const FEATURE_NAME = s__('MlExperimentTracking|Machine learning experiment tracking');
+
+export const FEATURE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/381660';
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
new file mode 100644
index 00000000000..4f2b8db3c00
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/components/ml_experiments_index.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlTableLite, GlEmptyState, GlLink } from '@gitlab/ui';
+import IncubationAlert from '~/vue_shared/components/incubation/incubation_alert.vue';
+import Pagination from '~/vue_shared/components/incubation/pagination.vue';
+import {
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
+ EMPTY_STATE_SVG,
+} from '~/ml/experiment_tracking/constants';
+import * as constants from '~/ml/experiment_tracking/routes/experiments/index/constants';
+import * as translations from '~/ml/experiment_tracking/routes/experiments/index/translations';
+
+export default {
+ name: 'MlExperimentsIndexApp',
+ components: {
+ Pagination,
+ IncubationAlert,
+ GlTableLite,
+ GlEmptyState,
+ GlLink,
+ },
+ props: {
+ experiments: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ tableFields: constants.EXPERIMENTS_TABLE_FIELDS,
+ i18n: translations,
+ computed: {
+ hasExperiments() {
+ return this.experiments.length > 0;
+ },
+ tableItems() {
+ return this.experiments.map((exp) => ({
+ nameColumn: { name: exp.name, path: exp.path },
+ candidateCountColumn: exp.candidate_count,
+ }));
+ },
+ },
+ constants: {
+ FEATURE_NAME,
+ FEATURE_FEEDBACK_ISSUE,
+ EMPTY_STATE_SVG,
+ ...constants,
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasExperiments">
+ <h1 class="page-title gl-font-size-h-display">
+ {{ $options.i18n.TITLE_LABEL }}
+ </h1>
+
+ <incubation-alert
+ :feature-name="$options.constants.FEATURE_NAME"
+ :link-to-feedback-issue="$options.constants.FEATURE_FEEDBACK_ISSUE"
+ />
+
+ <gl-table-lite :items="tableItems" :fields="$options.tableFields">
+ <template #cell(nameColumn)="data">
+ <gl-link :href="data.value.path">
+ {{ data.value.name }}
+ </gl-link>
+ </template>
+ </gl-table-lite>
+
+ <pagination v-if="hasExperiments" v-bind="pageInfo" />
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.EMPTY_STATE_TITLE_LABEL"
+ :primary-button-text="$options.i18n.CREATE_NEW_LABEL"
+ :primary-button-link="$options.constants.CREATE_EXPERIMENT_HELP_PATH"
+ :svg-path="$options.constants.EMPTY_STATE_SVG"
+ :description="$options.i18n.EMPTY_STATE_DESCRIPTION_LABEL"
+ class="gl-py-8"
+ />
+</template>
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js
new file mode 100644
index 00000000000..3026bce0972
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const CREATE_EXPERIMENT_HELP_PATH = helpPagePath(
+ 'user/project/ml/experiment_tracking/index.md',
+ {
+ anchor: 'tracking-new-experiments-and-trials',
+ },
+);
+
+export const EXPERIMENTS_TABLE_FIELDS = Object.freeze([
+ { key: 'nameColumn', label: s__('MlExperimentTracking|Experiment') },
+ {
+ key: 'candidateCountColumn',
+ label: s__('MlExperimentTracking|Logged candidates for experiment'),
+ },
+]);
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js
new file mode 100644
index 00000000000..b40735ebe22
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/index.js
@@ -0,0 +1,3 @@
+import MlExperimentsIndex from './components/ml_experiments_index.vue';
+
+export default MlExperimentsIndex;
diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js
new file mode 100644
index 00000000000..e954c054cf5
--- /dev/null
+++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/translations.js
@@ -0,0 +1,11 @@
+import { s__ } from '~/locale';
+
+export const TITLE_LABEL = s__('MlExperimentTracking|Model experiments');
+
+export const CREATE_NEW_LABEL = s__('MlExperimentTracking|Create a new experiment');
+
+export const EMPTY_STATE_TITLE_LABEL = s__('MlExperimentTracking|No experiments');
+
+export const EMPTY_STATE_DESCRIPTION_LABEL = s__(
+ 'MlExperimentTracking|There are no logged experiments for this project. Create a new experiment using the MLflow client.',
+);
diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js
index aab3c41b4cf..79447bc115d 100644
--- a/app/assets/javascripts/mr_notes/init.js
+++ b/app/assets/javascripts/mr_notes/init.js
@@ -20,7 +20,6 @@ function setupMrNotesState(notesDataset) {
store.dispatch('setUserData', currentUserData);
store.dispatch('setTargetNoteHash', getLocationHash());
store.dispatch('setEndpoints', endpoints);
- eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes'));
}
export function initMrStateLazyLoad() {
@@ -35,10 +34,13 @@ export function initMrStateLazyLoad() {
stop = store.watch(
(state) => state.page.activeTab,
(activeTab) => {
+ setupMrNotesState(notesDataset);
+
// prevent loading MR state on commits and pipelines pages
// this is due to them having a shared controller with the Overview page
if (['diffs', 'show'].includes(activeTab)) {
- setupMrNotesState(notesDataset);
+ eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes'));
+
requestIdleCallback(() => {
initReviewBar();
initOverviewTabCounter();
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index f5f10aa4a9b..d968c125068 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -23,6 +23,7 @@ export default () => {
}
const notesFilterProps = getNotesFilterData(el);
+ const notesDataset = el.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -32,8 +33,10 @@ export default () => {
NotesApp,
},
store,
+ provide: {
+ reportAbusePath: notesDataset.reportAbusePath,
+ },
data() {
- const notesDataset = el.dataset;
const noteableData = JSON.parse(notesDataset.noteableData);
noteableData.noteableType = notesDataset.noteableType;
noteableData.targetType = notesDataset.targetType;
diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue
index 7b0076cc5d4..da22a8d2fb7 100644
--- a/app/assets/javascripts/nav/components/new_nav_toggle.vue
+++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue
@@ -45,7 +45,7 @@ export default {
Tracking.event(undefined, 'click_toggle', {
label: this.enabled ? 'disable_new_nav_beta' : 'enable_new_nav_beta',
- property: 'navigation',
+ property: this.enabled ? 'navigation' : 'navigation_top',
});
window.location.reload();
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue
index e55bf25a60c..ab9313f7041 100644
--- a/app/assets/javascripts/nav/components/top_nav_app.vue
+++ b/app/assets/javascripts/nav/components/top_nav_app.vue
@@ -24,7 +24,7 @@ export default {
trackToggleEvent() {
Tracking.event(undefined, 'click_nav', {
label: 'hamburger_menu',
- property: 'top_navigation',
+ property: 'navigation_top',
});
},
},
diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
index 97856eaf256..0f069670d09 100644
--- a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
+++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
@@ -76,7 +76,11 @@ export default {
:class="menuClass"
data-testid="menu-sidebar"
>
- <top-nav-menu-sections :sections="menuSections" @menu-item-click="onMenuItemClick" />
+ <top-nav-menu-sections
+ :sections="menuSections"
+ :is-primary-section="true"
+ @menu-item-click="onMenuItemClick"
+ />
</div>
<keep-alive-slots
v-show="activeView"
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
index 97e63c7324e..1f3f11dc624 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
@@ -1,7 +1,7 @@
<script>
import TopNavMenuItem from './top_nav_menu_item.vue';
-const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-50';
+const BORDER_CLASSES = 'gl-pt-3 gl-border-1 gl-border-t-solid';
export default {
components: {
@@ -17,6 +17,11 @@ export default {
required: false,
default: false,
},
+ isPrimarySection: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
onClick(menuItem) {
@@ -30,8 +35,11 @@ export default {
getMenuSectionClasses(index) {
// This is a method instead of a computed so we don't have to incur the cost of
// creating a whole new array/object.
+ const hasBorder = this.withTopBorder || index > 0;
return {
- [BORDER_CLASSES]: this.withTopBorder || index > 0,
+ [BORDER_CLASSES]: hasBorder,
+ 'gl-border-gray-100': hasBorder && this.isPrimarySection,
+ 'gl-border-gray-50': hasBorder && !this.isPrimarySection,
'gl-mt-3': index > 0,
};
},
diff --git a/app/assets/javascripts/notes/components/attachments_warning.vue b/app/assets/javascripts/notes/components/attachments_warning.vue
new file mode 100644
index 00000000000..aaa4b0d92b9
--- /dev/null
+++ b/app/assets/javascripts/notes/components/attachments_warning.vue
@@ -0,0 +1,18 @@
+<script>
+import { COMMENT_FORM } from '../i18n';
+
+export default {
+ i18n: COMMENT_FORM.attachmentMsg,
+ data() {
+ return {
+ message: this.$options.i18n,
+ };
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-note-warning" data-testid="attachment-warning">
+ {{ message }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue
index 84bda1b0b5c..cc372520c70 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -1,14 +1,18 @@
<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import EmailParticipantsWarning from './email_participants_warning.vue';
+import AttachmentsWarning from './attachments_warning.vue';
const DEFAULT_NOTEABLE_TYPE = 'Issue';
export default {
components: {
+ AttachmentsWarning,
EmailParticipantsWarning,
NoteableWarning,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
noteableData: {
type: Object,
@@ -29,6 +33,11 @@ export default {
required: false,
default: false,
},
+ containsLink: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
isLocked() {
@@ -46,6 +55,13 @@ export default {
showEmailParticipantsWarning() {
return this.emailParticipants.length && !this.isInternalNote;
},
+ showAttachmentWarning() {
+ return (
+ this.glFeatures.serviceDeskNewNoteEmailNativeAttachments &&
+ this.showEmailParticipantsWarning &&
+ this.containsLink
+ );
+ },
},
};
</script>
@@ -68,6 +84,7 @@ export default {
:confidential-noteable-docs-path="noteableData.confidential_issues_docs_path"
/>
<slot></slot>
+ <attachments-warning v-if="showAttachmentWarning" />
<email-participants-warning
v-if="showEmailParticipantsWarning"
class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!"
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index c6e7117cf2e..4f7256d0b0e 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -28,6 +28,7 @@ import CommentTypeDropdown from './comment_type_dropdown.vue';
import DiscussionLockedWidget from './discussion_locked_widget.vue';
import NoteSignedOutWidget from './note_signed_out_widget.vue';
+const ATTACHMENT_REGEXP = /!?\[.*?\]\(\/uploads\/[0-9a-f]{32}\/.*?\)/;
export default {
name: 'CommentForm',
i18n: COMMENT_FORM,
@@ -176,6 +177,9 @@ export default {
disableSubmitButton() {
return this.note.length === 0 || this.isSubmitting;
},
+ containsLink() {
+ return ATTACHMENT_REGEXP.test(this.note);
+ },
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
@@ -356,6 +360,7 @@ export default {
:noteable-data="getNoteableData"
:is-internal-note="noteIsInternal"
:noteable-type="noteableType"
+ :contains-link="containsLink"
>
<markdown-field
ref="markdownField"
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index c15c11ed9db..abed95a9706 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -4,12 +4,14 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import { createAlert } from '~/flash';
+import { TYPE_ISSUE } from '~/issues/constants';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '~/lib/utils/text_utility';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import ReplyButton from './note_actions/reply_button.vue';
import TimelineEventButton from './note_actions/timeline_event_button.vue';
@@ -30,6 +32,7 @@ export default {
GlDropdownItem,
UserAccessRoleBadge,
EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ AbuseCategorySelector,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -58,11 +61,6 @@ export default {
required: false,
default: '',
},
- reportAbusePath: {
- type: String,
- required: false,
- default: null,
- },
isAuthor: {
type: Boolean,
required: false,
@@ -135,11 +133,16 @@ export default {
default: '',
},
},
+ data() {
+ return {
+ isReportAbuseDrawerOpen: false,
+ };
+ },
computed: {
...mapState(['isPromoteCommentToTimelineEventInProgress']),
...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']),
shouldShowActionsDropdown() {
- return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
+ return this.currentUserId;
},
showDeleteAction() {
return this.canDelete && !this.canReportAsAbuse && !this.noteUrl;
@@ -171,7 +174,7 @@ export default {
return this.getNoteableData.assignees || [];
},
isIssue() {
- return this.targetType === 'issue';
+ return this.targetType === TYPE_ISSUE;
},
canAssign() {
return this.getNoteableData.current_user?.can_set_issue_metadata && this.isIssue;
@@ -233,7 +236,7 @@ export default {
assignees.push({ id: this.author.id });
}
- if (this.targetType === 'issue') {
+ if (this.targetType === TYPE_ISSUE) {
Api.updateIssue(project_id, iid, {
assignee_ids: assignees.map((assignee) => assignee.id),
})
@@ -252,6 +255,9 @@ export default {
awardName,
});
},
+ toggleReportAbuseDrawer(isOpen) {
+ this.isReportAbuseDrawerOpen = isOpen;
+ },
},
};
</script>
@@ -261,7 +267,7 @@ export default {
<user-access-role-badge
v-if="isAuthor"
v-gl-tooltip
- class="gl-mr-3 d-none d-md-inline-block"
+ class="gl-mr-3 gl-display-none gl-sm-display-block"
:title="displayAuthorBadgeText"
>
{{ __('Author') }}
@@ -269,7 +275,7 @@ export default {
<user-access-role-badge
v-if="accessLevel"
v-gl-tooltip
- class="gl-mr-3"
+ class="gl-mr-3 gl-display-none gl-sm-display-block"
:title="displayMemberBadgeText"
>
{{ accessLevel }}
@@ -277,7 +283,7 @@ export default {
<user-access-role-badge
v-else-if="isContributor"
v-gl-tooltip
- class="gl-mr-3"
+ class="gl-mr-3 gl-display-none gl-sm-display-block"
:title="displayContributorBadgeText"
>
{{ __('Contributor') }}
@@ -334,7 +340,7 @@ export default {
:aria-label="$options.i18n.editCommentLabel"
icon="pencil"
category="tertiary"
- class="note-action-button js-note-edit"
+ class="note-action-button js-note-edit gl-display-none gl-sm-display-block"
data-qa-selector="note_edit_button"
@click="onEdit"
/>
@@ -362,7 +368,18 @@ export default {
/>
<!-- eslint-enable @gitlab/vue-no-data-toggle -->
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
- <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath">
+ <gl-dropdown-item
+ v-if="canEdit"
+ class="js-note-edit gl-sm-display-none!"
+ @click.prevent="onEdit"
+ >
+ {{ __('Edit comment') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canReportAsAbuse"
+ data-testid="report-abuse-button"
+ @click="toggleReportAbuseDrawer(true)"
+ >
{{ $options.i18n.reportAbuse }}
</gl-dropdown-item>
<gl-dropdown-item
@@ -380,5 +397,14 @@ export default {
</gl-dropdown-item>
</ul>
</div>
+ <!-- IMPORTANT: show this component lazily because it causes layout thrashing -->
+ <!-- https://gitlab.com/gitlab-org/gitlab/-/issues/331172#note_1269378396 -->
+ <abuse-category-selector
+ v-if="canReportAsAbuse && isReportAbuseDrawerOpen"
+ :reported-user-id="authorId"
+ :reported-from-url="noteUrl"
+ :show-drawer="isReportAbuseDrawerOpen"
+ @close-drawer="toggleReportAbuseDrawer(false)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 79b6139d4b1..c83b3d870d7 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -96,6 +96,8 @@ export default {
'text-underline': this.isUsernameLinkHovered,
'author-name-link': true,
'js-user-link': true,
+ 'gl-overflow-hidden': true,
+ 'gl-overflow-wrap-break': true,
};
},
authorName() {
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 826e7e5a3d0..93575ad57ff 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -43,6 +43,11 @@ export default {
SafeHtml,
},
mixins: [noteable, resolvable],
+ inject: {
+ reportAbusePath: {
+ default: '',
+ },
+ },
props: {
note: {
type: Object,
@@ -129,7 +134,7 @@ export default {
};
},
canReportAsAbuse() {
- return Boolean(this.note.report_abuse_path) && this.author.id !== this.getUserData.id;
+ return Boolean(this.reportAbusePath) && this.author.id !== this.getUserData.id;
},
noteAnchorId() {
return `note_${this.note.id}`;
@@ -488,7 +493,6 @@ export default {
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:can-resolve="canResolve"
- :report-abuse-path="note.report_abuse_path"
:resolvable="note.resolvable || note.isDraft"
:is-resolved="note.resolved || note.resolve_discussion"
:is-resolving="isResolving"
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
index 9fc11ff65d5..2a0a3d5414f 100644
--- a/app/assets/javascripts/notes/components/sidebar_subscription.vue
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions } from 'vuex';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { fetchPolicies } from '~/lib/graphql';
import { confidentialityQueries } from '~/sidebar/constants';
import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
@@ -28,7 +28,7 @@ export default {
},
},
created() {
- if (this.issuableType !== IssuableType.Issue && this.issuableType !== IssuableType.Epic) {
+ if (this.issuableType !== TYPE_ISSUE && this.issuableType !== TYPE_EPIC) {
return;
}
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 734e08dd586..4437d461308 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -79,7 +79,7 @@ export default {
:link-href="author.path"
:img-alt="author.name"
img-css-classes="gl-mr-0!"
- :img-src="author.avatar_url"
+ :img-src="author.avatar_url || author.avatarUrl"
:img-size="24"
:tooltip-text="author.name"
tooltip-placement="bottom"
@@ -102,7 +102,10 @@ export default {
</gl-link>
</template>
</gl-sprintf>
- <time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" />
+ <time-ago-tooltip
+ :time="lastReply.created_at || lastReply.createdAt"
+ tooltip-placement="bottom"
+ />
</template>
<gl-button
v-else
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
index 9b5fd69f816..a758a55014a 100644
--- a/app/assets/javascripts/notes/i18n.js
+++ b/app/assets/javascripts/notes/i18n.js
@@ -45,4 +45,7 @@ export const COMMENT_FORM = {
commentHelp: __('Add a general comment to this %{noteableDisplayName}.'),
internalCommentHelp: __('Add a confidential internal note to this %{noteableDisplayName}.'),
},
+ attachmentMsg: s__(
+ 'Notes|Attachments are sent by email. Attachments over 10 MB are sent as links to your GitLab instance, and only accessible to project members.',
+ ),
};
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 95263e666b2..2e09c9f2288 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -52,6 +52,7 @@ export default () => {
store,
provide: {
showTimelineViewToggle,
+ reportAbusePath: notesDataset.reportAbusePath,
},
data() {
return {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 5cad091ce2c..f6b9be6ee9b 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -4,6 +4,7 @@ import Vue from 'vue';
import Api from '~/api';
import { createAlert, VARIANT_INFO } from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
@@ -20,7 +21,7 @@ import TaskList from '~/task_list';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_NOTE } from '~/graphql_shared/constants';
+import { TYPENAME_NOTE } from '~/graphql_shared/constants';
import notesEventHub from '../event_hub';
import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql';
@@ -37,7 +38,8 @@ export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath })
return utils.gqClient
.mutate({
- mutation: targetType === 'issue' ? updateIssueLockMutation : updateMergeRequestLockMutation,
+ mutation:
+ targetType === TYPE_ISSUE ? updateIssueLockMutation : updateMergeRequestLockMutation,
variables: {
input: {
projectPath: fullPath,
@@ -48,7 +50,7 @@ export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath })
})
.then(({ data }) => {
const discussionLocked =
- targetType === 'issue'
+ targetType === TYPE_ISSUE
? data.issueSetLocked.issue.discussionLocked
: data.mergeRequestSetLocked.mergeRequest.discussionLocked;
@@ -276,7 +278,7 @@ export const promoteCommentToTimelineEvent = (
mutation: promoteTimelineEvent,
variables: {
input: {
- noteId: convertToGraphQLId(TYPE_NOTE, noteId),
+ noteId: convertToGraphQLId(TYPENAME_NOTE, noteId),
},
},
})
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
index 787f21d9419..d982df4f984 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue
@@ -1,15 +1,30 @@
<script>
+import { n__ } from '~/locale';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
+import {
+ CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+} from '~/packages_and_registries/package_registry/constants';
+import Tracking from '~/tracking';
export default {
components: {
+ DeleteModal,
VersionRow,
PackagesListLoader,
RegistryList,
},
+ mixins: [Tracking.mixin()],
props: {
+ canDestroy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
versions: {
type: Array,
required: true,
@@ -25,11 +40,35 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ itemsToBeDeleted: [],
+ };
+ },
computed: {
+ listTitle() {
+ return n__('%d version', '%d versions', this.versions.length);
+ },
isListEmpty() {
return this.versions.length === 0;
},
},
+ methods: {
+ deleteItemsCanceled() {
+ this.track(CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ this.itemsToBeDeleted = [];
+ },
+ deleteItemsConfirmation() {
+ this.$emit('delete', this.itemsToBeDeleted);
+ this.track(DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ this.itemsToBeDeleted = [];
+ },
+ setItemsToBeDeleted(items) {
+ this.itemsToBeDeleted = items;
+ this.track(REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION);
+ this.$refs.deletePackagesModal.show();
+ },
+ },
};
</script>
<template>
@@ -40,17 +79,34 @@ export default {
<slot v-else-if="isListEmpty" name="empty-state"></slot>
<div v-else>
<registry-list
- :hidden-delete="true"
+ :hidden-delete="!canDestroy"
:is-loading="isLoading"
:items="versions"
:pagination="pageInfo"
+ :title="listTitle"
+ @delete="setItemsToBeDeleted"
@prev-page="$emit('prev-page')"
@next-page="$emit('next-page')"
>
- <template #default="{ item }">
- <version-row :package-entity="item" />
+ <template #default="{ first, item, isSelected, selectItem }">
+ <!-- `first` prop is used to decide whether to show the top border
+ for the first element. We want to show the top border only when
+ user has permission to bulk delete versions. -->
+ <version-row
+ :first="canDestroy && first"
+ :package-entity="item"
+ :selected="isSelected(item)"
+ @select="selectItem(item)"
+ />
</template>
</registry-list>
+
+ <delete-modal
+ ref="deletePackagesModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ @confirm="deleteItemsConfirmation"
+ @cancel="deleteItemsCanceled"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
index 57ff3cd2a83..9f8f6328970 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/version_row.vue
@@ -1,15 +1,29 @@
<script>
-import { GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import {
+ GlFormCheckbox,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlTooltipDirective,
+ GlTruncate,
+} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
import PublishMethod from '~/packages_and_registries/shared/components/publish_method.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { PACKAGE_DEFAULT_STATUS } from '../../constants';
+import {
+ ERRORED_PACKAGE_TEXT,
+ ERROR_PUBLISHING,
+ PACKAGE_ERROR_STATUS,
+ WARNING_TEXT,
+} from '../../constants';
export default {
- name: 'PackageListRow',
+ name: 'PackageVersionRow',
components: {
+ GlFormCheckbox,
+ GlIcon,
GlLink,
GlSprintf,
GlTruncate,
@@ -18,30 +32,55 @@ export default {
ListItem,
TimeAgoTooltip,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
packageEntity: {
type: Object,
required: true,
},
+ selected: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
computed: {
+ containsWebPathLink() {
+ return Boolean(this.packageEntity?._links?.webPath);
+ },
packageLink() {
return `${getIdFromGraphQLId(this.packageEntity.id)}`;
},
- disabledRow() {
- return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
+ errorStatusRow() {
+ return this.packageEntity?.status === PACKAGE_ERROR_STATUS;
},
},
+ i18n: {
+ erroredPackageText: ERRORED_PACKAGE_TEXT,
+ errorPublishing: ERROR_PUBLISHING,
+ warningText: WARNING_TEXT,
+ },
};
</script>
<template>
- <list-item :disabled="disabledRow">
+ <list-item :selected="selected" v-bind="$attrs">
+ <template #left-action>
+ <gl-form-checkbox
+ v-if="packageEntity.canDestroy"
+ class="gl-m-0"
+ :checked="selected"
+ @change="$emit('select')"
+ />
+ </template>
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
- <gl-link :href="packageLink" class="gl-text-body gl-min-w-0" :disabled="disabledRow">
+ <gl-link v-if="containsWebPathLink" class="gl-text-body gl-min-w-0" :href="packageLink">
<gl-truncate :text="packageEntity.name" />
</gl-link>
+ <gl-truncate v-else :text="packageEntity.name" />
<package-tags
v-if="packageEntity.tags.nodes && packageEntity.tags.nodes.length"
@@ -53,7 +92,20 @@ export default {
</div>
</template>
<template #left-secondary>
- {{ packageEntity.version }}
+ <div v-if="errorStatusRow" class="gl-text-red-500">
+ <gl-icon
+ v-gl-tooltip="{ title: $options.i18n.erroredPackageText }"
+ name="warning"
+ :aria-label="$options.i18n.warningText"
+ />
+ <span>{{ $options.i18n.errorPublishing }}</span>
+ </div>
+ <gl-truncate
+ v-else
+ class="gl-max-w-15 gl-md-max-w-26"
+ :text="packageEntity.version"
+ :with-tooltip="true"
+ />
</template>
<template #right-primary>
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
deleted file mode 100644
index e1cf4883029..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<script>
-import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
-import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } 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) {
- createAlert({
- message: this.$options.i18n.successMessage,
- variant: VARIANT_SUCCESS,
- });
- }
- } catch (error) {
- createAlert({
- message: this.$options.i18n.errorMessage,
- variant: VARIANT_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/functional/delete_packages.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
new file mode 100644
index 00000000000..0914c013108
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_packages.vue
@@ -0,0 +1,76 @@
+<script>
+import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
+import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+
+import {
+ DELETE_PACKAGE_ERROR_MESSAGE,
+ DELETE_PACKAGE_SUCCESS_MESSAGE,
+ DELETE_PACKAGES_ERROR_MESSAGE,
+ DELETE_PACKAGES_SUCCESS_MESSAGE,
+} from '~/packages_and_registries/package_registry/constants';
+
+export default {
+ name: 'DeletePackages',
+ props: {
+ refetchQueries: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ showSuccessAlert: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ errorMessage: DELETE_PACKAGE_ERROR_MESSAGE,
+ errorMessageMultiple: DELETE_PACKAGES_ERROR_MESSAGE,
+ successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE,
+ successMessageMultiple: DELETE_PACKAGES_SUCCESS_MESSAGE,
+ },
+ methods: {
+ async deletePackages(packageEntities) {
+ const isSinglePackage = packageEntities.length === 1;
+ try {
+ this.$emit('start');
+ const ids = packageEntities.map((packageEntity) => packageEntity.id);
+ const { data } = await this.$apollo.mutate({
+ mutation: destroyPackagesMutation,
+ variables: {
+ ids,
+ },
+ awaitRefetchQueries: Boolean(this.refetchQueries),
+ refetchQueries: this.refetchQueries,
+ });
+
+ if (data?.destroyPackages?.errors[0]) {
+ throw data.destroyPackages.errors[0];
+ }
+
+ if (this.showSuccessAlert) {
+ createAlert({
+ message: isSinglePackage
+ ? this.$options.i18n.successMessage
+ : this.$options.i18n.successMessageMultiple,
+ variant: VARIANT_SUCCESS,
+ });
+ }
+ } catch (error) {
+ createAlert({
+ message: isSinglePackage
+ ? this.$options.i18n.errorMessage
+ : this.$options.i18n.errorMessageMultiple,
+ variant: VARIANT_WARNING,
+ captureError: true,
+ error,
+ });
+ }
+ this.$emit('end');
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({ deletePackages: this.deletePackages });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 7ad1ebac11e..16f21bfe61d 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -11,8 +11,11 @@ import {
import { s__, __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
+ ERRORED_PACKAGE_TEXT,
+ ERROR_PUBLISHING,
PACKAGE_ERROR_STATUS,
PACKAGE_DEFAULT_STATUS,
+ WARNING_TEXT,
} from '~/packages_and_registries/package_registry/constants';
import { getPackageTypeLabel } from '~/packages_and_registries/package_registry/utils';
import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
@@ -78,9 +81,6 @@ export default {
nonDefaultRow() {
return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
},
- routerLinkEvent() {
- return this.nonDefaultRow ? '' : 'click';
- },
errorPackageStyle() {
return {
'gl-text-red-500': this.errorStatusRow,
@@ -89,18 +89,18 @@ export default {
},
},
i18n: {
- erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
+ erroredPackageText: ERRORED_PACKAGE_TEXT,
createdAt: __('Created %{timestamp}'),
deletePackage: s__('PackageRegistry|Delete package'),
- errorPublishing: s__('PackageRegistry|Error publishing'),
- warning: __('Warning'),
+ errorPublishing: ERROR_PUBLISHING,
+ warning: WARNING_TEXT,
moreActions: __('More actions'),
},
};
</script>
<template>
- <list-item data-testid="package-row" v-bind="$attrs">
+ <list-item data-testid="package-row" :selected="selected" v-bind="$attrs">
<template #left-action>
<gl-form-checkbox
v-if="packageEntity.canDestroy"
@@ -117,7 +117,6 @@ export default {
class="gl-text-body gl-min-w-0"
data-testid="details-link"
data-qa-selector="package_link"
- :event="routerLinkEvent"
:to="{ name: 'details', params: { id: packageId } }"
>
<gl-truncate :text="packageEntity.name" />
@@ -134,8 +133,16 @@ export default {
</div>
</template>
<template #left-secondary>
- <div v-if="!errorStatusRow" class="gl-display-flex" data-testid="left-secondary-infos">
- <span>{{ packageEntity.version }}</span>
+ <div
+ v-if="!errorStatusRow"
+ class="gl-display-flex gl-align-items-center"
+ data-testid="left-secondary-infos"
+ >
+ <gl-truncate
+ class="gl-max-w-15 gl-md-max-w-26"
+ :text="packageEntity.version"
+ :with-tooltip="true"
+ />
<div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2">
<gl-sprintf :message="s__('PackageRegistry|published by %{author}')">
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 40bf7b7e143..486ab4fdc99 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
@@ -112,7 +112,7 @@ export default {
this.itemsToBeDeleted = [];
},
deleteItemConfirmation() {
- this.$emit('package:delete', this.itemToBeDeleted);
+ this.$emit('delete', [this.itemToBeDeleted]);
this.track(DELETE_PACKAGE_TRACKING_ACTION);
this.itemToBeDeleted = null;
},
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 539b12bd6db..d979ae5c08c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -115,6 +115,10 @@ export const DELETE_PACKAGES_TRACKING_ACTION = 'delete_packages';
export const REQUEST_DELETE_PACKAGES_TRACKING_ACTION = 'request_delete_packages';
export const CANCEL_DELETE_PACKAGES_TRACKING_ACTION = 'cancel_delete_packages';
+export const DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'delete_package_versions';
+export const REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'request_delete_package_versions';
+export const CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION = 'cancel_delete_package_versions';
+
export const DELETE_PACKAGES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting packages.',
);
@@ -124,6 +128,16 @@ export const DELETE_PACKAGES_MODAL_TITLE = s__('PackageRegistry|Delete packages'
export const DELETE_PACKAGE_MODAL_PRIMARY_ACTION = s__('PackageRegistry|Permanently delete');
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
+export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while deleting the package.',
+);
+
+export const ERRORED_PACKAGE_TEXT = s__(
+ 'PackageRegistry|Invalid Package: failed metadata extraction',
+);
+export const ERROR_PUBLISHING = s__('PackageRegistry|Error publishing');
+export const WARNING_TEXT = __('Warning');
+
export const PACKAGE_REGISTRY_TITLE = __('Package Registry');
export const PACKAGE_ERROR_STATUS = 'ERROR';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql
deleted file mode 100644
index 884980f24a9..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation destroyPackage($id: PackagesPackageID!) {
- destroyPackage(input: { id: $id }) {
- errors
- }
-}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 9153906a38c..109d535469b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -66,9 +66,13 @@ query getPackageDetails(
nodes {
id
name
+ canDestroy
createdAt
version
status
+ _links {
+ webPath
+ }
tags(first: 1) {
nodes {
id
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/index.js b/app/assets/javascripts/packages_and_registries/package_registry/index.js
index 336eb0ca079..15ed98122a0 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/index.js
@@ -36,7 +36,7 @@ export default () => {
const attachMainComponent = () =>
new Vue({
el,
- name: 'PackageRegistery',
+ name: 'PackageRegistry',
router,
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 03352f01aca..4591c2eca87 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -11,6 +11,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/flash';
+import { TYPENAME_PACKAGES_PACKAGE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
@@ -23,7 +24,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 PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
-import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
+import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue';
import {
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_COMPOSER,
@@ -71,7 +72,7 @@ export default {
AdditionalMetadata,
InstallationCommands,
PackageFiles,
- DeletePackage,
+ DeletePackages,
PackageVersionsList,
},
directives: {
@@ -94,6 +95,7 @@ export default {
deletePackageModalContent: DELETE_MODAL_CONTENT,
filesToDelete: [],
mutationLoading: false,
+ versionsMutationLoading: false,
packageEntity: {},
};
},
@@ -132,7 +134,7 @@ export default {
},
queryVariables() {
return {
- id: convertToGraphQLId('Packages::Package', this.packageId),
+ id: convertToGraphQLId(TYPENAME_PACKAGES_PACKAGE, this.packageId),
first: GRAPHQL_PAGE_SIZE,
};
},
@@ -145,6 +147,9 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
+ isVersionsLoading() {
+ return this.isLoading || this.versionsMutationLoading;
+ },
packageFilesLoading() {
return this.isLoading || this.mutationLoading;
},
@@ -156,9 +161,6 @@ export default {
category: packageTypeToTrackCategory(this.packageType),
};
},
- hasVersions() {
- return this.packageEntity.versions?.nodes?.length > 0;
- },
versionPageInfo() {
return this.packageEntity?.versions?.pageInfo ?? {};
},
@@ -180,6 +182,14 @@ export default {
PACKAGE_TYPE_PYPI,
].includes(this.packageType);
},
+ refetchQueriesData() {
+ return [
+ {
+ query: getPackageDetails,
+ variables: this.queryVariables,
+ },
+ ];
+ },
},
methods: {
formatSize(size) {
@@ -205,12 +215,7 @@ export default {
ids,
},
awaitRefetchQueries: true,
- refetchQueries: [
- {
- query: getPackageDetails,
- variables: this.queryVariables,
- },
- ],
+ refetchQueries: this.refetchQueriesData,
});
if (data?.destroyPackageFiles?.errors[0]) {
throw data.destroyPackageFiles.errors[0];
@@ -402,27 +407,38 @@ export default {
}}</gl-badge>
</template>
- <package-versions-list
- :is-loading="isLoading"
- :page-info="versionPageInfo"
- :versions="packageEntity.versions.nodes"
- @prev-page="fetchPreviousVersionsPage"
- @next-page="fetchNextVersionsPage"
+ <delete-packages
+ :refetch-queries="refetchQueriesData"
+ show-success-alert
+ @start="versionsMutationLoading = true"
+ @end="versionsMutationLoading = false"
>
- <template #empty-state>
- <p class="gl-mt-3" data-testid="no-versions-message">
- {{ s__('PackageRegistry|There are no other versions of this package.') }}
- </p>
+ <template #default="{ deletePackages }">
+ <package-versions-list
+ :can-destroy="packageEntity.canDestroy"
+ :is-loading="isVersionsLoading"
+ :page-info="versionPageInfo"
+ :versions="packageEntity.versions.nodes"
+ @delete="deletePackages"
+ @prev-page="fetchPreviousVersionsPage"
+ @next-page="fetchNextVersionsPage"
+ >
+ <template #empty-state>
+ <p class="gl-mt-3" data-testid="no-versions-message">
+ {{ s__('PackageRegistry|There are no other versions of this package.') }}
+ </p>
+ </template>
+ </package-versions-list>
</template>
- </package-versions-list>
+ </delete-packages>
</gl-tab>
</gl-tabs>
- <delete-package
+ <delete-packages
@start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)"
@end="navigateToListWithSuccessModal"
>
- <template #default="{ deletePackage }">
+ <template #default="{ deletePackages }">
<gl-modal
ref="deleteModal"
size="sm"
@@ -430,7 +446,7 @@ export default {
data-testid="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
- @primary="deletePackage(packageEntity)"
+ @primary="deletePackages([packageEntity])"
@hidden="resetDeleteModalContent"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
>
@@ -446,7 +462,7 @@ export default {
</gl-sprintf>
</gl-modal>
</template>
- </delete-package>
+ </delete-packages>
<gl-modal
ref="deleteFileModal"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
index 396429d60d8..31c76c95e45 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue
@@ -1,6 +1,6 @@
<script>
-import { GlAlert, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { createAlert, VARIANT_INFO, VARIANT_SUCCESS, VARIANT_DANGER } from '~/flash';
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { createAlert, VARIANT_INFO } from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants';
@@ -9,33 +9,28 @@ import {
GROUP_RESOURCE_TYPE,
GRAPHQL_PAGE_SIZE,
DELETE_PACKAGE_SUCCESS_MESSAGE,
- DELETE_PACKAGES_ERROR_MESSAGE,
- DELETE_PACKAGES_SUCCESS_MESSAGE,
EMPTY_LIST_HELP_URL,
PACKAGE_HELP_URL,
} from '~/packages_and_registries/package_registry/constants';
import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
-import destroyPackagesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_packages.mutation.graphql';
-import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
+import DeletePackages from '~/packages_and_registries/package_registry/components/functional/delete_packages.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/list/package_title.vue';
import PackageSearch from '~/packages_and_registries/package_registry/components/list/package_search.vue';
import PackageList from '~/packages_and_registries/package_registry/components/list/packages_list.vue';
export default {
components: {
- GlAlert,
GlEmptyState,
GlLink,
GlSprintf,
PackageList,
PackageTitle,
PackageSearch,
- DeletePackage,
+ DeletePackages,
},
inject: ['emptyListIllustration', 'isGroupPage', 'fullPath'],
data() {
return {
- alertVariables: null,
packages: {},
sort: '',
filters: {},
@@ -114,39 +109,6 @@ export default {
historyReplaceState(cleanUrl);
}
},
- async deletePackages(packageEntities) {
- this.mutationLoading = true;
- try {
- const { data } = await this.$apollo.mutate({
- mutation: destroyPackagesMutation,
- variables: {
- ids: packageEntities.map((i) => i.id),
- },
- awaitRefetchQueries: true,
- refetchQueries: [
- {
- query: getPackagesQuery,
- variables: { ...this.queryVariables, first: GRAPHQL_PAGE_SIZE },
- },
- ],
- });
-
- if (data?.destroyPackages?.errors[0]) {
- throw new Error(data.destroyPackages.errors[0]);
- }
- this.showAlert({
- variant: VARIANT_SUCCESS,
- message: DELETE_PACKAGES_SUCCESS_MESSAGE,
- });
- } catch {
- this.showAlert({
- variant: VARIANT_DANGER,
- message: DELETE_PACKAGES_ERROR_MESSAGE,
- });
- } finally {
- this.mutationLoading = false;
- }
- },
handleSearchUpdate({ sort, filters }) {
this.sort = sort;
this.filters = { ...filters };
@@ -180,9 +142,6 @@ export default {
updateQuery: this.updateQuery,
});
},
- showAlert(obj) {
- this.alertVariables = { ...obj };
- },
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
@@ -201,32 +160,22 @@ export default {
<template>
<div>
- <gl-alert
- v-if="alertVariables"
- :variant="alertVariables.variant"
- class="gl-mt-5"
- dismissible
- @dismiss="alertVariables = null"
- >
- {{ alertVariables.message }}
- </gl-alert>
<package-title :help-url="$options.links.PACKAGE_HELP_URL" :count="packagesCount" />
<package-search class="gl-mb-5" @update="handleSearchUpdate" />
- <delete-package
+ <delete-packages
:refetch-queries="refetchQueriesData"
show-success-alert
@start="mutationLoading = true"
@end="mutationLoading = false"
>
- <template #default="{ deletePackage }">
+ <template #default="{ deletePackages }">
<package-list
:list="packages.nodes"
:is-loading="isLoading"
:page-info="pageInfo"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
- @package:delete="deletePackage"
@delete="deletePackages"
>
<template #empty-state>
@@ -245,6 +194,6 @@ export default {
</template>
</package-list>
</template>
- </delete-package>
+ </delete-packages>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
index f1f0b970b15..f95ec4336dc 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import { sprintf } from '~/locale';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
@@ -7,10 +8,14 @@ import {
KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
SET_CLEANUP_POLICY_BUTTON,
+ READY_FOR_CLEANUP_MESSAGE,
+ TIME_TO_NEXT_CLEANUP_MESSAGE,
} from '~/packages_and_registries/settings/project/constants';
+import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
import Tracking from '~/tracking';
+import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import ExpirationDropdown from './expiration_dropdown.vue';
export default {
@@ -36,6 +41,8 @@ export default {
KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
SET_CLEANUP_POLICY_BUTTON,
+ TIME_TO_NEXT_CLEANUP_MESSAGE,
+ READY_FOR_CLEANUP_MESSAGE,
},
data() {
return {
@@ -69,6 +76,15 @@ export default {
keepNDuplicatedPackageFiles: this.prefilledForm.keepNDuplicatedPackageFiles,
};
},
+ nextCleanupMessage() {
+ const { nextRunAt } = this.value;
+ const difference = calculateRemainingMilliseconds(nextRunAt);
+ return difference
+ ? sprintf(TIME_TO_NEXT_CLEANUP_MESSAGE, {
+ nextRunAt: approximateDuration(difference / 1000),
+ })
+ : READY_FOR_CLEANUP_MESSAGE;
+ },
},
methods: {
findDefaultOption(option) {
@@ -83,6 +99,15 @@ export default {
variables: {
input: this.mutationVariables,
},
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: packagesCleanupPolicyQuery,
+ variables: {
+ projectPath: this.projectPath,
+ },
+ },
+ ],
})
.then(({ data }) => {
const [errorMessage] = data?.updatePackagesCleanupPolicy?.errors ?? [];
@@ -119,6 +144,9 @@ export default {
data-testid="keep-n-duplicated-package-files-dropdown"
@input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
/>
+ <p v-if="value.nextRunAt" data-testid="next-run-at">
+ {{ nextCleanupMessage }}
+ </p>
<div class="gl-mt-7 gl-display-flex gl-align-items-center">
<gl-button
data-testid="save-button"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index a9b47cbd343..731fb3e4c45 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -74,6 +74,12 @@ export const KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL = s__(
export const KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION = s__(
'PackageRegistry|Examples of assets include .pom & .jar files',
);
+export const TIME_TO_NEXT_CLEANUP_MESSAGE = s__(
+ 'PackageRegistry|Packages and assets will not be deleted until cleanup runs in %{nextRunAt}.',
+);
+export const READY_FOR_CLEANUP_MESSAGE = s__(
+ 'PackageRegistry|Packages and assets cleanup is ready to be executed when the next cleanup job runs.',
+);
export const KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME = 'keepNDuplicatedPackageFiles';
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
index d07d0a7673f..7485f8282ee 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue
@@ -1,6 +1,5 @@
<script>
import { GlButton, GlFormCheckbox, GlKeysetPagination } from '@gitlab/ui';
-import { filter } from 'lodash';
import { __ } from '~/locale';
export default {
@@ -52,24 +51,31 @@ export default {
return this.pagination.hasPreviousPage || this.pagination.hasNextPage;
},
disableDeleteButton() {
- return this.isLoading || filter(this.selectedReferences).length === 0;
+ return this.isLoading || this.selectedItems.length === 0;
},
selectedItems() {
return this.items.filter(this.isSelected);
},
- selectAll: {
- get() {
- return this.items.every(this.isSelected);
- },
- set(value) {
- this.items.forEach((item) => {
- const id = item[this.idProperty];
- this.$set(this.selectedReferences, id, value);
- });
- },
+ disabled() {
+ return this.items.length === 0;
+ },
+ checked() {
+ return this.items.every(this.isSelected);
+ },
+ indeterminate() {
+ return !this.checked && this.items.some(this.isSelected);
+ },
+ label() {
+ return this.checked ? __('Unselect all') : __('Select all');
},
},
methods: {
+ onChange(event) {
+ this.items.forEach((item) => {
+ const id = item[this.idProperty];
+ this.$set(this.selectedReferences, id, event);
+ });
+ },
selectItem(item) {
const id = item[this.idProperty];
this.$set(this.selectedReferences, id, !this.selectedReferences[id]);
@@ -80,7 +86,7 @@ export default {
},
},
i18n: {
- deleteSelected: __('Delete Selected'),
+ deleteSelected: __('Delete selected'),
},
};
</script>
@@ -91,9 +97,18 @@ export default {
v-if="!hiddenDelete"
class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center"
>
- <gl-form-checkbox v-model="selectAll" class="gl-ml-2 gl-pt-2">
- <span class="gl-font-weight-bold">{{ title }}</span>
- </gl-form-checkbox>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-form-checkbox
+ class="gl-ml-2 gl-pt-2"
+ :aria-label="label"
+ :checked="checked"
+ :disabled="disabled"
+ :indeterminate="indeterminate"
+ @change="onChange"
+ />
+
+ <p class="gl-font-weight-bold gl-mb-0">{{ title }}</p>
+ </div>
<gl-button
:disabled="disableDeleteButton"
diff --git a/app/assets/javascripts/pages/abuse_reports/index.js b/app/assets/javascripts/pages/abuse_reports/index.js
new file mode 100644
index 00000000000..feceeb0b10a
--- /dev/null
+++ b/app/assets/javascripts/pages/abuse_reports/index.js
@@ -0,0 +1,3 @@
+import { initLinkToSpam } from '~/abuse_reports';
+
+initLinkToSpam();
diff --git a/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js b/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js
index 455c637a6b3..8b8147425bc 100644
--- a/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js
+++ b/app/assets/javascripts/pages/admin/application_settings/account_and_limits.js
@@ -20,10 +20,65 @@ function setUserInternalRegexPlaceholder(checkbox) {
}
}
-export default function initUserInternalRegexPlaceholder() {
+function initUserInternalRegexPlaceholder() {
const checkbox = document.getElementById('application_setting_user_default_external');
setUserInternalRegexPlaceholder(checkbox);
checkbox.addEventListener('change', () => {
setUserInternalRegexPlaceholder(checkbox);
});
}
+
+/**
+ * Sets up logic inside "Dormant users" subsection:
+ * - checkbox enables/disables additional input
+ * - shows/hides an inline error on input validation
+ */
+function initDeactivateDormantUsersPeriodInputSection() {
+ const DISPLAY_NONE_CLASS = 'gl-display-none';
+
+ /** @type {HTMLInputElement} */
+ const checkbox = document.getElementById('application_setting_deactivate_dormant_users');
+ /** @type {HTMLInputElement} */
+ const input = document.getElementById('application_setting_deactivate_dormant_users_period');
+ /** @type {HTMLDivElement} */
+ const errorLabel = document.getElementById(
+ 'application_setting_deactivate_dormant_users_period_error',
+ );
+
+ if (!checkbox || !input || !errorLabel) return;
+
+ const hideInputErrorLabel = () => {
+ if (input.checkValidity()) {
+ errorLabel.classList.add(DISPLAY_NONE_CLASS);
+ }
+ };
+
+ const handleInputInvalidState = (event) => {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ errorLabel.classList.remove(DISPLAY_NONE_CLASS);
+ return false;
+ };
+
+ const updateInputDisabledState = () => {
+ input.disabled = !checkbox.checked;
+ if (input.disabled) {
+ hideInputErrorLabel();
+ }
+ };
+
+ // Show error when input is invalid
+ input.addEventListener('invalid', handleInputInvalidState);
+ // Hide error when input changes
+ input.addEventListener('input', hideInputErrorLabel);
+ input.addEventListener('change', hideInputErrorLabel);
+
+ // Handle checkbox change and set initial state
+ checkbox.addEventListener('change', updateInputDisabledState);
+ updateInputDisabledState();
+}
+
+export default function initAccountAndLimitsSection() {
+ initUserInternalRegexPlaceholder();
+ initDeactivateDormantUsersPeriodInputSection();
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index c48d99da990..8a810ca649c 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,9 +1,9 @@
-import initUserInternalRegexPlaceholder from '../account_and_limits';
+import initAccountAndLimitsSection from '../account_and_limits';
import initGitpod from '../gitpod';
import initSignupRestrictions from '../signup_restrictions';
(() => {
- initUserInternalRegexPlaceholder();
+ initAccountAndLimitsSection();
initGitpod();
initSignupRestrictions();
})();
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index f1e92cf195a..366be334e87 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,5 +1,4 @@
import initVariableList from '~/ci/ci_variable_list';
-import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
@@ -8,5 +7,4 @@ initVariableList('js-instance-variables');
selfMonitor();
// Initialize expandable settings panels
initSettingsPanels();
-projectSelect();
initSearchSettings();
diff --git a/app/assets/javascripts/pages/admin/hooks/index.js b/app/assets/javascripts/pages/admin/hooks/index.js
new file mode 100644
index 00000000000..82e601426f1
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/hooks/index.js
@@ -0,0 +1,3 @@
+import { initHookTestDropdowns } from '~/webhooks';
+
+initHookTestDropdowns();
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue
new file mode 100644
index 00000000000..72cfc005782
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
+import CancelJobsModal from './cancel_jobs_modal.vue';
+import { CANCEL_JOBS_MODAL_ID, CANCEL_JOBS_BUTTON_TEXT, CANCEL_BUTTON_TOOLTIP } from './constants';
+
+export default {
+ name: 'CancelJobs',
+ components: {
+ GlButton,
+ CancelJobsModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+ modalId: CANCEL_JOBS_MODAL_ID,
+ buttonText: CANCEL_JOBS_BUTTON_TEXT,
+ buttonTooltip: CANCEL_BUTTON_TOOLTIP,
+};
+</script>
+<template>
+ <div>
+ <gl-button
+ v-gl-modal="$options.modalId"
+ v-gl-tooltip="$options.buttonTooltip"
+ variant="danger"
+ >{{ $options.buttonText }}</gl-button
+ >
+ <cancel-jobs-modal :modal-id="$options.modalId" :url="url" @confirm="$emit('confirm')" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
index b608b3b9492..d5857294617 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/cancel_jobs_modal.vue
@@ -5,10 +5,9 @@ import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
import {
CANCEL_TEXT,
- STOP_JOBS_MODAL_ID,
- STOP_JOBS_FAILED_TEXT,
- STOP_JOBS_MODAL_TITLE,
- STOP_JOBS_WARNING,
+ CANCEL_JOBS_FAILED_TEXT,
+ CANCEL_JOBS_MODAL_TITLE,
+ CANCEL_JOBS_WARNING,
PRIMARY_ACTION_TEXT,
} from './constants';
@@ -21,6 +20,10 @@ export default {
type: String,
required: true,
},
+ modalId: {
+ type: String,
+ required: true,
+ },
},
methods: {
onSubmit() {
@@ -32,7 +35,7 @@ export default {
})
.catch((error) => {
createAlert({
- message: STOP_JOBS_FAILED_TEXT,
+ message: CANCEL_JOBS_FAILED_TEXT,
});
throw error;
});
@@ -45,20 +48,19 @@ export default {
cancelAction: {
text: CANCEL_TEXT,
},
- STOP_JOBS_WARNING,
- STOP_JOBS_MODAL_ID,
- STOP_JOBS_MODAL_TITLE,
+ CANCEL_JOBS_WARNING,
+ CANCEL_JOBS_MODAL_TITLE,
};
</script>
<template>
<gl-modal
- :modal-id="$options.STOP_JOBS_MODAL_ID"
+ :modal-id="modalId"
:action-primary="$options.primaryAction"
:action-cancel="$options.cancelAction"
+ :title="$options.CANCEL_JOBS_MODAL_TITLE"
@primary="onSubmit"
>
- <template #modal-title>{{ $options.STOP_JOBS_MODAL_TITLE }}</template>
- {{ $options.STOP_JOBS_WARNING }}
+ {{ $options.CANCEL_JOBS_WARNING }}
</gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
index 9e2d464bc4d..cfde1fc0a2b 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/constants.js
@@ -1,11 +1,12 @@
import { s__, __ } from '~/locale';
-export const STOP_JOBS_MODAL_ID = 'stop-jobs-modal';
-export const STOP_JOBS_MODAL_TITLE = s__('AdminArea|Stop all jobs?');
-export const STOP_JOBS_BUTTON_TEXT = s__('AdminArea|Stop all jobs');
+export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal';
+export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?');
+export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs');
+export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs');
export const CANCEL_TEXT = __('Cancel');
-export const STOP_JOBS_FAILED_TEXT = s__('AdminArea|Stopping jobs failed');
-export const PRIMARY_ACTION_TEXT = s__('AdminArea|Stop jobs');
-export const STOP_JOBS_WARNING = s__(
- 'AdminArea|You’re about to stop all jobs. This will halt all current jobs that are running.',
+export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed');
+export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed');
+export const CANCEL_JOBS_WARNING = s__(
+ "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?",
);
diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
new file mode 100644
index 00000000000..c5a0509b625
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/jobs/index/components/table/admin_jobs_table_app.vue
@@ -0,0 +1,19 @@
+<script>
+export default {
+ inject: {
+ jobStatuses: {
+ default: null,
+ },
+ url: {
+ default: '',
+ },
+ emptyStateSvgPath: {
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div>{{ __('Jobs') }}</div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index c82b186f671..9df52557212 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,31 +1,33 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import { STOP_JOBS_MODAL_ID } from './components/constants';
-import StopJobsModal from './components/stop_jobs_modal.vue';
+import { CANCEL_JOBS_MODAL_ID } from './components/constants';
+import CancelJobsModal from './components/cancel_jobs_modal.vue';
+import AdminJobsTableApp from './components/table/admin_jobs_table_app.vue';
Vue.use(Translate);
function initJobs() {
const buttonId = 'js-stop-jobs-button';
- const stopJobsButton = document.getElementById(buttonId);
- if (stopJobsButton) {
+ const cancelJobsButton = document.getElementById(buttonId);
+ if (cancelJobsButton) {
// eslint-disable-next-line no-new
new Vue({
- el: `#js-${STOP_JOBS_MODAL_ID}`,
+ el: `#js-${CANCEL_JOBS_MODAL_ID}`,
components: {
- StopJobsModal,
+ CancelJobsModal,
},
mounted() {
- stopJobsButton.classList.remove('disabled');
- stopJobsButton.addEventListener('click', () => {
- this.$root.$emit(BV_SHOW_MODAL, STOP_JOBS_MODAL_ID, `#${buttonId}`);
+ cancelJobsButton.classList.remove('disabled');
+ cancelJobsButton.addEventListener('click', () => {
+ this.$root.$emit(BV_SHOW_MODAL, CANCEL_JOBS_MODAL_ID, `#${buttonId}`);
});
},
render(createElement) {
- return createElement(STOP_JOBS_MODAL_ID, {
+ return createElement(CANCEL_JOBS_MODAL_ID, {
props: {
- url: stopJobsButton.dataset.url,
+ url: cancelJobsButton.dataset.url,
+ modalId: CANCEL_JOBS_MODAL_ID,
},
});
},
@@ -33,4 +35,28 @@ function initJobs() {
}
}
-initJobs();
+export function initAdminJobsApp() {
+ const containerEl = document.getElementById('admin-jobs-app');
+
+ if (!containerEl) return false;
+
+ const { jobStatuses, emptyStateSvgPath, url } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ provide: {
+ url,
+ emptyStateSvgPath,
+ jobStatuses: JSON.parse(jobStatuses),
+ },
+ render(createElement) {
+ return createElement(AdminJobsTableApp);
+ },
+ });
+}
+
+if (gon.features.adminJobsVue) {
+ initAdminJobsApp();
+} else {
+ initJobs();
+}
diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
index c75c031b0b1..fa8f78839f3 100644
--- a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
+++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
@@ -1,34 +1,31 @@
<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlSearchBoxByType,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import Api from '~/api';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
export default {
i18n: {
- dropdownHeader: __('Namespaces'),
+ headerText: __('Namespaces'),
searchPlaceholder: __('Search for Namespace'),
- anyNamespace: __('Any namespace'),
+ reset: __('Clear'),
},
components: {
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
- GlLoadingIcon,
- GlSearchBoxByType,
+ GlCollapsibleListbox,
},
props: {
- showAny: {
- type: Boolean,
+ origSelectedId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ origSelectedText: {
+ type: String,
required: false,
- default: false,
+ default: '',
},
- placeholder: {
+ toggleTextPlaceholder: {
type: String,
required: false,
default: __('Namespace'),
@@ -42,56 +39,72 @@ export default {
data() {
return {
namespaceOptions: [],
- selectedNamespaceId: null,
- selectedNamespace: null,
+ selectedNamespaceId: this.origSelectedId,
+ selectedNamespaceText: this.origSelectedText,
searchTerm: '',
isLoading: false,
};
},
computed: {
- selectedNamespaceName() {
- if (this.selectedNamespaceId === null) {
- return this.placeholder;
- }
- return this.selectedNamespace;
+ toggleText() {
+ return this.selectedNamespaceText || this.toggleTextPlaceholder;
},
},
watch: {
- searchTerm() {
- this.fetchNamespaces(this.searchTerm);
+ selectedNamespaceId(val) {
+ if (!val) {
+ this.selectedNamespaceText = null;
+ }
+
+ this.selectedNamespaceText = this.namespaceOptions.find(({ value }) => value === val)?.text;
},
},
mounted() {
this.fetchNamespaces();
},
methods: {
- fetchNamespaces(filter) {
+ fetchNamespaces() {
this.isLoading = true;
this.namespaceOptions = [];
- return Api.namespaces(filter, (namespaces) => {
- this.namespaceOptions = namespaces;
+
+ return Api.namespaces(this.searchTerm, (namespaces) => {
+ this.namespaceOptions = this.formatNamespaceOptions(namespaces);
this.isLoading = false;
});
},
- selectNamespace(key) {
- this.selectedNamespaceId = this.namespaceOptions[key].id;
- this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]);
- this.$emit('setNamespace', this.selectedNamespaceId);
+ formatNamespaceOptions(namespaces) {
+ if (!namespaces) {
+ return [];
+ }
+
+ return namespaces.map((namespace) => {
+ return {
+ value: String(namespace.id),
+ text: this.getNamespaceString(namespace),
+ };
+ });
},
- selectAnyNamespace() {
- this.selectedNamespaceId = null;
- this.selectedNamespace = null;
- this.$emit('setNamespace', null);
+ selectNamespace(value) {
+ this.selectedNamespaceId = value;
+ this.$emit('setNamespace', this.selectedNamespaceId);
},
getNamespaceString(namespace) {
return `${namespace.kind}: ${namespace.full_path}`;
},
+ search: debounce(function debouncedSearch(searchQuery) {
+ this.searchTerm = searchQuery?.trim();
+ this.fetchNamespaces();
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ onReset() {
+ this.selectedNamespaceId = null;
+ this.$emit('setNamespace', null);
+ },
},
};
</script>
<template>
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-w-full">
<input
v-if="fieldName"
:name="fieldName"
@@ -99,45 +112,19 @@ export default {
type="hidden"
data-testid="hidden-input"
/>
- <gl-dropdown
- :text="selectedNamespaceName"
- :header-text="$options.i18n.dropdownHeader"
- toggle-class="dropdown-menu-toggle large"
- data-testid="namespace-dropdown"
- :right="true"
- >
- <template #header>
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- class="namespace-search-box"
- debounce="250"
- :placeholder="$options.i18n.searchPlaceholder"
- />
- </template>
-
- <template v-if="showAny">
- <gl-dropdown-item @click="selectAnyNamespace">
- {{ $options.i18n.anyNamespace }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
- </template>
-
- <gl-loading-icon v-if="isLoading" />
-
- <gl-dropdown-item
- v-for="(namespace, key) in namespaceOptions"
- :key="namespace.id"
- @click="selectNamespace(key)"
- >
- {{ getNamespaceString(namespace) }}
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :items="namespaceOptions"
+ :header-text="$options.i18n.headerText"
+ :reset-button-label="$options.i18n.reset"
+ :toggle-text="toggleText"
+ :search-placeholder="$options.i18n.searchPlaceholder"
+ :searching="isLoading"
+ :selected="selectedNamespaceId"
+ toggle-class="gl-w-full gl-flex-direction-column gl-align-items-stretch!"
+ searchable
+ @reset="onReset"
+ @search="search"
+ @select="selectNamespace"
+ />
</div>
</template>
-
-<style scoped>
-/* workaround position: relative imposed by .top-area .nav-controls */
-.namespace-search-box >>> input {
- position: static;
-}
-</style>
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
index 3098d06510b..49ee89de772 100644
--- a/app/assets/javascripts/pages/admin/projects/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import ProjectsList from '~/projects_list';
import NamespaceSelect from './components/namespace_select.vue';
@@ -12,16 +11,17 @@ function mountNamespaceSelect() {
return false;
}
- const { showAny, fieldName, placeholder, updateLocation } = el.dataset;
+ const { fieldName, toggleTextPlaceholder, selectedId, selectedText, updateLocation } = el.dataset;
return new Vue({
el,
render(createComponent) {
return createComponent(NamespaceSelect, {
props: {
- showAny: parseBoolean(showAny),
fieldName,
- placeholder,
+ toggleTextPlaceholder,
+ origSelectedId: selectedId,
+ origSelectedText: selectedText,
},
on: {
setNamespace(newNamespace) {
diff --git a/app/assets/javascripts/pages/admin/runners/new/index.js b/app/assets/javascripts/pages/admin/runners/new/index.js
new file mode 100644
index 00000000000..5048ad7b57a
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/new/index.js
@@ -0,0 +1,3 @@
+import { initAdminNewRunner } from '~/ci/runner/admin_new_runner';
+
+initAdminNewRunner();
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
index 08c247a498b..2ca11e96f69 100644
--- a/app/assets/javascripts/pages/dashboard/issues/index.js
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -3,7 +3,7 @@ import { mountIssuesDashboardApp } from '~/issues/dashboard';
import initManualOrdering from '~/issues/manual_ordering';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import projectSelect from '~/project_select';
+import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown';
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
@@ -11,7 +11,7 @@ initFilteredSearch({
useDefaultState: true,
});
-projectSelect();
+initNewResourceDropdown();
initManualOrdering();
mountIssuesDashboardApp();
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index 1350837476b..a8c59ea6f3d 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -2,7 +2,9 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import projectSelect from '~/project_select';
+import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown';
+import { RESOURCE_TYPE_MERGE_REQUEST } from '~/vue_shared/components/new_resource_dropdown/constants';
+import searchUserProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql';
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys, true);
@@ -12,4 +14,7 @@ initFilteredSearch({
useDefaultState: true,
});
-projectSelect();
+initNewResourceDropdown({
+ resourceType: RESOURCE_TYPE_MERGE_REQUEST,
+ query: searchUserProjectsWithMergeRequestsEnabled,
+});
diff --git a/app/assets/javascripts/pages/dashboard/milestones/index/index.js b/app/assets/javascripts/pages/dashboard/milestones/index/index.js
index b526fce6f7b..88061d9ca22 100644
--- a/app/assets/javascripts/pages/dashboard/milestones/index/index.js
+++ b/app/assets/javascripts/pages/dashboard/milestones/index/index.js
@@ -1,3 +1,12 @@
-import projectSelect from '~/project_select';
+import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown';
+import { RESOURCE_TYPE_MILESTONE } from '~/vue_shared/components/new_resource_dropdown/constants';
+import searchUserGroupsAndProjects from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql';
-projectSelect();
+initNewResourceDropdown({
+ resourceType: RESOURCE_TYPE_MILESTONE,
+ query: searchUserGroupsAndProjects,
+ extractProjects: (data) => [
+ ...(data?.user?.groups?.nodes ?? []),
+ ...(data?.projects?.nodes ?? []),
+ ],
+});
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index c5d62ae5daf..2fdf3c42935 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -133,10 +133,10 @@ export default class Todos {
restoreBtn.classList.add('hidden');
doneBtn.classList.remove('hidden');
} else if (target === doneBtn) {
- row.classList.add('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100');
+ row.classList.add('done-reversible', 'gl-bg-gray-10', 'gl-border-gray-50');
restoreBtn.classList.remove('hidden');
} else if (target === restoreBtn) {
- row.classList.remove('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100');
+ row.classList.remove('done-reversible', 'gl-bg-gray-10', 'gl-border-gray-50');
doneBtn.classList.remove('hidden');
} else {
row.parentNode.removeChild(row);
@@ -147,17 +147,17 @@ export default class Todos {
e.stopPropagation();
e.preventDefault();
- const target = e.currentTarget;
- target.setAttribute('disabled', true);
- target.classList.add('disabled');
+ const { currentTarget } = e;
+ currentTarget.setAttribute('disabled', true);
+ currentTarget.classList.add('disabled');
- target.querySelector('.gl-spinner-container').classList.add('gl-mr-2');
+ currentTarget.querySelector('.gl-spinner-container').classList.add('gl-mr-2');
- axios[target.dataset.method](target.dataset.href, {
+ axios[currentTarget.dataset.method](currentTarget.href, {
ids: this.todo_ids,
})
.then(({ data }) => {
- this.updateAllState(target, data);
+ this.updateAllState(currentTarget, data);
this.updateBadges(data);
})
.catch(() =>
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index fb685247bd4..dec06fe6f4d 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -2,10 +2,10 @@ import { GROUP_BADGE } from '~/badges/constants';
import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import initTransferGroupForm from '~/groups/init_transfer_group_form';
-import { initGroupSelects } from '~/vue_shared/components/group_select/init_group_selects';
+import { initGroupSelects } from '~/vue_shared/components/entity_select/init_group_selects';
+import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects';
import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
-import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import initConfirmDanger from '~/init_confirm_danger';
@@ -22,7 +22,8 @@ mountBadgeSettings(GROUP_BADGE);
// Initialize Subgroups selector
initGroupSelects();
-projectSelect();
+// Initialize project selectors
+initProjectSelects();
initSearchSettings();
initCascadingSettingsLockPopovers();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index ceda2c8fa17..1b3c7ba5a52 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -12,7 +12,6 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
const APP_OPTIONS = {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'activity']),
- tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: [
'account',
'granted',
@@ -32,10 +31,6 @@ const APP_OPTIONS = {
},
[MEMBER_TYPES.group]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
- tableAttrs: {
- table: { 'data-qa-selector': 'groups_list' },
- tr: { 'data-qa-selector': 'group_row' },
- },
requestFormatter: groupLinkRequestFormatter,
filteredSearchBar: {
show: true,
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index bf0147ca885..2cf75fcf666 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -3,7 +3,9 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { initBulkUpdateSidebar } from '~/issuable';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import projectSelect from '~/project_select';
+import { initNewResourceDropdown } from '~/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown';
+import { RESOURCE_TYPE_MERGE_REQUEST } from '~/vue_shared/components/new_resource_dropdown/constants';
+import searchUserGroupProjectsWithMergeRequestsEnabled from '~/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql';
const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_';
@@ -16,4 +18,8 @@ initFilteredSearch({
useDefaultState: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
-projectSelect();
+initNewResourceDropdown({
+ resourceType: RESOURCE_TYPE_MERGE_REQUEST,
+ query: searchUserGroupProjectsWithMergeRequestsEnabled,
+ extractProjects: (data) => data?.group?.projects?.nodes,
+});
diff --git a/app/assets/javascripts/pages/groups/usage_quotas/index.js b/app/assets/javascripts/pages/groups/usage_quotas/index.js
new file mode 100644
index 00000000000..dab2d0b17d2
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/usage_quotas/index.js
@@ -0,0 +1,3 @@
+import initUsageQuotas from '~/usage_quotas';
+
+initUsageQuotas();
diff --git a/app/assets/javascripts/pages/profiles/saved_replies/index.js b/app/assets/javascripts/pages/profiles/saved_replies/index.js
new file mode 100644
index 00000000000..ef227b82172
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/saved_replies/index.js
@@ -0,0 +1,3 @@
+import { initSavedReplies } from '~/saved_replies';
+
+initSavedReplies();
diff --git a/app/assets/javascripts/pages/projects/airflow/dags/index/index.js b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js
new file mode 100644
index 00000000000..1d7cf4a5b8e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/airflow/dags/index/index.js
@@ -0,0 +1,27 @@
+import Vue from 'vue';
+import AirflowDags from '~/airflow/dags/components/dags.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+const initShowDags = () => {
+ const element = document.querySelector('#js-show-airflow-dags');
+ if (!element) {
+ return null;
+ }
+
+ const dags = JSON.parse(element.dataset.dags);
+ const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination));
+
+ return new Vue({
+ el: element,
+ render(h) {
+ return h(AirflowDags, {
+ props: {
+ dags,
+ pagination,
+ },
+ });
+ },
+ });
+};
+
+initShowDags();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 46704d96552..667fd89af55 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -16,6 +16,7 @@ import syntaxHighlight from '~/syntax_highlight';
import ZenMode from '~/zen_mode';
import '~/sourcegraph/load';
import DiffStats from '~/diffs/components/diff_stats.vue';
+import { initReportAbuse } from '~/projects/report_abuse';
const hasPerfBar = document.querySelector('.with-performance-bar');
const performanceHeight = hasPerfBar ? 35 : 0;
@@ -26,6 +27,7 @@ new ShortcutsNavigation();
initCommitBoxInfo();
initDeprecatedNotes();
+initReportAbuse();
const loadDiffStats = () => {
const diffStatsElements = document.querySelectorAll('#js-diff-stats');
@@ -67,6 +69,7 @@ if (filesContainer.length) {
handleLocationHash();
new Diff();
loadDiffStats();
+ initReportAbuse();
})
.catch(() => {
createAlert({ message: __('An error occurred while retrieving diff files') });
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index c0eb2a8fd77..82035008459 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -10,6 +10,8 @@ import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
import UserCallout from '~/user_callout';
import initTopicsTokenSelector from '~/projects/settings/topics';
+import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects';
+import initPruneObjectsButton from '~/projects/prune_objects_button';
import initProjectPermissionsSettings from '../shared/permissions';
import initProjectLoadingSpinner from '../shared/save_project_loader';
@@ -17,6 +19,7 @@ initFilePickers();
initConfirmDanger();
initSettingsPanels();
initProjectDeleteButton();
+initPruneObjectsButton();
mountBadgeSettings(PROJECT_BADGE);
new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new
@@ -30,3 +33,4 @@ dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-
initSearchSettings();
initTopicsTokenSelector();
+initProjectSelects();
diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js
new file mode 100644
index 00000000000..9a3bb25de70
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/index.js
@@ -0,0 +1,38 @@
+import Vue from 'vue';
+import { s__ } from '~/locale';
+import Translate from '~/vue_shared/translate';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { generateRefDestinationPath } from './ref_switcher_utils';
+
+Vue.use(Translate);
+
+const REF_SWITCH_HEADER = s__('FindFile|Switch branch/tag');
+
+export default () => {
+ const el = document.getElementById('js-blob-ref-switcher');
+ if (!el) return false;
+
+ const { projectId, ref, namespace } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ translations: {
+ dropdownHeader: REF_SWITCH_HEADER,
+ searchPlaceholder: REF_SWITCH_HEADER,
+ },
+ },
+ on: {
+ input(selected) {
+ visitUrl(generateRefDestinationPath(selected, namespace));
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js
new file mode 100644
index 00000000000..5fecd024f1a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/find_file/ref_switcher/ref_switcher_utils.js
@@ -0,0 +1,28 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
+/**
+ * Generates a ref destination url based on the selected ref and current url.
+ * @param {string} selectedRef - The selected ref from the ref dropdown.
+ * @param {string} namespace - The destination namespace for the path.
+ */
+export function generateRefDestinationPath(selectedRef, namespace) {
+ if (!selectedRef || !namespace) {
+ return window.location.href;
+ }
+
+ const { pathname } = window.location;
+ const encodedHash = '%23';
+
+ const [projectRootPath] = pathname.split(namespace);
+
+ const destinationPath = joinPaths(
+ projectRootPath,
+ namespace,
+ encodeURI(selectedRef).replace(/#/g, encodedHash),
+ );
+
+ const newURL = new URL(window.location);
+ newURL.pathname = destinationPath;
+
+ return newURL.href;
+}
diff --git a/app/assets/javascripts/pages/projects/find_file/show/index.js b/app/assets/javascripts/pages/projects/find_file/show/index.js
index f47888f0cb8..e207df2434b 100644
--- a/app/assets/javascripts/pages/projects/find_file/show/index.js
+++ b/app/assets/javascripts/pages/projects/find_file/show/index.js
@@ -1,7 +1,9 @@
import $ from 'jquery';
import ShortcutsFindFile from '~/behaviors/shortcuts/shortcuts_find_file';
import ProjectFindFile from '~/projects/project_find_file';
+import InitBlobRefSwitcher from '../ref_switcher';
+InitBlobRefSwitcher();
const findElement = document.querySelector('.js-file-finder');
const projectFindFile = new ProjectFindFile($('.file-finder-holder'), {
url: findElement.dataset.fileFindUrl,
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 2028af8b8f0..85fe3477d7c 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
@@ -16,7 +16,7 @@ import { createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
import {
VISIBILITY_LEVEL_PRIVATE_STRING,
@@ -25,8 +25,24 @@ import {
VISIBILITY_LEVELS_STRING_TO_INTEGER,
VISIBILITY_LEVELS_INTEGER_TO_STRING,
} from '~/visibility_level/constants';
+import { START_RULE, CONTAINS_RULE } from '~/projects/project_name_rules';
import ProjectNamespace from './project_namespace.vue';
+const feedbackMap = {
+ valueMissing: {
+ isInvalid: (el) => el.validity?.valueMissing,
+ message: __('Please fill out this field.'),
+ },
+ nameStartPattern: {
+ isInvalid: (el) => el.validity?.patternMismatch && !START_RULE.reg.test(el.value),
+ message: START_RULE.msg,
+ },
+ nameContainsPattern: {
+ isInvalid: (el) => el.validity?.patternMismatch && !CONTAINS_RULE.reg.test(el.value),
+ message: CONTAINS_RULE.msg,
+ },
+};
+
const initFormField = ({ value, required = true, skipValidation = false }) => ({
value,
required,
@@ -48,7 +64,7 @@ export default {
ProjectNamespace,
},
directives: {
- validation: validation(),
+ validation: validation(feedbackMap),
},
inject: {
newGroupPath: {
@@ -109,6 +125,15 @@ export default {
};
},
computed: {
+ projectNameDescription() {
+ if (this.form.fields.name.state === false) {
+ return null;
+ }
+
+ return s__(
+ 'ProjectsNew|Must start with a lowercase or uppercase letter, digit, emoji, or underscore. Can also contain dots, pluses, dashes, or spaces.',
+ );
+ },
projectVisibilityLevel() {
return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility];
},
@@ -248,6 +273,7 @@ export default {
},
},
csrf,
+ projectNamePattern: `(${START_RULE.reg.source})|(${CONTAINS_RULE.reg.source})`,
};
</script>
@@ -257,8 +283,10 @@ export default {
<gl-form-group
:label="__('Project name')"
+ :description="projectNameDescription"
label-for="fork-name"
:invalid-feedback="form.fields.name.feedback"
+ data-testid="fork-name-form-group"
>
<gl-form-input
id="fork-name"
@@ -268,6 +296,7 @@ export default {
data-testid="fork-name-input"
:state="form.fields.name.state"
required
+ :pattern="$options.projectNamePattern"
/>
</gl-form-group>
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 65e7f48ed24..10c794c9ba2 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -2,6 +2,9 @@ import { GlColumnChart } from '@gitlab/ui/dist/charts';
import Vue from 'vue';
import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
import { __ } from '~/locale';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
+import RefSelector from '~/ref/components/ref_selector.vue';
import CodeCoverage from '../components/code_coverage.vue';
import SeriesDataMixin from './series_data_mixin';
@@ -13,6 +16,7 @@ waitForCSSLoaded(() => {
const monthContainer = document.getElementById('js-month-chart');
const weekdayContainer = document.getElementById('js-weekday-chart');
const hourContainer = document.getElementById('js-hour-chart');
+ const branchSelector = document.getElementById('js-project-graph-ref-switcher');
const LANGUAGE_CHART_HEIGHT = 300;
const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => {
if (firstDayOfWeek === 0) {
@@ -173,4 +177,38 @@ waitForCSSLoaded(() => {
});
},
});
+
+ const { projectId, projectBranch, graphPath } = branchSelector.dataset;
+
+ const GRAPHS_PATH_REGEX = /^(.*?)\/-\/graphs/g;
+ const graphsPathPrefix = graphPath.match(GRAPHS_PATH_REGEX)?.[0];
+ if (!graphsPathPrefix) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Path is not correct');
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: branchSelector,
+ name: 'RefSelector',
+ render(createComponent) {
+ return createComponent(RefSelector, {
+ props: {
+ enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS],
+ value: projectBranch,
+ translations: {
+ dropdownHeader: __('Switch branch/tag'),
+ searchPlaceholder: __('Search branches and tags'),
+ },
+ projectId,
+ },
+ class: 'gl-w-20',
+ on: {
+ input(selected) {
+ visitUrl(`${graphsPathPrefix}/${encodeURIComponent(selected)}/charts`);
+ },
+ },
+ });
+ },
+ });
});
diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js
index 9e559354205..f25547f9982 100644
--- a/app/assets/javascripts/pages/projects/hooks/index.js
+++ b/app/assets/javascripts/pages/projects/hooks/index.js
@@ -1,7 +1,8 @@
import initSearchSettings from '~/search_settings';
-import initWebhookForm from '~/webhooks';
+import initWebhookForm, { initHookTestDropdowns } from '~/webhooks';
import { initPushEventsEditForm } from '~/webhooks/webhook';
initSearchSettings();
initWebhookForm();
initPushEventsEditForm();
+initHookTestDropdowns();
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 37cf345fe77..1075241e172 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,7 +1,5 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import initTerraformNotification from '~/projects/terraform_notification';
import Project from './project';
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
-initTerraformNotification();
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue
deleted file mode 100644
index 693dc6a15ad..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/included_in_trial_indicator.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-<script>
-import { s__ } from '~/locale';
-
-export default {
- name: 'IncludedInTrialIndicator',
- i18n: {
- trialOnly: s__('LearnGitlab|- Included in trial'),
- },
-};
-</script>
-<template>
- <span class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
- {{ $options.i18n.trialOnly }}
- </span>
-</template>
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
deleted file mode 100644
index 54e15b6552c..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<script>
-import { GlProgressBar, GlSprintf, GlAlert } from '@gitlab/ui';
-import eventHub from '~/invite_members/event_hub';
-import { s__ } from '~/locale';
-import { getCookie, removeCookie, parseBoolean } from '~/lib/utils/common_utils';
-import { ACTION_LABELS, ACTION_SECTIONS, INVITE_MODAL_OPEN_COOKIE } from '../constants';
-import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
-
-export default {
- components: { GlProgressBar, GlSprintf, GlAlert, LearnGitlabSectionCard },
- i18n: {
- title: s__('LearnGitLab|Learn GitLab'),
- description: s__(
- 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
- ),
- percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
- successfulInvitations: s__(
- "LearnGitLab|Your team is growing! You've successfully invited new team members to the %{projectName} project.",
- ),
- },
- props: {
- actions: {
- required: true,
- type: Object,
- },
- sections: {
- required: true,
- type: Object,
- },
- project: {
- required: true,
- type: Object,
- },
- },
- data() {
- return {
- showSuccessfulInvitationsAlert: false,
- actionsData: this.actions,
- };
- },
- actionSections: Object.keys(ACTION_SECTIONS),
- computed: {
- maxValue() {
- return Object.keys(this.actionsData).length;
- },
- progressValue() {
- return Object.values(this.actionsData).filter((a) => a.completed).length;
- },
- progressPercentage() {
- return Math.round((this.progressValue / this.maxValue) * 100);
- },
- },
- mounted() {
- if (this.getCookieForInviteMembers()) {
- this.openInviteMembersModal('celebrate');
- }
-
- eventHub.$on('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
- },
- beforeDestroy() {
- eventHub.$off('showSuccessfulInvitationsAlert', this.handleShowSuccessfulInvitationsAlert);
- },
- methods: {
- getCookieForInviteMembers() {
- const value = parseBoolean(getCookie(INVITE_MODAL_OPEN_COOKIE));
-
- removeCookie(INVITE_MODAL_OPEN_COOKIE);
-
- return value;
- },
- openInviteMembersModal(mode) {
- eventHub.$emit('openModal', { mode, source: 'learn-gitlab' });
- },
- handleShowSuccessfulInvitationsAlert() {
- this.showSuccessfulInvitationsAlert = true;
- this.markActionAsCompleted('userAdded');
- },
- actionsFor(section) {
- const actions = Object.fromEntries(
- Object.entries(this.actionsData).filter(
- ([action]) => ACTION_LABELS[action].section === section,
- ),
- );
- return actions;
- },
- svgFor(section) {
- return this.sections[section].svg;
- },
- markActionAsCompleted(completedAction) {
- Object.keys(this.actionsData).forEach((action) => {
- if (action === completedAction) {
- this.actionsData[action].completed = true;
- this.modifySidebarPercentage();
- }
- });
- },
- modifySidebarPercentage() {
- const el = document.querySelector('.sidebar-top-level-items .active .count');
- el.textContent = `${this.progressPercentage}%`;
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-alert
- v-if="showSuccessfulInvitationsAlert"
- class="gl-mt-5"
- @dismiss="showSuccessfulInvitationsAlert = false"
- >
- <gl-sprintf :message="$options.i18n.successfulInvitations">
- <template #projectName>
- <strong>{{ project.name }}</strong>
- </template>
- </gl-sprintf>
- </gl-alert>
- <div class="row">
- <div class="gl-mb-7 gl-ml-5">
- <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
- <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p>
- </div>
- </div>
- <div class="gl-mb-3">
- <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage">
- <gl-sprintf :message="$options.i18n.percentageCompleted">
- <template #percentage>{{ progressPercentage }}</template>
- <template #percentSymbol>%</template>
- </gl-sprintf>
- </p>
- <gl-progress-bar :value="progressValue" :max="maxValue" />
- </div>
- <div class="row">
- <div
- v-for="section in $options.actionSections"
- :key="section"
- class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4"
- >
- <learn-gitlab-section-card
- :section="section"
- :svg="svgFor(section)"
- :actions="actionsFor(section)"
- />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
deleted file mode 100644
index e8f0e6c47ee..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import { GlCard } from '@gitlab/ui';
-import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
-
-import LearnGitlabSectionLink from './learn_gitlab_section_link.vue';
-
-export default {
- name: 'LearnGitlabSectionCard',
- components: { GlCard, LearnGitlabSectionLink },
- i18n: {
- ...ACTION_SECTIONS,
- },
- props: {
- section: {
- required: true,
- type: String,
- },
- svg: {
- required: true,
- type: String,
- },
- actions: {
- required: true,
- type: Object,
- },
- },
- computed: {
- sortedActions() {
- return Object.entries(this.actions).sort(
- (a1, a2) => ACTION_LABELS[a1[0]].position - ACTION_LABELS[a2[0]].position,
- );
- },
- },
-};
-</script>
-<template>
- <gl-card
- class="gl-pt-0 h-100"
- header-class="gl-bg-white gl-border-0 gl-pb-0"
- body-class="gl-pt-0"
- >
- <template #header>
- <img :src="svg" />
- <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2>
- <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p>
- </template>
- <template #default>
- <learn-gitlab-section-link
- v-for="[action, value] in sortedActions"
- :key="action"
- :action="action"
- :value="value"
- />
- </template>
- </gl-card>
-</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
deleted file mode 100644
index d9b0dbbb9b0..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ /dev/null
@@ -1,151 +0,0 @@
-<script>
-import { uniqueId } from 'lodash';
-import { GlLink, GlIcon, GlButton, GlPopover, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
-import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
-import { isExperimentVariant } from '~/experimentation/utils';
-import eventHub from '~/invite_members/event_hub';
-import { s__, __ } from '~/locale';
-import { ACTION_LABELS } from '../constants';
-import IncludedInTrialIndicator from './included_in_trial_indicator.vue';
-
-export default {
- name: 'LearnGitlabSectionLink',
- components: {
- GlLink,
- GlIcon,
- GlButton,
- GlPopover,
- GitlabExperiment,
- IncludedInTrialIndicator,
- },
- directives: {
- GlTooltip,
- },
- i18n: {
- contactAdmin: s__('LearnGitlab|Contact your administrator to enable this action.'),
- viewAdminList: s__('LearnGitlab|View administrator list'),
- watchHow: __('Watch how'),
- },
- props: {
- action: {
- required: true,
- type: String,
- },
- value: {
- required: true,
- type: Object,
- },
- },
- data() {
- return {
- popoverId: uniqueId('contact-admin-'),
- };
- },
- computed: {
- showInviteModalLink() {
- return (
- this.action === 'userAdded' && isExperimentVariant('invite_for_help_continuous_onboarding')
- );
- },
- openInNewTab() {
- return ACTION_LABELS[this.action]?.openInNewTab === true || this.value.openInNewTab === true;
- },
- popoverText() {
- return this.value.message || this.$options.i18n.contactAdmin;
- },
- },
- methods: {
- openModal() {
- eventHub.$emit('openModal', { source: 'learn_gitlab' });
- },
- actionLabelValue(value) {
- return ACTION_LABELS[this.action][value];
- },
- },
-};
-</script>
-<template>
- <div class="gl-mb-4">
- <div class="flex align-items-center">
- <span v-if="value.completed" class="gl-text-green-500">
- <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
- {{ actionLabelValue('title') }}
- <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
- </span>
- <div v-else-if="showInviteModalLink">
- <gl-link
- data-track-action="click_link"
- :data-track-label="actionLabelValue('trackLabel')"
- data-track-property="Growth::Activation::Experiment::InviteForHelpContinuousOnboarding"
- data-testid="invite-for-help-continuous-onboarding-experiment-link"
- @click="openModal"
- >{{ actionLabelValue('title') }}</gl-link
- >
-
- <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
- </div>
- <div v-else-if="value.enabled">
- <gl-link
- :target="openInNewTab ? '_blank' : '_self'"
- :href="value.url"
- data-testid="uncompleted-learn-gitlab-link"
- data-qa-selector="uncompleted_learn_gitlab_link"
- data-track-action="click_link"
- :data-track-label="actionLabelValue('trackLabel')"
- >{{ actionLabelValue('title') }}</gl-link
- >
-
- <included-in-trial-indicator v-if="actionLabelValue('trialRequired')" />
- </div>
- <template v-else>
- <div data-testid="disabled-learn-gitlab-link">{{ actionLabelValue('title') }}</div>
- <gl-button
- :id="popoverId"
- category="tertiary"
- icon="question-o"
- class="ml-auto"
- :aria-label="popoverText"
- size="small"
- data-testid="contact-admin-popover-trigger"
- />
- <gl-popover
- :target="popoverId"
- placement="top"
- triggers="hover focus"
- data-testid="contact-admin-popover"
- >
- <p>{{ popoverText }}</p>
- <gl-link
- :href="value.url"
- class="font-size-inherit"
- data-testid="view-administrator-link-text"
- >
- {{ $options.i18n.viewAdminList }}
- </gl-link>
- </gl-popover>
- </template>
- <gitlab-experiment name="video_tutorials_continuous_onboarding">
- <template #control></template>
- <template #candidate>
- <gl-button
- v-if="actionLabelValue('videoTutorial')"
- v-gl-tooltip
- category="tertiary"
- icon="live-preview"
- :title="$options.i18n.watchHow"
- :aria-label="$options.i18n.watchHow"
- :href="actionLabelValue('videoTutorial')"
- target="_blank"
- class="ml-auto"
- size="small"
- data-testid="video-tutorial-link"
- data-track-action="click_video_link"
- :data-track-label="actionLabelValue('trackLabel')"
- data-track-property="Growth::Conversion::Experiment::LearnGitLab"
- data-track-experiment="video_tutorials_continuous_onboarding"
- />
- </template>
- </gitlab-experiment>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
deleted file mode 100644
index cb1a0302d91..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { s__ } from '~/locale';
-
-export const ACTION_LABELS = {
- gitWrite: {
- title: s__('LearnGitLab|Create a repository'),
- actionLabel: s__('LearnGitLab|Create a repository'),
- description: s__('LearnGitLab|Create or import your first repository into your new project.'),
- trackLabel: 'create_a_repository',
- section: 'workspace',
- position: 1,
- },
- userAdded: {
- title: s__('LearnGitLab|Invite your colleagues'),
- actionLabel: s__('LearnGitLab|Invite your colleagues'),
- description: s__(
- 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
- ),
- trackLabel: 'invite_your_colleagues',
- section: 'workspace',
- position: 0,
- },
- pipelineCreated: {
- title: s__("LearnGitLab|Set up your first project's CI/CD"),
- actionLabel: s__('LearnGitLab|Set up CI/CD'),
- description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
- trackLabel: 'set_up_your_first_project_s_ci_cd',
- section: 'workspace',
- position: 2,
- },
- trialStarted: {
- title: s__('LearnGitLab|Start a free trial of GitLab Ultimate'),
- actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
- description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
- trackLabel: 'start_a_free_trial_of_gitlab_ultimate',
- section: 'workspace',
- position: 3,
- openInNewTab: true,
- },
- codeOwnersEnabled: {
- title: s__('LearnGitLab|Add code owners'),
- actionLabel: s__('LearnGitLab|Add code owners'),
- description: s__(
- 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
- ),
- trackLabel: 'add_code_owners',
- trialRequired: true,
- section: 'workspace',
- position: 4,
- openInNewTab: true,
- videoTutorial: 'https://vimeo.com/670896787',
- },
- requiredMrApprovalsEnabled: {
- title: s__('LearnGitLab|Enable require merge approvals'),
- actionLabel: s__('LearnGitLab|Enable require merge approvals'),
- description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
- trackLabel: 'enable_require_merge_approvals',
- trialRequired: true,
- section: 'workspace',
- position: 5,
- openInNewTab: true,
- videoTutorial: 'https://vimeo.com/670904904',
- },
- mergeRequestCreated: {
- title: s__('LearnGitLab|Submit a merge request (MR)'),
- actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
- description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
- trackLabel: 'submit_a_merge_request_mr',
- section: 'plan',
- position: 1,
- },
- issueCreated: {
- title: s__('LearnGitLab|Create an issue'),
- actionLabel: s__('LearnGitLab|Create an issue'),
- description: s__(
- 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.',
- ),
- trackLabel: 'create_an_issue',
- section: 'plan',
- position: 0,
- },
- securityScanEnabled: {
- title: s__('LearnGitLab|Run a Security scan using CI/CD'),
- actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
- description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
- trackLabel: 'run_a_security_scan_using_ci_cd',
- section: 'deploy',
- position: 1,
- },
- licenseScanningRun: {
- title: s__('LearnGitLab|Scan dependencies for licenses'),
- trackLabel: 'scan_dependencies_for_licenses',
- trialRequired: true,
- section: 'deploy',
- position: 2,
- },
- secureDependencyScanningRun: {
- title: s__('LearnGitLab|Scan dependencies for vulnerabilities'),
- trackLabel: 'scan_dependencies_for_vulnerabilities',
- trialRequired: true,
- section: 'deploy',
- position: 3,
- },
- secureDastRun: {
- title: s__('LearnGitLab|Analyze your application for vulnerabilities with DAST'),
- trackLabel: 'analyze_your_application_for_vulnerabilities_with_dast',
- trialRequired: true,
- section: 'deploy',
- position: 4,
- },
-};
-
-export const ACTION_SECTIONS = {
- workspace: {
- title: s__('LearnGitLab|Set up your workspace'),
- description: s__(
- "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:",
- ),
- },
- plan: {
- title: s__('LearnGitLab|Plan and execute'),
- description: s__(
- 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:',
- ),
- },
- deploy: {
- title: s__('LearnGitLab|Deploy'),
- description: s__(
- 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:',
- ),
- },
-};
-
-export const INVITE_MODAL_OPEN_COOKIE = 'confetti_post_signup';
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
deleted file mode 100644
index af4a6f8a0c9..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Vue from 'vue';
-import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
-import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import LearnGitlab from '../components/learn_gitlab.vue';
-
-function initLearnGitlab() {
- const el = document.getElementById('js-learn-gitlab-app');
-
- if (!el) {
- return false;
- }
-
- const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
- const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
- const project = convertObjectPropsToCamelCase(JSON.parse(el.dataset.project));
-
- return new Vue({
- el,
- render(createElement) {
- return createElement(LearnGitlab, {
- props: { actions, sections, project },
- });
- },
- });
-}
-
-initInviteMembersModal();
-initInviteMembersTrigger();
-
-initLearnGitlab();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
deleted file mode 100644
index 653f903c6d1..00000000000
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import $ from 'jquery';
-import axios from '~/lib/utils/axios_utils';
-import { localTimeAgo } from '~/lib/utils/datetime_utility';
-import initCompareAutocomplete from './compare_autocomplete';
-import initTargetProjectDropdown from './target_project_dropdown';
-
-const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, params) => {
- $emptyState.hide();
- $loadingIndicator.show();
- $commitList.empty();
-
- return axios
- .get(url, {
- params,
- })
- .then(({ data }) => {
- $loadingIndicator.hide();
- $commitList.html(data);
- localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago'));
-
- if (!data) {
- $emptyState.show();
- }
- });
-};
-
-export default (mrNewCompareNode) => {
- const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset;
-
- if (!window.gon?.features?.mrCompareDropdowns) {
- initTargetProjectDropdown();
- }
-
- const updateSourceBranchCommitList = () =>
- updateCommitList(
- sourceBranchUrl,
- $(mrNewCompareNode).find('.js-source-commit-empty'),
- $(mrNewCompareNode).find('.js-source-loading'),
- $(mrNewCompareNode).find('.mr_source_commit'),
- {
- ref: $(mrNewCompareNode).find("input[name='merge_request[source_branch]']").val(),
- },
- );
- const updateTargetBranchCommitList = () =>
- updateCommitList(
- targetBranchUrl,
- $(mrNewCompareNode).find('.js-target-commit-empty'),
- $(mrNewCompareNode).find('.js-target-loading'),
- $(mrNewCompareNode).find('.mr_target_commit'),
- {
- target_project_id: $(mrNewCompareNode)
- .find("input[name='merge_request[target_project_id]']")
- .val(),
- ref: $(mrNewCompareNode).find("input[name='merge_request[target_branch]']").val(),
- },
- );
- initCompareAutocomplete('branches', ($dropdown) => {
- if ($dropdown.is('.js-target-branch')) {
- updateTargetBranchCommitList();
- } else if ($dropdown.is('.js-source-branch')) {
- updateSourceBranchCommitList();
- }
- });
- updateSourceBranchCommitList();
- updateTargetBranchCommitList();
-};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
deleted file mode 100644
index 65942464e2b..00000000000
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
+++ /dev/null
@@ -1,91 +0,0 @@
-/* eslint-disable func-names */
-
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-import { __ } from '~/locale';
-import { fixTitle } from '~/tooltips';
-
-export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) {
- $('.js-compare-dropdown').each(function () {
- const $dropdown = $(this);
- const selected = $dropdown.data('selected');
- const defaultText = $dropdown.data('defaultText').trim();
- const $dropdownContainer = $dropdown.closest('.dropdown');
- const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer);
- const $filterInput = $('input[type="search"]', $dropdownContainer);
- initDeprecatedJQueryDropdown($dropdown, {
- data(term, callback) {
- const params = {
- ref: $dropdown.data('ref'),
- search: term,
- };
-
- if (limitTo) {
- params.find = limitTo;
- }
-
- axios
- .get($dropdown.data('refsUrl'), {
- params,
- })
- .then(({ data }) => {
- if (limitTo) {
- callback(data[capitalizeFirstCharacter(limitTo)] || []);
- } else {
- callback(data);
- }
- })
- .catch(() =>
- createAlert({
- message: __('Error fetching refs'),
- }),
- );
- },
- selectable: true,
- filterable: true,
- filterRemote: Boolean($dropdown.data('refsUrl')),
- fieldName: $dropdown.data('fieldName'),
- filterInput: 'input[type="search"]',
- renderRow(ref) {
- const link = $('<a />')
- .attr('href', '#')
- .addClass(ref === selected ? 'is-active' : '')
- .text(ref)
- .attr('data-ref', ref);
- if (ref.header != null) {
- return $('<li />').addClass('dropdown-header').text(ref.header);
- }
- return $('<li />').append(link);
- },
- id(obj, $el) {
- return $el.attr('data-ref');
- },
- toggleLabel(obj, $el) {
- if ($el.hasClass('is-active')) {
- return $el.text().trim();
- }
-
- return defaultText;
- },
- clicked: () => clickHandler($dropdown),
- });
- $filterInput.on('keyup', (e) => {
- const keyCode = e.keyCode || e.which;
- if (keyCode !== 13) return;
- const text = $filterInput.val();
- $fieldInput.val(text);
- $('.dropdown-toggle-text', $dropdown).text(text);
- $dropdownContainer.removeClass('open');
- });
-
- $dropdownContainer.on('click', '.dropdown-content a', (e) => {
- $dropdown.prop('title', e.target.text.replace(/_+?/g, '-'));
- if ($dropdown.hasClass('has-tooltip')) {
- fixTitle($dropdown);
- }
- });
- });
-}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
index b3868653d6a..2718765ee23 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js
@@ -1,35 +1,78 @@
-import $ from 'jquery';
import Vue from 'vue';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import MergeRequest from '~/merge_request';
-import TargetProjectDropdown from '~/merge_requests/components/target_project_dropdown.vue';
-import initCompare from './compare';
+import CompareApp from '~/merge_requests/components/compare_app.vue';
+import { __ } from '~/locale';
const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare');
if (mrNewCompareNode) {
- initCompare(mrNewCompareNode);
-
- const el = document.getElementById('js-target-project-dropdown');
- const { targetProjectsPath, currentProject } = el.dataset;
+ const targetCompareEl = document.getElementById('js-target-project-dropdown');
+ const sourceCompareEl = document.getElementById('js-source-project-dropdown');
+ const compareEl = document.querySelector('.js-merge-request-new-compare');
// eslint-disable-next-line no-new
new Vue({
- el,
- name: 'TargetProjectDropdown',
+ el: sourceCompareEl,
+ name: 'SourceCompareApp',
provide: {
- targetProjectsPath,
- currentProject: JSON.parse(currentProject),
+ currentProject: JSON.parse(sourceCompareEl.dataset.currentProject),
+ currentBranch: JSON.parse(sourceCompareEl.dataset.currentBranch),
+ branchCommitPath: compareEl.dataset.sourceBranchUrl,
+ inputs: {
+ project: {
+ id: 'merge_request_source_project_id',
+ name: 'merge_request[source_project_id]',
+ },
+ branch: {
+ id: 'merge_request_source_branch',
+ name: 'merge_request[source_branch]',
+ },
+ },
+ i18n: {
+ projectHeaderText: __('Select source project'),
+ branchHeaderText: __('Select source branch'),
+ },
+ toggleClass: {
+ project: 'js-source-project',
+ branch: 'js-source-branch gl-font-monospace',
+ },
+ branchQaSelector: 'source_branch_dropdown',
},
render(h) {
- return h(TargetProjectDropdown, {
- on: {
- 'project-selected': function projectSelectedFunction(refsUrl) {
- const $targetBranchDropdown = $('.js-target-branch');
- $targetBranchDropdown.data('refsUrl', refsUrl);
- $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu();
- },
+ return h(CompareApp);
+ },
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: targetCompareEl,
+ name: 'TargetCompareApp',
+ provide: {
+ currentProject: JSON.parse(targetCompareEl.dataset.currentProject),
+ currentBranch: JSON.parse(targetCompareEl.dataset.currentBranch),
+ projectsPath: targetCompareEl.dataset.targetProjectsPath,
+ branchCommitPath: compareEl.dataset.targetBranchUrl,
+ inputs: {
+ project: {
+ id: 'merge_request_target_project_id',
+ name: 'merge_request[target_project_id]',
},
- });
+ branch: {
+ id: 'merge_request_target_branch',
+ name: 'merge_request[target_branch]',
+ },
+ },
+ i18n: {
+ projectHeaderText: __('Select target project'),
+ branchHeaderText: __('Select target branch'),
+ },
+ toggleClass: {
+ project: 'js-target-project',
+ branch: 'js-target-branch gl-font-monospace',
+ },
+ },
+ render(h) {
+ return h(CompareApp);
},
});
} else {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js
deleted file mode 100644
index e9f0e008435..00000000000
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/target_project_dropdown.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-
-export default () => {
- const $targetProjectDropdown = $('.js-target-project');
- initDeprecatedJQueryDropdown($targetProjectDropdown, {
- selectable: true,
- fieldName: $targetProjectDropdown.data('fieldName'),
- filterable: true,
- id(obj, $el) {
- return $el.data('id');
- },
- toggleLabel(obj, $el) {
- return $el.text().trim();
- },
- clicked({ $el }) {
- $('.mr_target_commit').empty();
- const $targetBranchDropdown = $('.js-target-branch');
- $targetBranchDropdown.data('refsUrl', $el.data('refsUrl'));
- $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu();
- },
- });
-};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index b3a09cc0be3..af75c05b300 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -5,7 +5,6 @@ import { FILTERED_SEARCH } from '~/filtered_search/constants';
import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
import { ISSUABLE_INDEX } from '~/issuable/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import UsersSelect from '~/users_select';
initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST);
@@ -18,7 +17,6 @@ initFilteredSearch({
useDefaultState: true,
});
-new UsersSelect(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
initIssuableByEmail();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index f0a955e5360..91394755367 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,5 +1,5 @@
import initNotesApp from '~/mr_notes/init_notes';
-import { initReportAbuse } from '~/projects/merge_requests';
+import { initReportAbuse } from '~/projects/report_abuse';
import { initMrPage } from '../page';
initMrPage();
diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
index c1acef5ac13..fee6258eddc 100644
--- a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js
@@ -1,27 +1,4 @@
-import Vue from 'vue';
+import { initSimpleApp } from '~/helpers/init_simple_app_helper';
import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue';
-const initShowCandidate = () => {
- const element = document.querySelector('#js-show-ml-candidate');
- if (!element) {
- return;
- }
-
- const container = document.createElement('div');
- element.appendChild(container);
-
- const candidate = JSON.parse(element.dataset.candidate);
-
- // eslint-disable-next-line no-new
- new Vue({
- el: container,
- provide: {
- candidate,
- },
- render(h) {
- return h(MlCandidate);
- },
- });
-};
-
-initShowCandidate();
+initSimpleApp('#js-show-ml-candidate', MlCandidate);
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/index/index.js b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
new file mode 100644
index 00000000000..e9ffd4b528b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/ml/experiments/index/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import MlExperimentsIndex from '~/ml/experiment_tracking/routes/experiments/index';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+const initIndexMlExperiments = () => {
+ const element = document.querySelector('#js-project-ml-experiments-index');
+ if (!element) {
+ return undefined;
+ }
+
+ const props = {
+ experiments: JSON.parse(element.dataset.experiments),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)),
+ };
+
+ return new Vue({
+ el: element,
+ render(h) {
+ return h(MlExperimentsIndex, { props });
+ },
+ });
+};
+
+initIndexMlExperiments();
diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
index 6947b15dcbe..0e64d8c17db 100644
--- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
+++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js
@@ -14,7 +14,7 @@ const initShowExperiment = () => {
const candidates = JSON.parse(element.dataset.candidates);
const metricNames = JSON.parse(element.dataset.metrics);
const paramNames = JSON.parse(element.dataset.params);
- const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination));
+ const pageInfo = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo));
// eslint-disable-next-line no-new
new Vue({
@@ -23,7 +23,7 @@ const initShowExperiment = () => {
candidates,
metricNames,
paramNames,
- pagination,
+ pageInfo,
},
render(h) {
return h(MlExperiment);
diff --git a/app/assets/javascripts/pages/projects/network/show/index.js b/app/assets/javascripts/pages/projects/network/show/index.js
index 2dabcfadfab..414636f0a74 100644
--- a/app/assets/javascripts/pages/projects/network/show/index.js
+++ b/app/assets/javascripts/pages/projects/network/show/index.js
@@ -1,7 +1,39 @@
import $ from 'jquery';
+import Vue from 'vue';
+import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import ShortcutsNetwork from '~/behaviors/shortcuts/shortcuts_network';
+import RefSelector from '~/ref/components/ref_selector.vue';
import Network from '../network';
+const initRefSwitcher = () => {
+ const refSwitcherEl = document.getElementById('js-graph-ref-switcher');
+ const NETWORK_PATH_REGEX = /^(.*?)\/-\/network/g;
+
+ if (!refSwitcherEl) return false;
+
+ const { projectId, ref, networkPath } = refSwitcherEl.dataset;
+ const networkRootPath = networkPath.match(NETWORK_PATH_REGEX)?.[0]; // gets the network path without the ref
+
+ return new Vue({
+ el: refSwitcherEl,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ },
+ on: {
+ input(selectedRef) {
+ visitUrl(joinPaths(networkRootPath, selectedRef));
+ },
+ },
+ });
+ },
+ });
+};
+
+initRefSwitcher();
+
(() => {
if (!$('.network-graph').length) return;
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 4c9eb830ff6..5773737c41b 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -9,7 +9,6 @@ import axios from '~/lib/utils/axios_utils';
import { serializeForm } from '~/lib/utils/forms';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import projectSelect from '~/project_select';
const BRANCH_REF_TYPE = 'heads';
const TAG_REF_TYPE = 'tags';
@@ -44,13 +43,6 @@ export default class Project {
$(this).parents('.auto-devops-implicitly-enabled-banner').remove();
return e.preventDefault();
});
-
- Project.projectSelectDropdown();
- }
-
- static projectSelectDropdown() {
- projectSelect();
- $('.project-item-select').on('click', (e) => Project.changeProject($(e.currentTarget).val()));
}
static changeProject(url) {
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 2fd372a45b8..79a4ed0f9c3 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -21,7 +21,6 @@ const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'activity']),
- tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: [
'account',
'granted',
@@ -41,10 +40,6 @@ initMembersApp(document.querySelector('.js-project-members-list-app'), {
},
[MEMBER_TYPES.group]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
- tableAttrs: {
- table: { 'data-qa-selector': 'groups_list' },
- tr: { 'data-qa-selector': 'group_row' },
- },
requestFormatter: groupLinkRequestFormatter,
filteredSearchBar: {
show: true,
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index 895c7d0a18e..964c6ca9792 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -4,7 +4,6 @@ import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
import initVariableList from '~/ci/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
-import { initRunnerAwsDeployments } from '~/pages/shared/mount_runner_aws_deployments';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import initSettingsPanels from '~/settings_panels';
@@ -44,7 +43,5 @@ initArtifactsSettings();
initProjectRunners();
initSharedRunnersToggle();
initInstallRunner();
-initRunnerAwsDeployments();
-
initTokenAccess();
initCiSecureFiles();
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 5fa3288bbef..f2bc4796324 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
@@ -49,14 +49,10 @@ export default {
infrastructureLabel: s__('ProjectSettings|Infrastructure'),
infrastructureHelpText: s__('ProjectSettings|Configure your infrastructure.'),
monitorLabel: s__('ProjectSettings|Monitor'),
- packagesHelpText: s__(
- 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
- ),
packageRegistryHelpText: s__('ProjectSettings|Publish, store, and view packages in a project.'),
packageRegistryForEveryoneHelpText: s__(
'ProjectSettings|Anyone can pull packages with a package manager API.',
),
- packagesLabel: s__('ProjectSettings|Packages'),
packageRegistryLabel: s__('ProjectSettings|Package registry'),
packageRegistryForEveryoneLabel: s__(
'ProjectSettings|Allow anyone to pull from Package Registry',
@@ -355,9 +351,6 @@ export default {
this.visibilityLevel < this.currentSettings.visibilityLevel
);
},
- packageRegistryAccessLevelEnabled() {
- return this.glFeatures.packageRegistryAccessLevel;
- },
packageRegistryEnabled() {
return this.packageRegistryAccessLevel > featureAccessLevel.NOT_ENABLED;
},
@@ -392,14 +385,12 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.buildsAccessLevel,
);
- if (this.packageRegistryAccessLevelEnabled) {
- if (
- this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE ||
- (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE &&
- oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER)
- ) {
- this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
- }
+ if (
+ this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE ||
+ (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE &&
+ oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER)
+ ) {
+ this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel);
this.snippetsAccessLevel = Math.min(
@@ -459,10 +450,7 @@ export default {
this.repositoryAccessLevel = featureAccessLevel.EVERYONE;
if (this.mergeRequestsAccessLevel > featureAccessLevel.NOT_ENABLED)
this.mergeRequestsAccessLevel = featureAccessLevel.EVERYONE;
- if (
- this.packageRegistryAccessLevelEnabled &&
- this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS
- ) {
+ if (this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS) {
this.packageRegistryAccessLevel =
PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[value];
}
@@ -488,19 +476,17 @@ export default {
this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE;
this.highlightChanges();
- } else if (this.packageRegistryAccessLevelEnabled) {
- if (
- value === VISIBILITY_LEVEL_PUBLIC_INTEGER &&
- this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE
- ) {
- // eslint-disable-next-line prefer-destructuring
- this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0];
- } else if (
- value === VISIBILITY_LEVEL_INTERNAL_INTEGER &&
- this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]
- ) {
- this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE;
- }
+ } else if (
+ value === VISIBILITY_LEVEL_PUBLIC_INTEGER &&
+ this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE
+ ) {
+ // eslint-disable-next-line prefer-destructuring
+ this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0];
+ } else if (
+ value === VISIBILITY_LEVEL_INTERNAL_INTEGER &&
+ this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]
+ ) {
+ this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE;
}
},
@@ -770,22 +756,6 @@ export default {
</p>
</project-setting-row>
<project-setting-row
- v-if="packagesAvailable && !packageRegistryAccessLevelEnabled"
- ref="package-settings"
- :help-path="packagesHelpPath"
- :label="$options.i18n.packagesLabel"
- :help-text="$options.i18n.packagesHelpText"
- >
- <gl-toggle
- v-model="packagesEnabled"
- class="gl-my-2"
- :disabled="!repositoryEnabled"
- :label="$options.i18n.packagesLabel"
- label-position="hidden"
- name="project[packages_enabled]"
- />
- </project-setting-row>
- <project-setting-row
ref="pipeline-settings"
:label="$options.i18n.ciCdLabel"
:help-text="s__('ProjectSettings|Build, test, and deploy your changes.')"
@@ -889,7 +859,7 @@ export default {
/>
</project-setting-row>
<project-setting-row
- v-if="packageRegistryAccessLevelEnabled && packagesAvailable"
+ v-if="packagesAvailable"
:help-path="packagesHelpPath"
:label="$options.i18n.packageRegistryLabel"
:help-text="$options.i18n.packageRegistryHelpText"
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 1de36f4a0fb..33d4090011f 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -6,6 +6,7 @@ import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert'
import leaveByUrl from '~/namespaces/leave_by_url';
import initVueNotificationsDropdown from '~/notifications';
import Star from '~/projects/star';
+import initTerraformNotification from '~/projects/terraform_notification';
import { initUploadFileTrigger } from '~/projects/upload_file';
import initReadMore from '~/read_more';
@@ -44,6 +45,7 @@ initUploadFileTrigger();
initInviteMembersModal();
initInviteMembersTrigger();
initClustersDeprecationAlert();
+initTerraformNotification();
initReadMore();
new Star(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/search/show/refresh_counts.js b/app/assets/javascripts/pages/search/show/refresh_counts.js
deleted file mode 100644
index f3f6312cb7c..00000000000
--- a/app/assets/javascripts/pages/search/show/refresh_counts.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-function showCount(el, count) {
- el.textContent = count;
- el.classList.remove('hidden');
-}
-
-function refreshCount(el) {
- const { url } = el.dataset;
-
- return axios
- .get(url)
- .then(({ data }) => showCount(el, data.count))
- .catch((e) => {
- // eslint-disable-next-line no-console
- console.error(`Failed to fetch search count from '${url}'.`, e);
- });
-}
-
-export default function refreshCounts() {
- const elements = Array.from(document.querySelectorAll('.js-search-count'));
-
- return Promise.all(elements.map(refreshCount));
-}
diff --git a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js b/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js
deleted file mode 100644
index f3807a33a2b..00000000000
--- a/app/assets/javascripts/pages/shared/mount_runner_aws_deployments.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Vue from 'vue';
-import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue';
-
-export function initRunnerAwsDeployments(componentId = 'js-runner-aws-deployments') {
- const el = document.getElementById(componentId);
-
- if (!el) {
- return null;
- }
-
- return new Vue({
- el,
- render(createElement) {
- return createElement(RunnerAwsDeployments);
- },
- });
-}
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 8e2f542aec0..0d2bbfbbc43 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -119,6 +119,12 @@ export default {
isContentEditorActive: false,
switchEditingControlDisabled: false,
isFormDirty: getIsFormDirty(this.pageInfo),
+ formFieldProps: {
+ placeholder: this.$options.i18n.content.placeholder,
+ 'aria-label': this.$options.i18n.content.label,
+ id: 'wiki_content',
+ name: 'wiki[content]',
+ },
};
},
computed: {
@@ -338,16 +344,13 @@ export default {
<gl-form-group>
<markdown-editor
v-model="content"
+ :form-field-props="formFieldProps"
:render-markdown-path="pageInfo.markdownPreviewPath"
:markdown-docs-path="pageInfo.markdownHelpPath"
:uploads-path="pageInfo.uploadsPath"
:enable-content-editor="isMarkdownFormat"
:enable-preview="isMarkdownFormat"
:autofocus="pageInfo.persisted"
- :form-field-placeholder="$options.i18n.content.placeholder"
- :form-field-aria-label="$options.i18n.content.label"
- form-field-id="wiki_content"
- form-field-name="wiki[content]"
@contentEditor="notifyContentEditorActive"
@markdownField="notifyContentEditorInactive"
@keydown.ctrl.enter="submitFormShortcut"
diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js
new file mode 100644
index 00000000000..f1b4e00c810
--- /dev/null
+++ b/app/assets/javascripts/pages/users/show/index.js
@@ -0,0 +1,16 @@
+import { s__ } from '~/locale';
+import { createAlert } from '~/flash';
+
+if (window.gon.features?.profileTabsVue) {
+ import('~/profile')
+ .then(({ initProfileTabs }) => {
+ initProfileTabs();
+ })
+ .catch(() => {
+ createAlert({
+ message: s__(
+ 'UserProfile|An error occurred loading the profile. Please refresh the page to try again.',
+ ),
+ });
+ });
+}
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index ea8005e8dfb..69d60a7caf9 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -158,7 +158,7 @@ export default {
v-model="sortOrder"
:toggle-text="$options.sortOrderOptions[sortOrder].text"
:items="Object.values($options.sortOrderOptions)"
- right
+ placement="right"
data-testid="performance-bar-sort-order"
/>
</div>
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 139da5dabbd..e37f63d4053 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -20,6 +20,8 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-web-hook-disabled-callout',
'.js-merge-request-settings-callout',
'.js-ultimate-feature-removal-banner',
+ '.js-geo-enable-hashed-storage-callout',
+ '.js-geo-migrate-hashed-storage-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index 85ca52f633e..e650a48bc2a 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -10,6 +10,8 @@ export const ONE_COL_WIDTH = 180;
export const STAGE_VIEW = 'stage';
export const LAYER_VIEW = 'layer';
+
+export const SKIP_RETRY_MODAL_KEY = 'skip_retry_modal';
export const VIEW_TYPE_KEY = 'pipeline_graph_view_type';
export const SINGLE_JOB = 'single_job';
@@ -20,3 +22,5 @@ export const BRIDGE_KIND = 'BRIDGE';
export const ACTION_FAILURE = 'action_failure';
export const IID_FAILURE = 'missing_iid';
+
+export const RETRY_ACTION_TITLE = 'Retry';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 1a05710a13e..49df71beeec 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -2,7 +2,10 @@
import { reportToSentry } from '../../utils';
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
-import { generateColumnsFromLayersListMemoized } from '../parsing_utils';
+import {
+ generateColumnsFromLayersListMemoized,
+ keepLatestDownstreamPipelines,
+} from '../parsing_utils';
import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
@@ -44,6 +47,11 @@ export default {
required: false,
default: () => ({}),
},
+ skipRetryModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
type: {
type: String,
required: false,
@@ -76,7 +84,9 @@ export default {
return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`;
},
downstreamPipelines() {
- return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
+ return this.hasDownstreamPipelines
+ ? keepLatestDownstreamPipelines(this.pipeline.downstream)
+ : [];
},
layout() {
return this.isStageView
@@ -181,9 +191,11 @@ export default {
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:show-links="showJobLinks"
+ :skip-retry-modal="skipRetryModal"
:type="$options.pipelineTypeConstants.UPSTREAM"
:view-type="viewType"
@error="onError"
+ @setSkipRetryModal="$emit('setSkipRetryModal')"
/>
</template>
<template #main>
@@ -210,11 +222,13 @@ export default {
:highlighted-jobs="highlightedJobs"
:is-stage-view="isStageView"
:job-hovered="hoveredJobName"
+ :skip-retry-modal="skipRetryModal"
:source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
:user-permissions="pipeline.userPermissions"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
+ @setSkipRetryModal="$emit('setSkipRetryModal')"
@jobHover="setJob"
@updateMeasurements="getMeasurements"
/>
@@ -228,12 +242,15 @@ export default {
:config-paths="configPaths"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
+ :skip-retry-modal="skipRetryModal"
:show-links="showJobLinks"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
:view-type="viewType"
+ data-testid="downstream-pipelines"
@downstreamHovered="setSourceJob"
@pipelineExpandToggle="togglePipelineExpanded"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
+ @setSkipRetryModal="$emit('setSkipRetryModal')"
@scrollContainer="slidePipelineContainer"
@error="onError"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 4d7596e6e16..8f76d7535f1 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -8,7 +8,14 @@ import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql';
import { reportToSentry, reportMessageToSentry } from '../../utils';
-import { ACTION_FAILURE, IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants';
+import {
+ ACTION_FAILURE,
+ IID_FAILURE,
+ LAYER_VIEW,
+ SKIP_RETRY_MODAL_KEY,
+ STAGE_VIEW,
+ VIEW_TYPE_KEY,
+} from './constants';
import PipelineGraph from './graph_component.vue';
import GraphViewSelector from './graph_view_selector.vue';
import {
@@ -53,6 +60,7 @@ export default {
currentViewType: STAGE_VIEW,
canRefetchHeaderPipeline: false,
pipeline: null,
+ skipRetryModal: false,
showAlert: false,
showLinks: false,
};
@@ -206,8 +214,8 @@ export default {
if (!this.pipelineIid) {
this.reportFailure({ type: IID_FAILURE, skipSentry: true });
}
-
toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
+ this.skipRetryModal = Boolean(JSON.parse(localStorage.getItem(SKIP_RETRY_MODAL_KEY)));
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
@@ -259,6 +267,9 @@ export default {
updateShowLinksState(val) {
this.showLinks = val;
},
+ setSkipRetryModal() {
+ this.skipRetryModal = true;
+ },
updateViewType(type) {
this.currentViewType = type;
},
@@ -293,10 +304,12 @@ export default {
:config-paths="configPaths"
:pipeline="pipeline"
:computed-pipeline-info="getPipelineInfo()"
+ :skip-retry-modal="skipRetryModal"
:show-links="showLinks"
:view-type="graphViewType"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
+ @setSkipRetryModal="setSkipRetryModal"
/>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 4f2be27486c..992e3d2f552 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -1,13 +1,14 @@
<script>
-import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlForm, GlFormCheckbox, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { sprintf, __ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue';
import JobNameComponent from '../jobs_shared/job_name_component.vue';
-import { BRIDGE_KIND, SINGLE_JOB } from './constants';
+import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -35,17 +36,32 @@ import { BRIDGE_KIND, SINGLE_JOB } from './constants';
*/
export default {
+ confirmationModalDocLink: helpPagePath('/ci/pipelines/downstream_pipelines'),
i18n: {
bridgeBadgeText: __('Trigger job'),
unauthorizedTooltip: __('You are not authorized to run this manual job'),
+ confirmationModal: {
+ title: s__('PipelineGraph|Are you sure you want to retry %{jobName}?'),
+ description: s__(
+ 'PipelineGraph|Retrying a trigger job will create a new downstream pipeline.',
+ ),
+ linkText: s__('PipelineGraph|What is a downstream pipeline?'),
+ footer: __("Don't show this again"),
+ actionPrimary: { text: __('Retry') },
+ actionCancel: { text: __('Cancel') },
+ },
+ runAgainTooltipText: __('Run again'),
},
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: {
ActionComponent,
CiIcon,
- JobNameComponent,
GlBadge,
+ GlForm,
+ GlFormCheckbox,
GlLink,
+ GlModal,
+ JobNameComponent,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -86,6 +102,11 @@ export default {
required: false,
default: -1,
},
+ skipRetryModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
sourceJobHovered: {
type: String,
required: false,
@@ -102,6 +123,13 @@ export default {
default: SINGLE_JOB,
},
},
+ data() {
+ return {
+ currentSkipModalValue: this.skipRetryModal,
+ showConfirmationModal: false,
+ shouldTriggerActionClick: false,
+ };
+ },
computed: {
boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
@@ -115,6 +143,12 @@ export default {
hasDetails() {
return this.status.hasDetails;
},
+ hasRetryAction() {
+ return Boolean(this.job?.status?.action?.title === RETRY_ACTION_TITLE);
+ },
+ isRetryableBridge() {
+ return this.isBridge && this.hasRetryAction;
+ },
isSingleItem() {
return this.type === SINGLE_JOB;
},
@@ -127,6 +161,11 @@ export default {
nameComponent() {
return this.hasDetails ? 'gl-link' : 'div';
},
+ retryTriggerJobWarningText() {
+ return sprintf(this.$options.i18n.confirmationModal.title, {
+ jobName: this.job.name,
+ });
+ },
showStageName() {
return Boolean(this.stageName);
},
@@ -205,11 +244,34 @@ export default {
},
];
},
+ withConfirmationModal() {
+ return this.isRetryableBridge && !this.skipRetryModal;
+ },
+ jobActionTooltipText() {
+ const { group } = this.status;
+ const { title, icon } = this.status.action;
+
+ return icon === 'retry' && group === 'success'
+ ? this.$options.i18n.runAgainTooltipText
+ : title;
+ },
+ },
+ watch: {
+ skipRetryModal(val) {
+ this.currentSkipModalValue = val;
+ this.shouldTriggerActionClick = false;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('job_item', `error: ${err}, info: ${info}`);
},
methods: {
+ handleConfirmationModalPreferences() {
+ if (this.currentSkipModalValue) {
+ this.$emit('setSkipRetryModal');
+ localStorage.setItem(SKIP_RETRY_MODAL_KEY, String(this.currentSkipModalValue));
+ }
+ },
hideTooltips() {
this.$root.$emit(BV_HIDE_TOOLTIP);
},
@@ -227,6 +289,15 @@ export default {
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
+ executePendingAction() {
+ this.shouldTriggerActionClick = true;
+ },
+ showActionConfirmationModal() {
+ this.showConfirmationModal = true;
+ },
+ toggleSkipRetryModalCheckbox() {
+ this.currentSkipModalValue = !this.currentSkipModalValue;
+ },
},
};
</script>
@@ -272,12 +343,16 @@ export default {
<action-component
v-if="hasAction"
- :tooltip-text="status.action.title"
+ :tooltip-text="jobActionTooltipText"
:link="status.action.path"
:action-icon="status.action.icon"
class="gl-mr-1"
+ :should-trigger-click="shouldTriggerActionClick"
+ :with-confirmation-modal="withConfirmationModal"
data-qa-selector="job_action_button"
+ @actionButtonClicked="handleConfirmationModalPreferences"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
+ @showActionConfirmationModal="showActionConfirmationModal"
/>
<action-component
v-if="hasUnauthorizedManualAction"
@@ -287,5 +362,28 @@ export default {
:link="`unauthorized-${computedJobId}`"
class="gl-mr-1"
/>
+ <gl-modal
+ v-if="showConfirmationModal"
+ ref="modal"
+ v-model="showConfirmationModal"
+ modal-id="action-confirmation-modal"
+ :title="retryTriggerJobWarningText"
+ :action-cancel="$options.i18n.confirmationModal.actionCancel"
+ :action-primary="$options.i18n.confirmationModal.actionPrimary"
+ @primary="executePendingAction"
+ @close="handleConfirmationModalPreferences"
+ @hide="handleConfirmationModalPreferences"
+ >
+ <p class="gl-mb-1">{{ $options.i18n.confirmationModal.description }}</p>
+ <gl-link :href="$options.confirmationModalDocLink" target="_blank">{{
+ $options.i18n.confirmationModal.linkText
+ }}</gl-link>
+ <div class="gl-mt-4 gl-display-flex">
+ <gl-form>
+ <gl-form-checkbox class="gl-min-h-0" @input="toggleSkipRetryModalCheckbox" />
+ </gl-form>
+ <p class="gl-m-0">{{ $options.i18n.confirmationModal.footer }}</p>
+ </div>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 225706265c3..9b4e5d471d6 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -7,13 +7,13 @@ import {
GlTooltip,
GlTooltipDirective,
} from '@gitlab/ui';
+import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
-import { PIPELINE_GRAPHQL_TYPE } from '../../constants';
import { reportToSentry } from '../../utils';
import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants';
@@ -118,7 +118,7 @@ export default {
return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row';
},
graphqlPipelineId() {
- return convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, this.pipeline.id);
+ return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id);
},
hasUpdatePipelinePermissions() {
return Boolean(this.pipeline?.userPermissions?.updatePipeline);
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index b06c2f15042..02e426064c9 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -36,6 +36,11 @@ export default {
type: Boolean,
required: true,
},
+ skipRetryModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
type: {
type: String,
required: true,
@@ -229,8 +234,10 @@ export default {
:pipeline="currentPipeline"
:computed-pipeline-info="getPipelineLayers(pipeline.id)"
:show-links="showLinks"
+ :skip-retry-modal="skipRetryModal"
:is-linked-pipeline="true"
:view-type="graphViewType"
+ @setSkipRetryModal="$emit('setSkipRetryModal')"
/>
</div>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 4aec28295bd..ffd0fec2ca8 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -53,6 +53,11 @@ export default {
required: false,
default: () => ({}),
},
+ skipRetryModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
sourceJobHovered: {
type: String,
required: false,
@@ -164,6 +169,7 @@ export default {
v-if="singleJobExists(group)"
:job="group.jobs[0]"
:job-hovered="jobHovered"
+ :skip-retry-modal="skipRetryModal"
:source-job-hovered="sourceJobHovered"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
@@ -174,6 +180,7 @@ export default {
'gl-transition-duration-slow gl-transition-timing-function-ease',
]"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
+ @setSkipRetryModal="$emit('setSkipRetryModal')"
/>
<div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
<job-group-dropdown
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index 387b01aee7e..7020bfc1e65 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -39,6 +39,16 @@ export default {
type: String,
required: true,
},
+ withConfirmationModal: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ shouldTriggerClick: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -52,6 +62,14 @@ export default {
return `${actionIconDash} js-icon-${actionIconDash}`;
},
},
+ watch: {
+ shouldTriggerClick(flag) {
+ if (flag && this.withConfirmationModal) {
+ this.executeAction();
+ this.$emit('actionButtonClicked');
+ }
+ },
+ },
errorCaptured(err, _vm, info) {
reportToSentry('action_component', `error: ${err}, info: ${info}`);
},
@@ -63,6 +81,13 @@ export default {
*
*/
onClickAction() {
+ if (this.withConfirmationModal) {
+ this.$emit('showActionConfirmationModal');
+ } else {
+ this.executeAction();
+ }
+ },
+ executeAction() {
this.$root.$emit(BV_HIDE_TOOLTIP, `js-ci-action-${this.link}`);
this.isDisabled = true;
this.isLoading = true;
@@ -91,6 +116,7 @@ export default {
<template>
<gl-button
:id="`js-ci-action-${link}`"
+ ref="button"
:class="cssClass"
:disabled="isDisabled"
class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index cae4e11c13f..e158f8809b5 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -170,3 +170,13 @@ export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipe
};
export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare);
+
+export const keepLatestDownstreamPipelines = (downstreamPipelines = []) => {
+ return downstreamPipelines.filter((pipeline) => {
+ if (pipeline.source_job) {
+ return !pipeline?.source_job?.retried || false;
+ }
+
+ return !pipeline?.sourceJob?.retried || false;
+ });
+};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
index 51b46f25048..66bf5068149 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
@@ -2,7 +2,7 @@
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue';
import JobNameComponent from '../jobs_shared/job_name_component.vue';
@@ -33,6 +33,9 @@ import JobNameComponent from '../jobs_shared/job_name_component.vue';
*/
export default {
+ i18n: {
+ runAgainTooltipText: __('Run again'),
+ },
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: {
ActionComponent,
@@ -129,6 +132,14 @@ export default {
? `${this.$options.hoverClass} ${this.cssClassJobName}`
: this.cssClassJobName;
},
+ jobActionTooltipText() {
+ const { group } = this.status;
+ const { title, icon } = this.status.action;
+
+ return icon === 'retry' && group === 'success'
+ ? this.$options.i18n.runAgainTooltipText
+ : title;
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`);
@@ -177,7 +188,7 @@ export default {
<action-component
v-if="hasAction"
- :tooltip-text="status.action.title"
+ :tooltip-text="jobActionTooltipText"
:link="status.action.path"
:action-icon="status.action.icon"
data-qa-selector="action_button"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index c498f12d5c7..4111823e0bb 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -311,7 +311,7 @@ export default {
this.resetRequestData();
}
- this.updateContent(this.requestData);
+ this.updateContent({ ...this.requestData, page: '1' });
},
changeVisibilityPipelineID(val) {
this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value);
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 ed32d643c0e..365572f194b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -2,6 +2,7 @@
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import Tracking from '~/tracking';
+import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import eventHub from '../../event_hub';
import { TRACKING_CATEGORIES } from '../../constants';
@@ -115,6 +116,10 @@ export default {
eventHub.$off('openConfirmationModal', this.setModalData);
},
methods: {
+ getDownstreamPipelines(pipeline) {
+ const downstream = pipeline.triggered;
+ return keepLatestDownstreamPipelines(downstream);
+ },
setModalData(data) {
this.pipelineId = data.pipeline.id;
this.pipeline = data.pipeline;
@@ -171,7 +176,7 @@ export default {
<template #cell(stages)="{ item }">
<pipeline-mini-graph
- :downstream-pipelines="item.triggered"
+ :downstream-pipelines="getDownstreamPipelines(item)"
:pipeline-path="item.path"
:stages="item.details.stages"
:update-dropdown="updateGraphDropdown"
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 2f37f90e625..820501089ed 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -9,7 +9,6 @@ export const FILTER_TAG_IDENTIFIER = 'tag';
export const SCHEDULE_ORIGIN = 'schedule';
export const NEEDS_PROPERTY = 'needs';
export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds';
-export const PIPELINE_GRAPHQL_TYPE = 'Ci::Pipeline';
export const ICONS = {
TAG: 'tag',
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index f00378733fc..ba51347ad69 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,6 +1,7 @@
import VueRouter from 'vue-router';
import { createAlert } from '~/flash';
import { __ } from '~/locale';
+import { pipelineTabName } from './constants';
import { createPipelineHeaderApp } from './pipeline_details_header';
import { apolloProvider } from './pipeline_shared_client';
@@ -38,6 +39,12 @@ export default async function initPipelineDetailsBundle() {
routes,
});
+ // We handle the shortcut `pipelines/latest` by forwarding the user to the pipeline graph
+ // tab and changing the route to the correct `pipelines/:id`
+ if (window.location.pathname.endsWith('latest')) {
+ router.replace({ name: pipelineTabName });
+ }
+
try {
const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider, router);
createPipelineTabs(appOptions);
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index d0ee6871a48..6360ccc41bc 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -34,6 +34,7 @@ export const createAppOptions = (selector, apolloProvider, router) => {
totalJobCount,
licenseManagementApiUrl,
licenseManagementSettingsPath,
+ licenseScanCount,
licensesApiPath,
canManageLicenses,
summaryEndpoint,
@@ -87,6 +88,7 @@ export const createAppOptions = (selector, apolloProvider, router) => {
totalJobCount,
licenseManagementApiUrl,
licenseManagementSettingsPath,
+ licenseScanCount,
licensesApiPath,
canManageLicenses: parseBoolean(canManageLicenses),
summaryEndpoint,
diff --git a/app/assets/javascripts/profile/components/activity_tab.vue b/app/assets/javascripts/profile/components/activity_tab.vue
new file mode 100644
index 00000000000..aae5c489e88
--- /dev/null
+++ b/app/assets/javascripts/profile/components/activity_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Activity'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/contributed_projects_tab.vue b/app/assets/javascripts/profile/components/contributed_projects_tab.vue
new file mode 100644
index 00000000000..e490643e57a
--- /dev/null
+++ b/app/assets/javascripts/profile/components/contributed_projects_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Contributed projects'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/followers_tab.vue b/app/assets/javascripts/profile/components/followers_tab.vue
new file mode 100644
index 00000000000..47651c33eb8
--- /dev/null
+++ b/app/assets/javascripts/profile/components/followers_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Followers'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/following_tab.vue b/app/assets/javascripts/profile/components/following_tab.vue
new file mode 100644
index 00000000000..6d9631c5e89
--- /dev/null
+++ b/app/assets/javascripts/profile/components/following_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Following'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/groups_tab.vue b/app/assets/javascripts/profile/components/groups_tab.vue
new file mode 100644
index 00000000000..6c4847872a7
--- /dev/null
+++ b/app/assets/javascripts/profile/components/groups_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Groups'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
new file mode 100644
index 00000000000..e884c2d7083
--- /dev/null
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Overview'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/personal_projects_tab.vue b/app/assets/javascripts/profile/components/personal_projects_tab.vue
new file mode 100644
index 00000000000..285f01930e7
--- /dev/null
+++ b/app/assets/javascripts/profile/components/personal_projects_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Personal projects'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
new file mode 100644
index 00000000000..2425d56c52a
--- /dev/null
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlTabs } from '@gitlab/ui';
+
+import OverviewTab from './overview_tab.vue';
+import ActivityTab from './activity_tab.vue';
+import GroupsTab from './groups_tab.vue';
+import ContributedProjectsTab from './contributed_projects_tab.vue';
+import PersonalProjectsTab from './personal_projects_tab.vue';
+import StarredProjectsTab from './starred_projects_tab.vue';
+import SnippetsTab from './snippets_tab.vue';
+import FollowersTab from './followers_tab.vue';
+import FollowingTab from './following_tab.vue';
+
+export default {
+ components: {
+ GlTabs,
+ OverviewTab,
+ ActivityTab,
+ GroupsTab,
+ ContributedProjectsTab,
+ PersonalProjectsTab,
+ StarredProjectsTab,
+ SnippetsTab,
+ FollowersTab,
+ FollowingTab,
+ },
+ tabs: [
+ {
+ key: 'overview',
+ component: OverviewTab,
+ },
+ {
+ key: 'activity',
+ component: ActivityTab,
+ },
+ {
+ key: 'groups',
+ component: GroupsTab,
+ },
+ {
+ key: 'contributedProjects',
+ component: ContributedProjectsTab,
+ },
+ {
+ key: 'personalProjects',
+ component: PersonalProjectsTab,
+ },
+ {
+ key: 'starredProjects',
+ component: StarredProjectsTab,
+ },
+ {
+ key: 'snippets',
+ component: SnippetsTab,
+ },
+ {
+ key: 'followers',
+ component: FollowersTab,
+ },
+ {
+ key: 'following',
+ component: FollowingTab,
+ },
+ ],
+};
+</script>
+
+<template>
+ <gl-tabs>
+ <component :is="component" v-for="{ key, component } in $options.tabs" :key="key" />
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/profile/components/snippets_tab.vue b/app/assets/javascripts/profile/components/snippets_tab.vue
new file mode 100644
index 00000000000..d64c5b900a5
--- /dev/null
+++ b/app/assets/javascripts/profile/components/snippets_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Snippets'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/components/starred_projects_tab.vue b/app/assets/javascripts/profile/components/starred_projects_tab.vue
new file mode 100644
index 00000000000..b9ef1e6e713
--- /dev/null
+++ b/app/assets/javascripts/profile/components/starred_projects_tab.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('UserProfile|Starred projects'),
+ },
+ components: { GlTab },
+};
+</script>
+
+<template>
+ <gl-tab :title="$options.i18n.title">
+ <!-- placeholder -->
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
new file mode 100644
index 00000000000..5378ed3d743
--- /dev/null
+++ b/app/assets/javascripts/profile/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+
+import ProfileTabs from './components/profile_tabs.vue';
+
+export const initProfileTabs = () => {
+ const el = document.getElementById('js-profile-tabs');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(ProfileTabs);
+ },
+ });
+};
diff --git a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue
index 74dd2d5628a..a8a25dc2ec6 100644
--- a/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue
+++ b/app/assets/javascripts/profile/preferences/components/diffs_colors_preview.vue
@@ -110,8 +110,8 @@ export default {
</td>
<td class="line_content parallel left-side old">
<span>
- <span>{{ ' ' }}</span>
- <span class="k">print</span><span class="p">(</span><span class="n">i</span>
+ <span>{{ ' ' }}</span
+ ><span class="k">print</span><span class="p">(</span><span class="n">i</span>
<span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span
>
</td>
@@ -120,8 +120,8 @@ export default {
</td>
<td class="line_content parallel right-side new">
<span>
- <span>{{ ' ' }}</span>
- <span class="k">print</span><span class="p">(</span><span class="n">i</span>
+ <span>{{ ' ' }}</span
+ ><span class="k">print</span><span class="p">(</span><span class="n">i</span>
<span class="o">+</span> <span class="mi">1</span><span class="p">)</span></span
>
</td>
@@ -162,8 +162,8 @@ export default {
</td>
<td class="line_content parallel left-side old">
<span>
- <span>{{ ' ' }}</span>
- <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
+ <span>{{ ' ' }}</span
+ ><span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
><span class="bp">self</span><span class="p">,</span> <span class="n">x</span
><span class="p">):</span></span
>
@@ -173,8 +173,8 @@ export default {
</td>
<td class="line_content parallel right-side new">
<span>
- <span>{{ ' ' }}</span>
- <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
+ <span>{{ ' ' }}</span
+ ><span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span
><span class="bp">self</span><span class="p">,</span> <span class="n">x</span
><span class="p">):</span></span
>
@@ -186,8 +186,8 @@ export default {
</td>
<td class="line_content parallel left-side old">
<span>
- <span>{{ ' ' }}</span>
- <span class="bp">self</span><span class="p">.</span><span class="n">val</span>
+ <span>{{ ' ' }}</span
+ ><span class="bp">self</span><span class="p">.</span><span class="n">val</span>
<span class="o">=</span> <span class="n">x</span></span
>
</td>
@@ -196,8 +196,8 @@ export default {
</td>
<td class="line_content parallel right-side new">
<span>
- <span>{{ ' ' }}</span>
- <span class="bp">self</span><span class="p">.</span><span class="n">val</span>
+ <span>{{ ' ' }}</span
+ ><span class="bp">self</span><span class="p">.</span><span class="n">val</span>
<span class="o">=</span> <span class="n">x</span></span
>
</td>
@@ -208,8 +208,8 @@ export default {
</td>
<td class="line_content parallel left-side old">
<span>
- <span>{{ ' ' }}</span>
- <span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
+ <span>{{ ' ' }}</span
+ ><span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
<span class="o">=</span> <span class="bp">None</span></span
>
</td>
@@ -218,8 +218,8 @@ export default {
</td>
<td class="line_content parallel right-side new">
<span>
- <span>{{ ' ' }}</span>
- <span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
+ <span>{{ ' ' }}</span
+ ><span class="bp">self</span><span class="p">.</span><span class="nb">next</span>
<span class="o">=</span> <span class="bp">None</span></span
>
</td>
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
deleted file mode 100644
index 705234537a8..00000000000
--- a/app/assets/javascripts/project_select.js
+++ /dev/null
@@ -1,128 +0,0 @@
-/* eslint-disable func-names */
-
-import $ from 'jquery';
-import { createAlert } from '~/flash';
-import Api from './api';
-import { loadCSSFile } from './lib/utils/css_utils';
-import { s__ } from './locale';
-import ProjectSelectComboButton from './project_select_combo_button';
-
-const projectSelect = async () => {
- await loadCSSFile(gon.select2_css_path);
-
- $('.ajax-project-select').each(function (i, select) {
- let placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- const isInstantiated = $(select).data('select2');
- this.groupId = $(select).data('groupId');
- this.userId = $(select).data('userId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
-
- placeholder = s__('ProjectSelect|Search for project');
- if (this.includeGroups) {
- placeholder += s__('ProjectSelect| or group');
- }
-
- $(select).select2({
- placeholder,
- minimumInputLength: 0,
- query: (query) => {
- let projectsCallback;
- const finalCallback = function (projects) {
- const data = {
- results: projects,
- };
- return query.callback(data);
- };
- if (this.includeGroups) {
- projectsCallback = function (projects) {
- const groupsCallback = function (groups) {
- const data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (this.groupId) {
- return Api.groupProjects(
- this.groupId,
- query.term,
- {
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- with_shared: this.withShared,
- include_subgroups: this.includeProjectsInSubgroups,
- order_by: 'similarity',
- simple: true,
- },
- projectsCallback,
- ).catch(() => {
- createAlert({
- message: s__('ProjectSelect|Something went wrong while fetching projects'),
- });
- });
- } else if (this.userId) {
- return Api.userProjects(
- this.userId,
- query.term,
- {
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- with_shared: this.withShared,
- include_subgroups: this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- }
- return Api.projects(
- query.term,
- {
- order_by: this.orderBy,
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- membership: !this.allProjects,
- },
- projectsCallback,
- );
- },
- id(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
- });
- },
- text(project) {
- return project.name_with_namespace || project.name;
- },
-
- initSelection(el, callback) {
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
-
- allowClear: this.allowClear,
-
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (isInstantiated || simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
-};
-
-export default () => {
- if ($('.ajax-project-select').length) {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(projectSelect)
- .catch(() => {});
- }
-};
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
deleted file mode 100644
index ad80032c551..00000000000
--- a/app/assets/javascripts/project_select_combo_button.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import $ from 'jquery';
-import { sprintf, __ } from '~/locale';
-import { sanitizeUrl } from '~/lib/utils/url_utility';
-import AccessorUtilities from './lib/utils/accessor';
-import { loadCSSFile } from './lib/utils/css_utils';
-
-export default class ProjectSelectComboButton {
- constructor(select) {
- this.projectSelectInput = $(select);
- this.newItemBtn = $('.js-new-project-item-link');
- this.resourceType = this.newItemBtn.data('type');
- this.resourceLabel = this.newItemBtn.data('label');
- this.formattedText = this.deriveTextVariants();
- this.groupId = this.projectSelectInput.data('groupId');
- this.bindEvents();
- this.initLocalStorage();
- }
-
- bindEvents() {
- this.projectSelectInput
- .siblings('.new-project-item-select-button')
- .on('click', (e) => this.openDropdown(e));
-
- this.newItemBtn.on('click', (e) => {
- if (!this.getProjectFromLocalStorage()) {
- e.preventDefault();
- this.openDropdown(e);
- }
- });
-
- this.projectSelectInput.on('change', () => this.selectProject());
- }
-
- initLocalStorage() {
- const localStorageIsSafe = AccessorUtilities.canUseLocalStorage();
-
- if (localStorageIsSafe) {
- this.localStorageKey = [
- 'group',
- this.groupId,
- this.formattedText.localStorageItemType,
- 'recent-project',
- ].join('-');
- this.setBtnTextFromLocalStorage();
- }
- }
-
- // eslint-disable-next-line class-methods-use-this
- openDropdown(event) {
- import(/* webpackChunkName: 'select2' */ 'select2/select2')
- .then(() => {
- // eslint-disable-next-line promise/no-nesting
- loadCSSFile(gon.select2_css_path)
- .then(() => {
- $(event.currentTarget).siblings('.project-item-select').select2('open');
- })
- .catch(() => {});
- })
- .catch(() => {});
- }
-
- selectProject() {
- const selectedProjectData = JSON.parse(this.projectSelectInput.val());
- const projectUrl = `${selectedProjectData.url}/${this.projectSelectInput.data('relativePath')}`;
- const projectName = selectedProjectData.name;
-
- const projectMeta = {
- url: projectUrl,
- name: projectName,
- };
-
- this.setNewItemBtnAttributes(projectMeta);
- this.setProjectInLocalStorage(projectMeta);
- }
-
- setBtnTextFromLocalStorage() {
- const cachedProjectData = this.getProjectFromLocalStorage();
-
- this.setNewItemBtnAttributes(cachedProjectData);
- }
-
- setNewItemBtnAttributes(project) {
- if (project) {
- this.newItemBtn.attr('href', sanitizeUrl(project.url));
- this.newItemBtn.text(
- sprintf(__('New %{type} in %{project}'), {
- type: this.resourceLabel,
- project: project.name,
- }),
- );
- } else {
- this.newItemBtn.text(
- sprintf(__('Select project to create %{type}'), {
- type: this.formattedText.presetTextSuffix,
- }),
- );
- }
- }
-
- getProjectFromLocalStorage() {
- const projectString = localStorage.getItem(this.localStorageKey);
-
- return JSON.parse(projectString);
- }
-
- setProjectInLocalStorage(projectMeta) {
- const projectString = JSON.stringify(projectMeta);
-
- localStorage.setItem(this.localStorageKey, projectString);
- }
-
- deriveTextVariants() {
- // the trailing slice call depluralizes each of these strings (e.g. new-issues -> new-issue)
- const localStorageItemType = `new-${this.resourceType.split('_').join('-').slice(0, -1)}`;
- const presetTextSuffix = this.resourceType.split('_').join(' ').slice(0, -1);
-
- return {
- localStorageItemType, // new-issue / new-merge-request
- presetTextSuffix, // issue / merge request
- };
- }
-}
diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
index a037e721677..0ed154c47dd 100644
--- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue
@@ -1,12 +1,7 @@
<script>
-import {
- GlDropdown,
- GlSearchBoxByType,
- GlDropdownItem,
- GlDropdownText,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { debounce } from 'lodash';
import {
I18N_NO_RESULTS_MESSAGE,
I18N_BRANCH_HEADER,
@@ -16,11 +11,7 @@ import {
export default {
name: 'BranchesDropdown',
components: {
- GlDropdown,
- GlSearchBoxByType,
- GlDropdownItem,
- GlDropdownText,
- GlLoadingIcon,
+ GlCollapsibleListbox,
},
props: {
value: {
@@ -46,19 +37,17 @@ export default {
},
computed: {
...mapGetters(['joinedBranches']),
- ...mapState(['isFetching', 'branch', 'branches']),
- filteredResults() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.joinedBranches.filter((resultString) =>
- resultString.toLowerCase().includes(lowerCasedSearchTerm),
- );
+ ...mapState(['isFetching']),
+ listboxItems() {
+ return this.joinedBranches.map((value) => ({ value, text: value }));
},
},
watch: {
// Parent component can set the branch value (e.g. when the user selects a different project)
// and we need to keep the search term in sync with the selected value
value(val) {
- this.searchTermChanged(val);
+ this.searchTerm = val;
+ this.fetchBranches(this.searchTerm);
},
},
mounted() {
@@ -67,50 +56,29 @@ export default {
methods: {
...mapActions(['fetchBranches']),
selectBranch(branch) {
- this.$emit('selectBranch', branch);
- this.searchTerm = branch; // enables isSelected to work as expected
- },
- isSelected(selectedBranch) {
- return selectedBranch === this.branch;
+ this.$emit('input', branch);
},
+ debouncedSearch: debounce(function debouncedSearch() {
+ this.fetchBranches(this.searchTerm);
+ }, 250),
searchTermChanged(value) {
- this.searchTerm = value;
- this.fetchBranches(value);
+ this.searchTerm = value.trim();
+ this.debouncedSearch(value);
},
},
};
</script>
<template>
- <gl-dropdown :text="value" :header-text="$options.i18n.branchHeaderTitle">
- <gl-search-box-by-type
- :value="searchTerm"
- trim
- autocomplete="off"
- :debounce="250"
- :placeholder="$options.i18n.branchSearchPlaceholder"
- data-testid="dropdown-search-box"
- @input="searchTermChanged"
- />
- <gl-dropdown-item
- v-for="branch in filteredResults"
- v-show="!isFetching"
- :key="branch"
- :name="branch"
- :is-checked="isSelected(branch)"
- is-check-item
- data-testid="dropdown-item"
- @click="selectBranch(branch)"
- >
- {{ branch }}
- </gl-dropdown-item>
- <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
- <gl-loading-icon size="sm" class="gl-mx-auto" />
- </gl-dropdown-text>
- <gl-dropdown-text
- v-if="!filteredResults.length && !isFetching"
- data-testid="empty-result-message"
- >
- <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :header-text="$options.i18n.branchHeaderTitle"
+ :toggle-text="value"
+ :items="listboxItems"
+ searchable
+ :search-placeholder="$options.i18n.branchSearchPlaceholder"
+ :searching="isFetching"
+ :selected="value"
+ :no-results-text="$options.i18n.noResultsMessage"
+ @search="searchTermChanged"
+ @select="selectBranch"
+ />
</template>
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index 1febe8ceaab..f78afef1c17 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -141,11 +141,7 @@ export default {
:value="targetProjectId"
/>
- <projects-dropdown
- class="gl-w-half"
- :value="targetProjectName"
- @selectProject="setSelectedProject"
- />
+ <projects-dropdown :value="targetProjectName" @selectProject="setSelectedProject" />
</gl-form-group>
<gl-form-group
@@ -155,12 +151,7 @@ export default {
>
<input id="start_branch" type="hidden" name="start_branch" :value="branch" />
- <branches-dropdown
- class="gl-w-half"
- :value="branch"
- :blanked="isRevert"
- @selectBranch="setBranch"
- />
+ <branches-dropdown :value="branch" :blanked="isRevert" @input="setBranch" />
</gl-form-group>
<gl-form-checkbox
diff --git a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
index 6288bcdaad0..d43f5b99e2c 100644
--- a/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/projects_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDropdown, GlSearchBoxByType, GlDropdownItem, GlDropdownText } from '@gitlab/ui';
+import { GlCollapsibleListbox } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import {
I18N_NO_RESULTS_MESSAGE,
@@ -10,10 +10,7 @@ import {
export default {
name: 'ProjectsDropdown',
components: {
- GlDropdown,
- GlSearchBoxByType,
- GlDropdownItem,
- GlDropdownText,
+ GlCollapsibleListbox,
},
props: {
value: {
@@ -41,17 +38,20 @@ export default {
project.name.toLowerCase().includes(lowerCasedFilterTerm),
);
},
+ listboxItems() {
+ return this.filteredResults.map(({ id, name }) => ({ value: id, text: name }));
+ },
selectedProject() {
return this.sortedProjects.find((project) => project.id === this.targetProjectId) || {};
},
},
methods: {
- selectProject(project) {
- this.$emit('selectProject', project.id);
- this.filterTerm = project.name; // when we select a project, we want the dropdown to filter to the selected project
- },
- isSelected(selectedProject) {
- return selectedProject === this.selectedProject;
+ selectProject(value) {
+ this.$emit('selectProject', value);
+
+ // when we select a project, we want the dropdown to filter to the selected project
+ const project = this.listboxItems.find((x) => x.value === value);
+ this.filterTerm = project?.text || '';
},
filterTermChanged(value) {
this.filterTerm = value;
@@ -60,28 +60,15 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="selectedProject.name" :header-text="$options.i18n.projectHeaderTitle">
- <gl-search-box-by-type
- :value="filterTerm"
- trim
- autocomplete="off"
- :placeholder="$options.i18n.projectSearchPlaceholder"
- data-testid="dropdown-search-box"
- @input="filterTermChanged"
- />
- <gl-dropdown-item
- v-for="project in filteredResults"
- :key="project.name"
- :name="project.name"
- :is-checked="isSelected(project)"
- is-check-item
- data-testid="dropdown-item"
- @click="selectProject(project)"
- >
- {{ project.name }}
- </gl-dropdown-item>
- <gl-dropdown-text v-if="!filteredResults.length" data-testid="empty-result-message">
- <span class="gl-text-gray-500">{{ $options.i18n.noResultsMessage }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
+ <gl-collapsible-listbox
+ :header-text="$options.i18n.projectHeaderTitle"
+ :items="listboxItems"
+ searchable
+ :search-placeholder="$options.i18n.projectSearchPlaceholder"
+ :selected="selectedProject.id"
+ :toggle-text="selectedProject.name"
+ :no-results-text="$options.i18n.noResultsMessage"
+ @search="filterTermChanged"
+ @select="selectProject"
+ />
</template>
diff --git a/app/assets/javascripts/projects/commit/store/getters.js b/app/assets/javascripts/projects/commit/store/getters.js
index e0c36df8a75..b039ee3ba63 100644
--- a/app/assets/javascripts/projects/commit/store/getters.js
+++ b/app/assets/javascripts/projects/commit/store/getters.js
@@ -1,7 +1,7 @@
-import { uniq } from 'lodash';
+import { uniq, uniqBy } from 'lodash';
export const joinedBranches = (state) => {
return uniq(state.branches).sort();
};
-export const sortedProjects = (state) => uniq(state.projects).sort();
+export const sortedProjects = (state) => uniqBy(state.projects, 'id').sort();
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index 0256eec6d56..dafc4bc5abf 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -6,6 +6,7 @@ import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
+import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import { formatStages } from '../utils';
import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql';
@@ -91,7 +92,8 @@ export default {
},
computed: {
downstreamPipelines() {
- return this.pipeline?.downstream?.nodes;
+ const downstream = this.pipeline?.downstream?.nodes;
+ return keepLatestDownstreamPipelines(downstream);
},
pipelinePath() {
return this.pipeline?.path ?? '';
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
index c6a0d48626a..9257cc7de7b 100644
--- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
@@ -18,6 +18,10 @@ query getLinkedPipelines($fullPath: ID!, $iid: ID!) {
icon
label
}
+ sourceJob {
+ id
+ retried
+ }
}
}
upstream {
diff --git a/app/assets/javascripts/projects/merge_requests/index.js b/app/assets/javascripts/projects/merge_requests/index.js
deleted file mode 100644
index 25a70121d68..00000000000
--- a/app/assets/javascripts/projects/merge_requests/index.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Vue from 'vue';
-import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue';
-
-export const initReportAbuse = () => {
- const el = document.getElementById('js-report-abuse-dropdown-item');
-
- if (!el) return false;
-
- const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset;
-
- return new Vue({
- el,
- provide: { reportAbusePath, reportedUserId, reportedFromUrl },
- render(createElement) {
- return createElement(ReportAbuseDropdownItem);
- },
- });
-};
diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js
index eeef1fb5afc..4f62aa29ce4 100644
--- a/app/assets/javascripts/projects/project_name_rules.js
+++ b/app/assets/javascripts/projects/project_name_rules.js
@@ -1,28 +1,29 @@
import { __ } from '~/locale';
-const rulesReg = [
- {
- reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u,
- msg: __("Name must start with a letter, digit, emoji, or '_'"),
- },
- {
- reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u,
- msg: __("Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces"),
- },
-];
+export const START_RULE = {
+ reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u,
+ msg: __('Name must start with a letter, digit, emoji, or underscore.'),
+};
+
+export const CONTAINS_RULE = {
+ reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u,
+ msg: __(
+ 'Name can contain only lowercase or uppercase letters, digits, emojis, spaces, dots, underscores, dashes, or pluses.',
+ ),
+};
+
+const rulesReg = [START_RULE, CONTAINS_RULE];
/**
*
* @param {string} text
* @returns {string} msg
*/
-function checkRules(text) {
+export const checkRules = (text) => {
for (const item of rulesReg) {
if (!item.reg.test(text)) {
return item.msg;
}
}
return '';
-}
-
-export { checkRules };
+};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index d71e80dffcf..99ea02aaa4f 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -90,13 +90,16 @@ const validateGroupNamespaceDropdown = (e) => {
const checkProjectName = (projectNameInput) => {
const msg = checkRules(projectNameInput.value);
- const projectNameError = document.querySelector('#project_name_error');
+ const projectNameError = document.querySelector('#js-project-name-error');
+ const projectNameDescription = document.getElementById('js-project-name-description');
if (!projectNameError) return;
if (msg) {
projectNameError.innerText = msg;
- projectNameError.classList.remove('hidden');
+ projectNameError.classList.remove('gl-display-none');
+ projectNameDescription.classList.add('gl-display-none');
} else {
- projectNameError.classList.add('hidden');
+ projectNameError.classList.add('gl-display-none');
+ projectNameDescription.classList.remove('gl-display-none');
}
};
diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js
index 84b8936c17f..2dd5f821d90 100644
--- a/app/assets/javascripts/projects/project_visibility.js
+++ b/app/assets/javascripts/projects/project_visibility.js
@@ -44,21 +44,6 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) {
});
}
-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) {
- const el = document.querySelector('.select2.js-select-namespace');
- el.addEventListener('change', () => handleSelect2DropdownChange(namespaceSelector));
- handleSelect2DropdownChange(namespaceSelector);
- }
}
diff --git a/app/assets/javascripts/projects/prune_objects_button.js b/app/assets/javascripts/projects/prune_objects_button.js
new file mode 100644
index 00000000000..dba73f6a19d
--- /dev/null
+++ b/app/assets/javascripts/projects/prune_objects_button.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import PruneUnreachableObjectsButton from './prune_unreachable_objects_button.vue';
+
+export default (selector = '#js-project-prune-unreachable-objects-button') => {
+ const el = document.querySelector(selector);
+
+ if (!el) return;
+
+ const { pruneObjectsPath, pruneObjectsDocPath } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(PruneUnreachableObjectsButton, {
+ props: {
+ pruneObjectsPath,
+ pruneObjectsDocPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/prune_unreachable_objects_button.vue b/app/assets/javascripts/projects/prune_unreachable_objects_button.vue
new file mode 100644
index 00000000000..1387fbb78c0
--- /dev/null
+++ b/app/assets/javascripts/projects/prune_unreachable_objects_button.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlButton, GlLink, GlModal, GlModalDirective } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlModal,
+ },
+ PRUNE_UNREACHABLE_OBJECTS_MODAL_ID: 'prune-objects-modal',
+ MODAL_ACTION_PRIMARY: {
+ text: s__('UpdateProject|Prune'),
+ attributes: [{ variant: 'danger' }],
+ },
+ MODAL_ACTION_CANCEL: {
+ text: s__('UpdateProject|Cancel'),
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ pruneObjectsPath: {
+ type: String,
+ required: true,
+ },
+ pruneObjectsDocPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ csrfToken() {
+ return csrf.token;
+ },
+ },
+ methods: {
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+
+<template>
+ <form ref="form" :action="pruneObjectsPath" method="post">
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ <input value="true" type="hidden" name="prune" />
+ <gl-modal
+ :modal-id="$options.PRUNE_UNREACHABLE_OBJECTS_MODAL_ID"
+ :title="s__('UpdateProject|Are you sure you want to prune unreachable objects?')"
+ :action-primary="$options.MODAL_ACTION_PRIMARY"
+ :action-cancel="$options.MODAL_ACTION_CANCEL"
+ size="sm"
+ :no-focus-on-show="true"
+ @ok="submitForm"
+ >
+ <p>
+ {{ s__('UpdateProject|Pruning unreachable objects can lead to repository corruption.') }}
+ <gl-link :href="pruneObjectsDocPath" target="_blank">
+ {{ s__('UpdateProject|Learn more.') }}
+ </gl-link>
+ {{ s__('UpdateProject|Are you sure you want to prune?') }}
+ </p>
+ </gl-modal>
+ <gl-button
+ v-gl-modal="$options.PRUNE_UNREACHABLE_OBJECTS_MODAL_ID"
+ category="primary"
+ variant="danger"
+ >
+ {{ s__('UpdateProject|Prune unreachable objects') }}
+ </gl-button>
+ </form>
+</template>
diff --git a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue b/app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue
index 31890249f41..ff76ca7c862 100644
--- a/app/assets/javascripts/projects/merge_requests/components/report_abuse_dropdown_item.vue
+++ b/app/assets/javascripts/projects/report_abuse/components/report_abuse_dropdown_item.vue
@@ -12,6 +12,7 @@ export default {
MountingPortal,
AbuseCategorySelector,
},
+ inject: ['reportedUserId', 'reportedFromUrl'],
i18n: {
reportAbuse: s__('ReportAbuse|Report abuse to administrator'),
},
@@ -21,21 +22,23 @@ export default {
};
},
methods: {
- openDrawer() {
- this.open = true;
- },
- closeDrawer() {
- this.open = false;
+ toggleDrawer(open) {
+ this.open = open;
},
},
};
</script>
<template>
<span>
- <gl-dropdown-item @click="openDrawer">{{ $options.i18n.reportAbuse }}</gl-dropdown-item>
+ <gl-dropdown-item @click="toggleDrawer(true)">{{ $options.i18n.reportAbuse }}</gl-dropdown-item>
<mounting-portal mount-to="#js-report-abuse-drawer" name="abuse-category-selector" append>
- <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" />
+ <abuse-category-selector
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedFromUrl"
+ :show-drawer="open"
+ @close-drawer="toggleDrawer(false)"
+ />
</mounting-portal>
</span>
</template>
diff --git a/app/assets/javascripts/projects/report_abuse/index.js b/app/assets/javascripts/projects/report_abuse/index.js
new file mode 100644
index 00000000000..9bcfdbf6165
--- /dev/null
+++ b/app/assets/javascripts/projects/report_abuse/index.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import ReportAbuseDropdownItem from './components/report_abuse_dropdown_item.vue';
+
+export const initReportAbuse = () => {
+ const items = document.querySelectorAll('.js-report-abuse-dropdown-item');
+
+ items.forEach((el) => {
+ if (!el) return false;
+
+ const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'ReportAbuseDropdownItemRoot',
+ provide: {
+ reportAbusePath,
+ reportedUserId: parseInt(reportedUserId, 10),
+ reportedFromUrl,
+ },
+ render(createElement) {
+ return createElement(ReportAbuseDropdownItem);
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue
index 541923bb735..95e140f30a9 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/edit/protections/push_protections.vue
@@ -4,7 +4,7 @@ import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export const i18n = {
- allowedToPush: s__('BranchRules|Allowed to push'),
+ allowedToPush: s__('BranchRules|Allowed to push and merge'),
forcePushTitle: s__(
'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.',
),
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
index 61c37a2348a..a98c2439cde 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
@@ -1,10 +1,10 @@
import { s__ } from '~/locale';
export const I18N = {
- manageProtectionsLinkTitle: s__('BranchRules|Manage in Protected Branches'),
- targetBranch: s__('BranchRules|Target Branch'),
+ manageProtectionsLinkTitle: s__('BranchRules|Manage in protected branches'),
+ targetBranch: s__('BranchRules|Target branch'),
branchNameOrPattern: s__('BranchRules|Branch name or pattern'),
- branch: s__('BranchRules|Target Branch'),
+ branch: s__('BranchRules|Target branch'),
allBranches: s__('BranchRules|All branches'),
matchingBranchesLinkTitle: s__('BranchRules|%{total} matching %{subject}'),
protectBranchTitle: s__('BranchRules|Protect branch'),
@@ -20,7 +20,7 @@ export const I18N = {
),
disallowForcePushDescription: s__('BranchRules|Force push is not allowed.'),
approvalsTitle: s__('BranchRules|Approvals'),
- manageApprovalsLinkTitle: s__('BranchRules|Manage in Merge Request Approvals'),
+ manageApprovalsLinkTitle: s__('BranchRules|Manage in merge request approvals'),
approvalsDescription: s__(
'BranchRules|Approvals to ensure separation of duties for new merge requests. %{linkStart}Learn more.%{linkEnd}',
),
@@ -28,9 +28,9 @@ export const I18N = {
statusChecksDescription: s__(
'BranchRules|Check for a status response in merge requests. Failures do not block merges. %{linkStart}Learn more.%{linkEnd}',
),
- statusChecksLinkTitle: s__('BranchRules|Manage in Status checks'),
+ statusChecksLinkTitle: s__('BranchRules|Manage in status checks'),
statusChecksHeader: s__('BranchRules|Status checks (%{total})'),
- allowedToPushHeader: s__('BranchRules|Allowed to push (%{total})'),
+ allowedToPushHeader: s__('BranchRules|Allowed to push and merge (%{total})'),
allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'),
approvalsHeader: s__('BranchRules|Required approvals (%{total})'),
noData: s__('BranchRules|No data to display'),
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
index 6260c8dd4d0..740868e1d75 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
@@ -3,7 +3,7 @@ import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { sprintf, n__ } from '~/locale';
import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility';
import { helpPagePath } from '~/helpers/help_page_helper';
-import branchRulesQuery from '../../queries/branch_rules_details.query.graphql';
+import branchRulesQuery from 'ee_else_ce/projects/settings/branch_rules/queries/branch_rules_details.query.graphql';
import { getAccessLevels } from '../../../utils';
import Protection from './protection.vue';
import {
@@ -12,22 +12,16 @@ import {
BRANCH_PARAM_NAME,
WILDCARDS_HELP_PATH,
PROTECTED_BRANCHES_HELP_PATH,
- APPROVALS_HELP_PATH,
- STATUS_CHECKS_HELP_PATH,
} from './constants';
const wildcardsHelpDocLink = helpPagePath(WILDCARDS_HELP_PATH);
const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH);
-const approvalsHelpDocLink = helpPagePath(APPROVALS_HELP_PATH);
-const statusChecksHelpDocLink = helpPagePath(STATUS_CHECKS_HELP_PATH);
export default {
name: 'RuleView',
i18n: I18N,
wildcardsHelpDocLink,
protectedBranchesHelpDocLink,
- approvalsHelpDocLink,
- statusChecksHelpDocLink,
components: { Protection, GlSprintf, GlLink, GlLoadingIcon },
inject: {
projectPath: {
@@ -36,12 +30,6 @@ export default {
protectedBranchesPath: {
default: '',
},
- approvalRulesPath: {
- default: '',
- },
- statusChecksPath: {
- default: '',
- },
branchesPath: {
default: '',
},
@@ -58,7 +46,7 @@ export default {
const branchRule = branchRules.nodes.find((rule) => rule.name === this.branch);
this.branchRule = branchRule;
this.branchProtection = branchRule?.branchProtection;
- this.approvalRules = branchRule?.approvalRules;
+ this.approvalRules = branchRule?.approvalRules?.nodes || [];
this.statusChecks = branchRule?.externalStatusChecks?.nodes || [];
this.matchingBranchesCount = branchRule?.matchingBranchesCount;
},
@@ -98,20 +86,6 @@ export default {
total: this.pushAccessLevels?.total || 0,
});
},
- approvalsHeader() {
- const total = this.approvals.reduce(
- (sum, { approvalsRequired }) => sum + approvalsRequired,
- 0,
- );
- return sprintf(this.$options.i18n.approvalsHeader, {
- total,
- });
- },
- statusChecksHeader() {
- return sprintf(this.$options.i18n.statusChecksHeader, {
- total: this.statusChecks.length,
- });
- },
allBranches() {
return this.branch === ALL_BRANCHES_WILDCARD;
},
@@ -131,8 +105,13 @@ export default {
const subject = n__('branch', 'branches', total);
return sprintf(this.$options.i18n.matchingBranchesLinkTitle, { total, subject });
},
- approvals() {
- return this.approvalRules?.nodes || [];
+ // needed to override EE component
+ statusChecksHeader() {
+ return '';
+ },
+ // needed to override EE component
+ approvalsHeader() {
+ return '';
},
},
methods: {
@@ -199,40 +178,46 @@ export default {
:groups="mergeAccessLevels.groups"
/>
+ <!-- EE start -->
<!-- Approvals -->
- <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4>
- <gl-sprintf :message="$options.i18n.approvalsDescription">
- <template #link="{ content }">
- <gl-link :href="$options.approvalsHelpDocLink">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <template v-if="approvalsHeader">
+ <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.approvalsTitle }}</h4>
+ <gl-sprintf :message="$options.i18n.approvalsDescription">
+ <template #link="{ content }">
+ <gl-link :href="$options.approvalsHelpDocLink">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
- <protection
- class="gl-mt-3"
- :header="approvalsHeader"
- :header-link-title="$options.i18n.manageApprovalsLinkTitle"
- :header-link-href="approvalRulesPath"
- :approvals="approvals"
- />
+ <protection
+ class="gl-mt-3"
+ :header="approvalsHeader"
+ :header-link-title="$options.i18n.manageApprovalsLinkTitle"
+ :header-link-href="approvalRulesPath"
+ :approvals="approvalRules"
+ />
+ </template>
<!-- Status checks -->
- <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4>
- <gl-sprintf :message="$options.i18n.statusChecksDescription">
- <template #link="{ content }">
- <gl-link :href="$options.statusChecksHelpDocLink">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <template v-if="statusChecksHeader">
+ <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.statusChecksTitle }}</h4>
+ <gl-sprintf :message="$options.i18n.statusChecksDescription">
+ <template #link="{ content }">
+ <gl-link :href="$options.statusChecksHelpDocLink">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
- <protection
- class="gl-mt-3"
- :header="statusChecksHeader"
- :header-link-title="$options.i18n.statusChecksLinkTitle"
- :header-link-href="statusChecksPath"
- :status-checks="statusChecks"
- />
+ <protection
+ class="gl-mt-3"
+ :header="statusChecksHeader"
+ :header-link-title="$options.i18n.statusChecksLinkTitle"
+ :header-link-href="statusChecksPath"
+ :status-checks="statusChecks"
+ />
+ </template>
+ <!-- EE end -->
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
index 7639acc1181..081d6cec958 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import View from './components/view/index.vue';
+import View from 'ee_else_ce/projects/settings/branch_rules/components/view/index.vue';
export default function mountBranchRules(el) {
if (!el) {
diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql
index a832e59aa67..aa736469749 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql
+++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql
@@ -4,24 +4,14 @@ query getBranchRulesDetails($projectPath: ID!) {
branchRules {
nodes {
name
+ matchingBranchesCount
branchProtection {
allowForcePush
- codeOwnerApprovalRequired
mergeAccessLevels {
edges {
node {
accessLevel
accessLevelDescription
- group {
- id
- avatarUrl
- }
- user {
- id
- name
- avatarUrl
- webUrl
- }
}
}
}
@@ -30,45 +20,10 @@ query getBranchRulesDetails($projectPath: ID!) {
node {
accessLevel
accessLevelDescription
- group {
- id
- avatarUrl
- }
- user {
- id
- name
- avatarUrl
- webUrl
- }
- }
- }
- }
- }
- approvalRules {
- nodes {
- id
- name
- type
- approvalsRequired
- eligibleApprovers {
- nodes {
- id
- name
- username
- webUrl
- avatarUrl
}
}
}
}
- externalStatusChecks {
- nodes {
- id
- name
- externalUrl
- }
- }
- matchingBranchesCount
}
}
}
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index 9b669024a8b..f3d392a0ec4 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,23 +1,22 @@
<script>
-import { s__ } from '~/locale';
+import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql';
+import { expandSection } from '~/settings_panels';
+import { scrollToElement } from '~/lib/utils/common_utils';
import BranchRule from './components/branch_rule.vue';
-
-export const i18n = {
- queryError: s__(
- 'ProtectedBranch|An error occurred while loading branch rules. Please try again.',
- ),
- emptyState: s__(
- 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.',
- ),
-};
+import { I18N, PROTECTED_BRANCHES_ANCHOR, BRANCH_PROTECTION_MODAL_ID } from './constants';
export default {
name: 'BranchRules',
- i18n,
+ i18n: I18N,
components: {
BranchRule,
+ GlButton,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
},
apollo: {
branchRules: {
@@ -36,20 +35,27 @@ export default {
},
},
inject: {
- projectPath: {
- default: '',
- },
+ projectPath: { default: '' },
},
data() {
return {
branchRules: [],
};
},
+ methods: {
+ showProtectedBranches() {
+ // Protected branches section is on the same page as the branch rules section.
+ expandSection(this.$options.protectedBranchesAnchor);
+ scrollToElement(this.$options.protectedBranchesAnchor);
+ },
+ },
+ modalId: BRANCH_PROTECTION_MODAL_ID,
+ protectedBranchesAnchor: PROTECTED_BRANCHES_ANCHOR,
};
</script>
<template>
- <div class="settings-content">
+ <div class="settings-content gl-mb-0">
<branch-rule
v-for="(rule, index) in branchRules"
:key="`${rule.name}-${index}`"
@@ -61,6 +67,21 @@ export default {
:matching-branches-count="rule.matchingBranchesCount"
/>
- <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span>
+ <div v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</div>
+
+ <gl-button v-gl-modal="$options.modalId" class="gl-mt-5" category="secondary" variant="info">{{
+ $options.i18n.addBranchRule
+ }}</gl-button>
+
+ <gl-modal
+ :ref="$options.modalId"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.addBranchRule"
+ :ok-title="$options.i18n.createProtectedBranch"
+ @ok="showProtectedBranches"
+ >
+ <p>{{ $options.i18n.branchRuleModalDescription }}</p>
+ <p>{{ $options.i18n.branchRuleModalContent }}</p>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
index 4a24df4b0dc..fa96eee5f92 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
@@ -13,7 +13,7 @@ export const i18n = {
approvalRules: s__('BranchRules|%{total} approval %{subject}'),
matchingBranches: s__('BranchRules|%{total} matching %{subject}'),
pushAccessLevels: s__('BranchRules|Allowed to merge'),
- mergeAccessLevels: s__('BranchRules|Allowed to push'),
+ mergeAccessLevels: s__('BranchRules|Allowed to push and merge'),
};
export default {
@@ -106,7 +106,7 @@ export default {
},
approvalDetails() {
const approvalDetails = [];
- if (this.isWildcard) {
+ if (this.isWildcard || this.matchingBranchesCount > 1) {
approvalDetails.push(this.matchingBranchesText);
}
if (this.branchProtection?.allowForcePush) {
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js
new file mode 100644
index 00000000000..4413d8eab4e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/constants.js
@@ -0,0 +1,22 @@
+import { s__ } from '~/locale';
+
+export const I18N = {
+ queryError: s__(
+ 'ProtectedBranch|An error occurred while loading branch rules. Please try again.',
+ ),
+ emptyState: s__(
+ 'ProtectedBranch|After you configure a protected branch, merge request approval, or status check, it appears here.',
+ ),
+ addBranchRule: s__('BranchRules|Add branch rule'),
+ branchRuleModalDescription: s__(
+ 'BranchRules|To create a branch rule, you first need to create a protected branch.',
+ ),
+ branchRuleModalContent: s__(
+ 'BranchRules|After a protected branch is created, it will show up in the list as a branch rule.',
+ ),
+ createProtectedBranch: s__('BranchRules|Create protected branch'),
+};
+
+export const PROTECTED_BRANCHES_ANCHOR = '#js-protected-branches-settings';
+
+export const BRANCH_PROTECTION_MODAL_ID = 'addBranchRuleModal';
diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js
index 7bcfde39178..ea4574119c0 100644
--- a/app/assets/javascripts/projects/settings/utils.js
+++ b/app/assets/javascripts/projects/settings/utils.js
@@ -9,7 +9,7 @@ export const getAccessLevels = (accessLevels = {}) => {
} else if (node.group) {
accessLevelTypes.groups.push(node);
} else {
- accessLevelTypes.roles.push(node);
+ accessLevelTypes.roles.push({ accessLevelDescription: node.accessLevelDescription });
}
});
diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue
deleted file mode 100644
index 52d1ed96b21..00000000000
--- a/app/assets/javascripts/ref/components/ref_results_section.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<script>
-import { GlDropdownSectionHeader, GlDropdownItem, GlBadge, GlIcon } from '@gitlab/ui';
-import { s__ } from '~/locale';
-
-export default {
- name: 'RefResultsSection',
- components: {
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlBadge,
- GlIcon,
- },
- props: {
- showHeader: {
- type: Boolean,
- required: false,
- default: true,
- },
-
- sectionTitle: {
- type: String,
- required: true,
- },
-
- totalCount: {
- type: Number,
- required: true,
- },
-
- /**
- * An array of object that have the following properties:
- *
- * - name (String, required): The name of the ref that will be displayed
- * - value (String, optional): The value that will be selected when the ref
- * is selected. If not provided, `name` will be used as the value.
- * For example, commits use the short SHA for `name`
- * and long SHA for `value`.
- * - subtitle (String, optional): Text to render underneath the name.
- * For example, used to render the commit's title underneath its SHA.
- * - default (Boolean, optional): Whether or not to render a "default"
- * indicator next to the item. Used to indicate
- * the project's default branch.
- *
- */
- items: {
- type: Array,
- required: true,
- validator: (items) => Array.isArray(items) && items.every((item) => item.name),
- },
-
- /**
- * The currently selected ref.
- * Used to render a check mark by the selected item.
- * */
- selectedRef: {
- type: String,
- required: false,
- default: '',
- },
-
- /**
- * An error object that indicates that an error
- * occurred while fetching items for this section
- */
- error: {
- type: Error,
- required: false,
- default: null,
- },
-
- /** The message to display if an error occurs */
- errorMessage: {
- type: String,
- required: false,
- default: '',
- },
- shouldShowCheck: {
- type: Boolean,
- required: false,
- default: true,
- },
- },
- computed: {
- totalCountText() {
- return this.totalCount > 999 ? s__('TotalRefCountIndicator|1000+') : `${this.totalCount}`;
- },
- },
- methods: {
- showCheck(item) {
- if (!this.shouldShowCheck) {
- return false;
- }
- return item.name === this.selectedRef || item.value === this.selectedRef;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-dropdown-section-header v-if="showHeader">
- <div class="gl-display-flex align-items-center" data-testid="section-header">
- <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span>
- <gl-badge variant="neutral">{{ totalCountText }}</gl-badge>
- </div>
- </gl-dropdown-section-header>
- <template v-if="error">
- <div class="gl-display-flex align-items-start text-danger gl-ml-4 gl-mr-4 gl-mb-3">
- <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" />
- <span>{{ errorMessage }}</span>
- </div>
- </template>
- <template v-else>
- <gl-dropdown-item
- v-for="item in items"
- :key="item.name"
- @click="$emit('selected', item.value || item.name)"
- >
- <div class="gl-display-flex align-items-start">
- <gl-icon
- name="mobile-issue-close"
- class="gl-mr-2 gl-flex-shrink-0"
- :class="{ 'gl-visibility-hidden': !showCheck(item) }"
- />
-
- <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column">
- <span class="gl-font-monospace">{{ item.name }}</span>
- <span class="gl-text-gray-400">{{ item.subtitle }}</span>
- </div>
-
- <gl-badge v-if="item.default" size="sm" variant="info">{{
- s__('DefaultBranchLabel|default')
- }}</gl-badge>
- </div>
- </gl-dropdown-item>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 10967fb84ed..359909b8f3b 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -1,13 +1,8 @@
<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlSearchBoxByType,
- GlSprintf,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlBadge, GlIcon, GlCollapsibleListbox } from '@gitlab/ui';
import { debounce, isArray } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import { sprintf } from '~/locale';
import {
ALL_REF_TYPES,
SEARCH_DEBOUNCE_MS,
@@ -15,21 +10,16 @@ import {
REF_TYPE_BRANCHES,
REF_TYPE_TAGS,
REF_TYPE_COMMITS,
- BRANCH_REF_TYPE,
- TAG_REF_TYPE,
} from '../constants';
import createStore from '../stores';
-import RefResultsSection from './ref_results_section.vue';
+import { formatListBoxItems, formatErrors } from '../format_refs';
export default {
name: 'RefSelector',
components: {
- GlDropdown,
- GlDropdownDivider,
- GlSearchBoxByType,
- GlSprintf,
- GlLoadingIcon,
- RefResultsSection,
+ GlBadge,
+ GlIcon,
+ GlCollapsibleListbox,
},
inheritAttrs: false,
props: {
@@ -87,6 +77,11 @@ export default {
required: false,
default: '',
},
+ toggleButtonClass: {
+ type: [String, Object, Array],
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -106,35 +101,33 @@ export default {
...this.translations,
};
},
- showBranchesSection() {
- return (
- this.enabledRefTypes.includes(REF_TYPE_BRANCHES) &&
- Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error)
- );
+ listBoxItems() {
+ return formatListBoxItems(this.branches, this.tags, this.commits);
},
- showTagsSection() {
- return (
- this.enabledRefTypes.includes(REF_TYPE_TAGS) &&
- Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error)
- );
+ branches() {
+ return this.enabledRefTypes.includes(REF_TYPE_BRANCHES) ? this.matches.branches.list : [];
},
- showCommitsSection() {
- return (
- this.enabledRefTypes.includes(REF_TYPE_COMMITS) &&
- Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error)
- );
+ tags() {
+ return this.enabledRefTypes.includes(REF_TYPE_TAGS) ? this.matches.tags.list : [];
},
- showNoResults() {
- return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection;
+ commits() {
+ return this.enabledRefTypes.includes(REF_TYPE_COMMITS) ? this.matches.commits.list : [];
},
- showSectionHeaders() {
- return this.enabledRefTypes.length > 1;
- },
- toggleButtonClass() {
- return {
- 'gl-inset-border-1-red-500!': !this.state,
- 'gl-font-monospace': Boolean(this.selectedRef),
- };
+ extendedToggleButtonClass() {
+ const classes = [
+ {
+ 'gl-inset-border-1-red-500!': !this.state,
+ 'gl-font-monospace': Boolean(this.selectedRef),
+ },
+ ];
+
+ if (Array.isArray(this.toggleButtonClass)) {
+ classes.push(...this.toggleButtonClass);
+ } else {
+ classes.push(this.toggleButtonClass);
+ }
+
+ return classes;
},
footerSlotProps() {
return {
@@ -143,6 +136,9 @@ export default {
query: this.lastQuery,
};
},
+ errors() {
+ return formatErrors(this.matches.branches, this.matches.tags, this.matches.commits);
+ },
selectedRefForDisplay() {
if (this.useSymbolicRefNames && this.selectedRef) {
return this.selectedRef.replace(/^refs\/(tags|heads)\//, '');
@@ -153,11 +149,12 @@ export default {
buttonText() {
return this.selectedRefForDisplay || this.i18n.noRefSelected;
},
- isTagRefType() {
- return this.refType === TAG_REF_TYPE;
- },
- isBranchRefType() {
- return this.refType === BRANCH_REF_TYPE;
+ noResultsMessage() {
+ return this.lastQuery
+ ? sprintf(this.i18n.noResultsWithQuery, {
+ query: this.lastQuery,
+ })
+ : this.i18n.noResults;
},
},
watch: {
@@ -185,9 +182,7 @@ export default {
// because we need to access the .cancel() method
// lodash attaches to the function, which is
// made inaccessible by Vue.
- this.debouncedSearch = debounce(function search() {
- this.search();
- }, SEARCH_DEBOUNCE_MS);
+ this.debouncedSearch = debounce(this.search, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
@@ -214,14 +209,8 @@ export default {
'setSelectedRef',
]),
...mapActions({ storeSearch: 'search' }),
- focusSearchBox() {
- this.$refs.searchBox.$el.querySelector('input').focus();
- },
- onSearchBoxEnter() {
- this.debouncedSearch.cancel();
- this.search();
- },
- onSearchBoxInput() {
+ onSearchBoxInput(searchQuery = '') {
+ this.query = searchQuery?.trim();
this.debouncedSearch();
},
selectRef(ref) {
@@ -231,104 +220,55 @@ export default {
search() {
this.storeSearch(this.query);
},
+ totalCountText(count) {
+ return count > 999 ? this.i18n.totalCountLabel : `${count}`;
+ },
},
};
</script>
<template>
<div>
- <gl-dropdown
- :header-text="i18n.dropdownHeader"
- :toggle-class="toggleButtonClass"
- :text="buttonText"
+ <gl-collapsible-listbox
class="ref-selector gl-w-full"
+ block
+ searchable
+ :selected="selectedRef"
+ :header-text="i18n.dropdownHeader"
+ :items="listBoxItems"
+ :no-results-text="noResultsMessage"
+ :searching="isLoading"
+ :search-placeholder="i18n.searchPlaceholder"
+ :toggle-class="extendedToggleButtonClass"
+ :toggle-text="buttonText"
v-bind="$attrs"
v-on="$listeners"
- @shown="focusSearchBox"
+ @hidden="$emit('hide')"
+ @search="onSearchBoxInput"
+ @select="selectRef"
>
- <template #header>
- <gl-search-box-by-type
- ref="searchBox"
- v-model.trim="query"
- :placeholder="i18n.searchPlaceholder"
- autocomplete="off"
- data-qa-selector="ref_selector_searchbox"
- @input="onSearchBoxInput"
- @keydown.enter.prevent="onSearchBoxEnter"
- />
+ <template #group-label="{ group }">
+ {{ group.text }} <gl-badge size="sm">{{ totalCountText(group.options.length) }}</gl-badge>
</template>
-
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" />
-
- <div
- v-else-if="showNoResults"
- class="gl-text-center gl-mx-3 gl-py-3"
- data-testid="no-results"
- >
- <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery">
- <template #query>
- <b class="gl-word-break-all">{{ lastQuery }}</b>
- </template>
- </gl-sprintf>
-
- <span v-else>{{ i18n.noResults }}</span>
- </div>
-
- <template v-else>
- <template v-if="showBranchesSection">
- <ref-results-section
- :section-title="i18n.branches"
- :total-count="matches.branches.totalCount"
- :items="matches.branches.list"
- :selected-ref="selectedRef"
- :error="matches.branches.error"
- :error-message="i18n.branchesErrorMessage"
- :show-header="showSectionHeaders"
- data-testid="branches-section"
- data-qa-selector="branches_section"
- :should-show-check="!useSymbolicRefNames || isBranchRefType"
- @selected="selectRef($event)"
- />
-
- <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" />
- </template>
-
- <template v-if="showTagsSection">
- <ref-results-section
- :section-title="i18n.tags"
- :total-count="matches.tags.totalCount"
- :items="matches.tags.list"
- :selected-ref="selectedRef"
- :error="matches.tags.error"
- :error-message="i18n.tagsErrorMessage"
- :show-header="showSectionHeaders"
- data-testid="tags-section"
- :should-show-check="!useSymbolicRefNames || isTagRefType"
- @selected="selectRef($event)"
- />
-
- <gl-dropdown-divider v-if="showCommitsSection" />
- </template>
-
- <template v-if="showCommitsSection">
- <ref-results-section
- :section-title="i18n.commits"
- :total-count="matches.commits.totalCount"
- :items="matches.commits.list"
- :selected-ref="selectedRef"
- :error="matches.commits.error"
- :error-message="i18n.commitsErrorMessage"
- :show-header="showSectionHeaders"
- data-testid="commits-section"
- @selected="selectRef($event)"
- />
- </template>
+ <template #list-item="{ item }">
+ {{ item.text }}
+ <gl-badge v-if="item.default" size="sm" variant="info">{{
+ i18n.defaultLabelText
+ }}</gl-badge>
</template>
-
<template #footer>
<slot name="footer" v-bind="footerSlotProps"></slot>
+ <div
+ v-for="errorMessage in errors"
+ :key="errorMessage"
+ data-testid="red-selector-error-list"
+ class="gl-display-flex gl-align-items-flex-start gl-text-red-500 gl-mx-4 gl-my-3"
+ >
+ <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" />
+ <span>{{ errorMessage }}</span>
+ </div>
</template>
- </gl-dropdown>
+ </gl-collapsible-listbox>
<input
v-if="name"
data-testid="selected-ref-form-field"
diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js
index f4faa535166..4b5b18cf6c1 100644
--- a/app/assets/javascripts/ref/constants.js
+++ b/app/assets/javascripts/ref/constants.js
@@ -1,5 +1,5 @@
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES';
export const REF_TYPE_TAGS = 'REF_TYPE_TAGS';
@@ -13,6 +13,7 @@ export const X_TOTAL_HEADER = 'x-total';
export const SEARCH_DEBOUNCE_MS = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const DEFAULT_I18N = Object.freeze({
+ defaultLabelText: __('default'),
dropdownHeader: __('Select Git revision'),
searchPlaceholder: __('Search by Git revision'),
noResultsWithQuery: __('No matching results for "%{query}"'),
@@ -24,4 +25,5 @@ export const DEFAULT_I18N = Object.freeze({
tags: __('Tags'),
commits: __('Commits'),
noRefSelected: __('No ref selected'),
+ totalCountLabel: s__('TotalRefCountIndicator|1000+'),
});
diff --git a/app/assets/javascripts/ref/format_refs.js b/app/assets/javascripts/ref/format_refs.js
new file mode 100644
index 00000000000..af310a35ef4
--- /dev/null
+++ b/app/assets/javascripts/ref/format_refs.js
@@ -0,0 +1,60 @@
+import { DEFAULT_I18N } from './constants';
+
+function convertToListBoxItems(items) {
+ return items.map((item) => ({
+ text: item.name,
+ value: item.value || item.name,
+ default: item.default,
+ }));
+}
+
+/**
+ * Format multiple lists to array of group options for listbox
+ * @param branches list of branches
+ * @param tags list of tags
+ * @param commits list of commits
+ * @returns {*[]} array of group items with header and options
+ */
+export const formatListBoxItems = (branches, tags, commits) => {
+ const listBoxItems = [];
+
+ const addToFinalResult = (items, header) => {
+ if (items && items.length > 0) {
+ listBoxItems.push({
+ text: header,
+ options: convertToListBoxItems(items),
+ });
+ }
+ };
+
+ addToFinalResult(branches, DEFAULT_I18N.branches);
+ addToFinalResult(tags, DEFAULT_I18N.tags);
+ addToFinalResult(commits, DEFAULT_I18N.commits);
+
+ return listBoxItems;
+};
+
+/**
+ * Check error existence and add to final array
+ * @param branches list of branches
+ * @param tags list of tags
+ * @param commits list of commits
+ * @returns {*[]} array of error messages
+ */
+export const formatErrors = (branches, tags, commits) => {
+ const errorsList = [];
+
+ if (branches && branches.error) {
+ errorsList.push(DEFAULT_I18N.branchesErrorMessage);
+ }
+
+ if (tags && tags.error) {
+ errorsList.push(DEFAULT_I18N.tagsErrorMessage);
+ }
+
+ if (commits && commits.error) {
+ errorsList.push(DEFAULT_I18N.commitsErrorMessage);
+ }
+
+ return errorsList;
+};
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 102f1228355..adae92a92e9 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -1,10 +1,10 @@
<script>
import { GlFormGroup, GlFormRadioGroup, GlButton } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
- issuableTypesMap,
itemAddFailureTypesMap,
linkedIssueTypesMap,
addRelatedIssueErrorMap,
@@ -54,7 +54,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: issuableTypesMap.ISSUE,
+ default: TYPE_ISSUE,
},
hasError: {
type: Boolean,
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 09ecad2d90e..8d6a3110f35 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -1,11 +1,11 @@
<script>
import $ from 'jquery';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import { TYPE_ISSUE } from '~/issues/constants';
import {
autoCompleteTextMap,
inputPlaceholderConfidentialTextMap,
inputPlaceholderTextMap,
- issuableTypesMap,
} from '../constants';
import IssueToken from './issue_token.vue';
@@ -54,7 +54,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: issuableTypesMap.ISSUE,
+ default: TYPE_ISSUE,
},
confidential: {
type: Boolean,
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 11de734f5d4..7387b9ab87c 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import Sortable from 'sortablejs';
import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue';
+import { TYPE_ISSUE } from '~/issues/constants';
import { defaultSortableOptions } from '~/sortable/constants';
export default {
@@ -88,7 +89,7 @@ export default {
document.body.classList.remove('is-dragging');
},
issuableOrderingId({ epicIssueId, id }) {
- return this.issuableType === 'issue' ? epicIssueId : id;
+ return this.issuableType === TYPE_ISSUE ? epicIssueId : id;
},
},
};
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index 795eb3b0083..ed70e1ce8a8 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -25,12 +25,13 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
*/
import { createAlert } from '~/flash';
import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/issues/constants';
+import { HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status';
import { __ } from '~/locale';
import {
relatedIssuesRemoveErrorMap,
pathIndeterminateErrorMap,
addRelatedIssueErrorMap,
- issuableTypesMap,
PathIdSeparator,
} from '../constants';
import RelatedIssuesService from '../services/related_issues_service';
@@ -65,7 +66,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: issuableTypesMap.ISSUE,
+ default: TYPE_ISSUE,
},
allowAutoComplete: {
type: Boolean,
@@ -142,7 +143,7 @@ export default {
this.store.setRelatedIssues(data.issuables);
})
.catch((res) => {
- if (res && res.status !== 404) {
+ if (res && res.status !== HTTP_STATUS_NOT_FOUND) {
createAlert({ message: relatedIssuesRemoveErrorMap[this.issuableType] });
}
});
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index d1b2d41d7ae..2a4ce70511b 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -1,4 +1,5 @@
import { __, sprintf } from '~/locale';
+import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
export const issuableTypesMap = {
ISSUE: 'issue',
@@ -21,7 +22,7 @@ export const linkedIssueTypesTextMap = {
export const autoCompleteTextMap = {
true: {
- [issuableTypesMap.ISSUE]: sprintf(
+ [TYPE_ISSUE]: sprintf(
__(' or %{emphasisStart}#issue id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
false,
@@ -31,7 +32,7 @@ export const autoCompleteTextMap = {
{ emphasisStart: '<', emphasisEnd: '>' },
false,
),
- [issuableTypesMap.EPIC]: sprintf(
+ [TYPE_EPIC]: sprintf(
__(' or %{emphasisStart}&epic id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
false,
@@ -43,33 +44,33 @@ export const autoCompleteTextMap = {
),
},
false: {
- [issuableTypesMap.ISSUE]: '',
- [issuableTypesMap.EPIC]: '',
- [issuableTypesMap.MERGE_REQUEST]: __(' or references (e.g. path/to/project!merge_request_id)'),
+ [TYPE_ISSUE]: '',
+ [TYPE_EPIC]: '',
+ [issuableTypesMap.MERGE_REQUEST]: __(' or references'),
},
};
export const inputPlaceholderTextMap = {
- [issuableTypesMap.ISSUE]: __('Paste issue link'),
+ [TYPE_ISSUE]: __('Paste issue link'),
[issuableTypesMap.INCIDENT]: __('Paste link'),
- [issuableTypesMap.EPIC]: __('Paste epic link'),
+ [TYPE_EPIC]: __('Paste epic link'),
[issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
};
export const inputPlaceholderConfidentialTextMap = {
- [issuableTypesMap.ISSUE]: __('Paste confidential issue link'),
- [issuableTypesMap.EPIC]: __('Paste confidential epic link'),
+ [TYPE_ISSUE]: __('Paste confidential issue link'),
+ [TYPE_EPIC]: __('Paste confidential epic link'),
[issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
};
export const relatedIssuesRemoveErrorMap = {
- [issuableTypesMap.ISSUE]: __('An error occurred while removing issues.'),
- [issuableTypesMap.EPIC]: __('An error occurred while removing epics.'),
+ [TYPE_ISSUE]: __('An error occurred while removing issues.'),
+ [TYPE_EPIC]: __('An error occurred while removing epics.'),
};
export const pathIndeterminateErrorMap = {
- [issuableTypesMap.ISSUE]: __('We could not determine the path to remove the issue'),
- [issuableTypesMap.EPIC]: __('We could not determine the path to remove the epic'),
+ [TYPE_ISSUE]: __('We could not determine the path to remove the issue'),
+ [TYPE_EPIC]: __('We could not determine the path to remove the epic'),
};
export const itemAddFailureTypesMap = {
@@ -78,8 +79,8 @@ export const itemAddFailureTypesMap = {
};
export const addRelatedIssueErrorMap = {
- [issuableTypesMap.ISSUE]: __('Issue cannot be found.'),
- [issuableTypesMap.EPIC]: __('Epic cannot be found.'),
+ [TYPE_ISSUE]: __('Issue cannot be found.'),
+ [TYPE_EPIC]: __('Epic cannot be found.'),
};
export const addRelatedItemErrorMap = {
@@ -94,9 +95,9 @@ export const addRelatedItemErrorMap = {
* them inside i18n functions.
*/
export const issuableIconMap = {
- [issuableTypesMap.ISSUE]: 'issues',
+ [TYPE_ISSUE]: 'issues',
[issuableTypesMap.INCIDENT]: 'issues',
- [issuableTypesMap.EPIC]: 'epic',
+ [TYPE_EPIC]: 'epic',
};
export const PathIdSeparator = {
@@ -105,30 +106,30 @@ export const PathIdSeparator = {
};
export const issuablesBlockHeaderTextMap = {
- [issuableTypesMap.ISSUE]: __('Linked items'),
+ [TYPE_ISSUE]: __('Linked items'),
[issuableTypesMap.INCIDENT]: __('Linked incidents or issues'),
- [issuableTypesMap.EPIC]: __('Linked epics'),
+ [TYPE_EPIC]: __('Linked epics'),
};
export const issuablesBlockHelpTextMap = {
- [issuableTypesMap.ISSUE]: __('Learn more about linking issues'),
+ [TYPE_ISSUE]: __('Learn more about linking issues'),
[issuableTypesMap.INCIDENT]: __('Learn more about linking issues and incidents'),
- [issuableTypesMap.EPIC]: __('Learn more about linking epics'),
+ [TYPE_EPIC]: __('Learn more about linking epics'),
};
export const issuablesBlockAddButtonTextMap = {
- [issuableTypesMap.ISSUE]: __('Add a related issue'),
- [issuableTypesMap.EPIC]: __('Add a related epic'),
+ [TYPE_ISSUE]: __('Add a related issue'),
+ [TYPE_EPIC]: __('Add a related epic'),
};
export const issuablesFormCategoryHeaderTextMap = {
- [issuableTypesMap.ISSUE]: __('The current issue'),
+ [TYPE_ISSUE]: __('The current issue'),
[issuableTypesMap.INCIDENT]: __('The current incident'),
- [issuableTypesMap.EPIC]: __('The current epic'),
+ [TYPE_EPIC]: __('The current epic'),
};
export const issuablesFormInputTextMap = {
- [issuableTypesMap.ISSUE]: __('the following issues'),
+ [TYPE_ISSUE]: __('the following issues'),
[issuableTypesMap.INCIDENT]: __('the following incidents or issues'),
- [issuableTypesMap.EPIC]: __('the following epics'),
+ [TYPE_EPIC]: __('the following epics'),
};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index c77a67c4287..cc00ef10dda 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -1,9 +1,10 @@
import Vue from 'vue';
-import apolloProvider from '~/issues/show/graphql';
+import { TYPE_ISSUE } from '~/issues/constants';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedIssuesRoot from './components/related_issues_root.vue';
-export function initRelatedIssues(issueType = 'issue') {
+export function initRelatedIssues(issueType = TYPE_ISSUE) {
const el = document.querySelector('.js-related-issues-root');
if (!el) {
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 965b9fa09d6..ff92cdd42c6 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -13,6 +13,7 @@ import { isSameOriginUrl, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
+import { putCreateReleaseNotification } from '~/releases/release_notification_service';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import AssetLinksForm from './asset_links_form.vue';
import ConfirmDeleteModal from './confirm_delete_modal.vue';
@@ -49,6 +50,7 @@ export default {
'newMilestonePath',
'manageMilestonesPath',
'projectId',
+ 'projectPath',
'groupId',
'groupMilestonesAvailable',
'tagNotes',
@@ -150,6 +152,7 @@ export default {
submitForm() {
if (!this.isFormSubmissionDisabled) {
this.saveRelease();
+ putCreateReleaseNotification(this.projectPath, this.release.name);
}
},
},
@@ -161,7 +164,7 @@ export default {
<gl-sprintf
:message="
__(
- 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.',
+ 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}1.0.0%{codeEnd}, %{codeStart}2.1.0-pre%{codeEnd}.',
)
"
>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 1b360b79b0c..9f200856db3 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -244,21 +244,19 @@ export default {
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column gl-mt-3">
- <div class="gl-align-self-end gl-mb-3">
+ <releases-empty-state v-if="shouldRenderEmptyState" />
+ <div v-else class="gl-align-self-end gl-mb-3">
<releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" />
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
- :aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="confirm"
>{{ $options.i18n.newRelease }}</gl-button
>
</div>
- <releases-empty-state v-if="shouldRenderEmptyState" />
-
<release-block
v-for="(release, index) in releases"
:key="getReleaseKey(release, index)"
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 7147cfa01c8..544f2de5132 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,6 +1,7 @@
<script>
import { createAlert } from '~/flash';
import { s__ } from '~/locale';
+import { popCreateReleaseNotification } from '~/releases/release_notification_service';
import oneReleaseQuery from '../graphql/queries/one_release.query.graphql';
import { convertGraphQLRelease } from '../util';
import ReleaseBlock from './release_block.vue';
@@ -49,6 +50,9 @@ export default {
},
},
},
+ mounted() {
+ popCreateReleaseNotification(this.fullPath);
+ },
methods: {
showFlash(error) {
createAlert({
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 6d415471b14..2118c26fd81 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -67,12 +67,13 @@ export default {
<gl-link
v-gl-tooltip
class="d-flex align-items-center monospace"
- :title="__('Download evidence JSON')"
- :download="evidenceTitle(index)"
+ target="_blank"
+ :title="__('Open evidence JSON in new tab')"
:href="evidenceUrl(index)"
>
<gl-icon name="review-list" class="align-middle gl-mr-3" />
<span>{{ evidenceTitle(index) }}</span>
+ <gl-icon name="external-link" class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0" />
</gl-link>
<expand-button>
diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue
index 1761f4360d1..cc28980a6bf 100644
--- a/app/assets/javascripts/releases/components/release_block_assets.vue
+++ b/app/assets/javascripts/releases/components/release_block_assets.vue
@@ -121,7 +121,7 @@ export default {
<gl-icon :name="section.iconName" class="gl-mr-2 gl-flex-shrink-0 gl-flex-grow-0" />
{{ link.name }}
<gl-icon
- v-if="link.external"
+ v-if="section.title"
v-gl-tooltip
name="external-link"
:aria-label="$options.externalLinkTooltipText"
diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue
index 800497c186a..ae94bd6872e 100644
--- a/app/assets/javascripts/releases/components/releases_empty_state.vue
+++ b/app/assets/javascripts/releases/components/releases_empty_state.vue
@@ -1,44 +1,33 @@
<script>
-import { GlEmptyState, GlLink } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlEmptyState } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
name: 'ReleasesEmptyState',
components: {
GlEmptyState,
- GlLink,
- },
- inject: {
- documentationPath: {
- default: '',
- },
- illustrationPath: {
- default: '',
- },
},
+ inject: ['documentationPath', 'illustrationPath', 'newReleasePath'],
i18n: {
- emptyStateTitle: __('Getting started with releases'),
- emptyStateText: __(
- "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
+ emptyStateTitle: s__('Release|Getting started with releases'),
+ emptyStateText: s__(
+ "Release|Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
),
- releasesDocumentation: __('Releases documentation'),
- moreInformation: __('More information'),
+ releasesDocumentation: s__('Release|Learn more about releases'),
+ moreInformation: s__('Release|More information'),
+ newRelease: s__('Release|Create a new release'),
},
};
</script>
<template>
- <gl-empty-state :title="$options.i18n.emptyStateTitle" :svg-path="illustrationPath">
- <template #description>
- <span id="releases-description">
- {{ $options.i18n.emptyStateText }}
- <gl-link
- :href="documentationPath"
- :aria-label="$options.i18n.releasesDocumentation"
- target="_blank"
- >
- {{ $options.i18n.moreInformation }}
- </gl-link>
- </span>
- </template>
- </gl-empty-state>
+ <gl-empty-state
+ class="gl-layout-w-limited"
+ :title="$options.i18n.emptyStateTitle"
+ :description="$options.i18n.emptyStateText"
+ :svg-path="illustrationPath"
+ :primary-button-link="newReleasePath"
+ :primary-button-text="$options.i18n.newRelease"
+ :secondary-button-link="documentationPath"
+ :secondary-button-text="$options.i18n.releasesDocumentation"
+ />
</template>
diff --git a/app/assets/javascripts/releases/release_notification_service.js b/app/assets/javascripts/releases/release_notification_service.js
new file mode 100644
index 00000000000..a4f926d7561
--- /dev/null
+++ b/app/assets/javascripts/releases/release_notification_service.js
@@ -0,0 +1,23 @@
+import { s__, sprintf } from '~/locale';
+import { createAlert, VARIANT_SUCCESS } from '~/flash';
+
+const createReleaseSessionKey = (projectPath) => `createRelease:${projectPath}`;
+
+export const putCreateReleaseNotification = (projectPath, releaseName) => {
+ window.sessionStorage.setItem(createReleaseSessionKey(projectPath), releaseName);
+};
+
+export const popCreateReleaseNotification = (projectPath) => {
+ const key = createReleaseSessionKey(projectPath);
+ const createdRelease = window.sessionStorage.getItem(key);
+
+ if (createdRelease) {
+ createAlert({
+ message: sprintf(s__('Release|Release %{createdRelease} has been successfully created.'), {
+ createdRelease,
+ }),
+ variant: VARIANT_SUCCESS,
+ });
+ window.sessionStorage.removeItem(key);
+ }
+};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index f80e75501c9..ccd168aafc9 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -40,6 +40,7 @@ export default {
[types.UPDATE_RELEASE_TAG_NAME](state, tagName) {
state.release.tagName = tagName;
+ state.existingRelease = null;
},
[types.UPDATE_RELEASE_TAG_MESSAGE](state, tagMessage) {
state.release.tagMessage = tagMessage;
@@ -118,6 +119,7 @@ export default {
state.fetchError = error;
state.isFetchingTagNotes = false;
state.tagNotes = '';
+ state.existingRelease = null;
},
[types.UPDATE_INCLUDE_TAG_NOTES](state, includeTagNotes) {
state.includeTagNotes = includeTagNotes;
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index a480710f8ac..68b2cf6f3da 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -4,7 +4,7 @@ const viewers = {
image: () => import('./image_viewer.vue'),
video: () => import('./video_viewer.vue'),
empty: () => import('./empty_viewer.vue'),
- text: () => import('~/vue_shared/components/source_viewer/source_viewer.vue'),
+ text: () => import('~/vue_shared/components/source_viewer/source_viewer_deprecated.vue'),
pdf: () => import('./pdf_viewer.vue'),
lfs: () => import('./lfs_viewer.vue'),
audio: () => import('./audio_viewer.vue'),
diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue
index 980fa140eb5..9804837b200 100644
--- a/app/assets/javascripts/repository/components/fork_info.vue
+++ b/app/assets/javascripts/repository/components/fork_info.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIcon, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import { s__, sprintf, n__ } from '~/locale';
import { createAlert } from '~/flash';
import forkDetailsQuery from '../queries/fork_details.query.graphql';
@@ -9,9 +9,9 @@ export const i18n = {
inaccessibleProject: s__('ForkedFromProjectPath|Forked from an inaccessible project.'),
upToDate: s__('ForksDivergence|Up to date with the upstream repository.'),
unknown: s__('ForksDivergence|This fork has diverged from the upstream repository.'),
- behind: s__('ForksDivergence|%{behind} %{commit_word} behind'),
- ahead: s__('ForksDivergence|%{ahead} %{commit_word} ahead of'),
- behindAndAhead: s__('ForksDivergence|%{messages} the upstream repository.'),
+ behind: s__('ForksDivergence|%{behindLinkStart}%{behind} %{commit_word} behind%{behindLinkEnd}'),
+ ahead: s__('ForksDivergence|%{aheadLinkStart}%{ahead} %{commit_word} ahead%{aheadLinkEnd} of'),
+ behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'),
error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'),
};
@@ -20,6 +20,7 @@ export default {
components: {
GlIcon,
GlLink,
+ GlSprintf,
GlSkeletonLoader,
},
apollo: {
@@ -28,7 +29,7 @@ export default {
variables() {
return {
projectPath: this.projectPath,
- ref: this.selectedRef,
+ ref: this.selectedBranch,
};
},
skip() {
@@ -48,7 +49,7 @@ export default {
type: String,
required: true,
},
- selectedRef: {
+ selectedBranch: {
type: String,
required: true,
},
@@ -62,6 +63,16 @@ export default {
required: false,
default: '',
},
+ aheadComparePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ behindComparePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -116,7 +127,7 @@ export default {
return this.$options.i18n.unknown;
}
if (this.hasBehindAheadMessage) {
- return sprintf(this.$options.i18n.behindAndAhead, {
+ return sprintf(this.$options.i18n.behindAhead, {
messages: this.behindAheadMessage,
});
}
@@ -134,8 +145,15 @@ export default {
{{ $options.i18n.forkedFrom }}
<gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link>
<gl-skeleton-loader v-if="isLoading" :lines="1" />
- <div v-else class="gl-text-secondary">
- {{ forkDivergenceMessage }}
+ <div v-else class="gl-text-secondary" data-testid="divergence-message">
+ <gl-sprintf :message="forkDivergenceMessage">
+ <template #aheadLink="{ content }">
+ <gl-link :href="aheadComparePath">{{ content }}</gl-link>
+ </template>
+ <template #behindLink="{ content }">
+ <gl-link :href="behindComparePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</div>
</div>
<div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex">
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 8feac6b8e35..90949536cc1 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -14,7 +14,6 @@ export default {
url: this.blob.webPath,
};
},
- loadingKey: 'loading',
},
},
components: {
@@ -34,9 +33,13 @@ export default {
data() {
return {
readme: null,
- loading: 0,
};
},
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.readme.loading;
+ },
+ },
watch: {
readme(newVal) {
if (newVal) {
@@ -64,7 +67,7 @@ export default {
</div>
</div>
<div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about">
- <gl-loading-icon v-if="loading > 0" size="lg" color="dark" class="my-4 mx-auto" />
+ <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="my-4 mx-auto" />
<div
v-else-if="readme"
ref="readme"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 27ac11f3c58..6dd059a349f 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -203,7 +203,7 @@ export default {
:is="linkComponent"
ref="link"
v-gl-hover-load="handlePreload"
- v-gl-tooltip:tooltip-container
+ v-gl-tooltip="{ placement: 'left', boundary: 'viewport' }"
:title="fullPath"
:to="routerLinkTo"
:href="url"
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index e5d22f50d72..494e270a66c 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -69,7 +69,7 @@ export default function setupVueRepositoryList() {
if (!forkEl) {
return null;
}
- const { sourceName, sourcePath } = forkEl.dataset;
+ const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset;
return new Vue({
el: forkEl,
apolloProvider,
@@ -77,9 +77,11 @@ export default function setupVueRepositoryList() {
return h(ForkInfo, {
props: {
projectPath,
- selectedRef: ref,
+ selectedBranch: ref,
sourceName,
sourcePath,
+ aheadComparePath,
+ behindComparePath,
},
});
},
@@ -131,7 +133,7 @@ export default function setupVueRepositoryList() {
},
on: {
input(selectedRef) {
- visitUrl(generateRefDestinationPath(projectRootPath, selectedRef));
+ visitUrl(generateRefDestinationPath(projectRootPath, ref, selectedRef));
},
},
});
diff --git a/app/assets/javascripts/repository/mixins/highlight_mixin.js b/app/assets/javascripts/repository/mixins/highlight_mixin.js
new file mode 100644
index 00000000000..95d0c55bb04
--- /dev/null
+++ b/app/assets/javascripts/repository/mixins/highlight_mixin.js
@@ -0,0 +1,106 @@
+import { nextTick } from 'vue';
+import {
+ LEGACY_FALLBACKS,
+ EVENT_ACTION,
+ EVENT_LABEL_FALLBACK,
+ LINES_PER_CHUNK,
+} from '~/vue_shared/components/source_viewer/constants';
+import { splitIntoChunks } from '~/vue_shared/components/source_viewer/workers/highlight_utils';
+import LineHighlighter from '~/blob/line_highlighter';
+import languageLoader from '~/content_editor/services/highlight_js_language_loader';
+import Tracking from '~/tracking';
+import { TEXT_FILE_TYPE } from '../constants';
+
+/*
+ * This mixin is intended to be used as an interface between our highlight worker and Vue components
+ */
+export default {
+ mixins: [Tracking.mixin()],
+ inject: {
+ highlightWorker: { default: null },
+ },
+ data() {
+ return {
+ chunks: [],
+ };
+ },
+ methods: {
+ trackEvent(label, language) {
+ this.track(EVENT_ACTION, { label, property: language });
+ },
+ isUnsupportedLanguage(language) {
+ const supportedLanguages = Object.keys(languageLoader);
+ const isUnsupportedLanguage = !supportedLanguages.includes(language);
+
+ return LEGACY_FALLBACKS.includes(language) || isUnsupportedLanguage;
+ },
+ handleUnsupportedLanguage(language) {
+ this.trackEvent(EVENT_LABEL_FALLBACK, language);
+ this?.onError();
+ },
+ initHighlightWorker({ rawTextBlob, language, simpleViewer }) {
+ if (simpleViewer?.fileType !== TEXT_FILE_TYPE) return;
+
+ if (this.isUnsupportedLanguage(language)) {
+ this.handleUnsupportedLanguage(language);
+ return;
+ }
+
+ /*
+ * We want to start rendering content as soon as possible, but highlighting large amounts of
+ * content can take long, so we render the content in phases:
+ *
+ * 1. `splitIntoChunks` with the first 70 lines of raw text.
+ * This ensures that we start rendering raw content in the DOM as soon as we can so that
+ * the user can see content as fast as possible (improves perceived performance and LCP).
+ * 2. `instructWorker` to start highlighting the first 70 lines.
+ * This ensures that we display highlighted** content to the user as fast as possible
+ * (improves perceived performance and makes the first 70 lines look nice).
+ * 3. `instructWorker` to start highlighting all the content.
+ * This is the longest task. It ensures that we highlight all content, since the first 70
+ * lines are already rendered, this can happen in the background.
+ */
+
+ // Render the first 70 lines (raw text) ASAP, this improves perceived performance and LCP.
+ const firstSeventyLines = rawTextBlob.split(/\r?\n/).slice(0, LINES_PER_CHUNK).join('\n');
+
+ this.chunks = splitIntoChunks(language, firstSeventyLines);
+
+ this.highlightWorker.onmessage = this.handleWorkerMessage;
+
+ // Instruct the worker to highlight the first 70 lines ASAP, this improves perceived performance.
+ this.instructWorker(firstSeventyLines, language);
+
+ // Instruct the worker to start highlighting all lines in the background.
+ this.instructWorker(rawTextBlob, language);
+ },
+ handleWorkerMessage({ data }) {
+ this.chunks = data;
+ this.highlightHash(); // highlight the line if a line number hash is present in the URL
+ },
+ instructWorker(content, language) {
+ this.highlightWorker.postMessage({ content, language });
+ },
+ async highlightHash() {
+ const { hash } = this.$route;
+ if (!hash) return;
+
+ // Make the chunk containing the line number visible
+ const lineNumber = hash.substring(hash.indexOf('L') + 1).split('-')[0];
+ const chunkToHighlight = this.chunks.find(
+ (chunk) =>
+ chunk.startingFrom <= lineNumber && chunk.startingFrom + chunk.totalLines >= lineNumber,
+ );
+
+ if (chunkToHighlight) {
+ chunkToHighlight.isHighlighted = true;
+ }
+
+ // Line numbers in the DOM needs to update first based on changes made to `chunks`.
+ await nextTick();
+
+ const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ lineHighlighter.highlightHash(hash);
+ },
+ },
+};
diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js
index f296b5e9b4a..c62f7f709c4 100644
--- a/app/assets/javascripts/repository/utils/ref_switcher_utils.js
+++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js
@@ -5,9 +5,9 @@ import { joinPaths } from '~/lib/utils/url_utility';
* Example: /root/Flight/-/blob/fix/main/test/spec/utils_spec.js
* Group 1: /-/blob
* Group 2: blob
- * Group 3: main/test/spec/utils_spec.js
+ * Group 3: /test/spec/utils_spec.js
*/
-const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/;
+const getNamespaceTargetRegex = (ref) => new RegExp(`(/-/(blob|tree))/${ref}/(.*)`);
/**
* Generates a ref destination path based on the selected ref and current path.
@@ -15,11 +15,12 @@ const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/;
* @param {string} projectRootPath - The root path for a project.
* @param {string} selectedRef - The selected ref from the ref dropdown.
*/
-export function generateRefDestinationPath(projectRootPath, selectedRef) {
+export function generateRefDestinationPath(projectRootPath, ref, selectedRef) {
const currentPath = window.location.pathname;
const encodedHash = '%23';
let namespace = '/-/tree';
let target;
+ const NAMESPACE_TARGET_REGEX = getNamespaceTargetRegex(ref);
const match = NAMESPACE_TARGET_REGEX.exec(currentPath);
if (match) {
[, namespace, , target] = match;
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 7b5babdd3a6..87996d0bb85 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -7,6 +7,7 @@ export * from './api/namespaces_api';
export * from './api/tags_api';
export * from './api/alert_management_alerts_api';
export * from './api/harbor_registry';
+export * from './api/environments_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
diff --git a/app/assets/javascripts/saved_replies/components/app.vue b/app/assets/javascripts/saved_replies/components/app.vue
new file mode 100644
index 00000000000..db8476c44f3
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/components/app.vue
@@ -0,0 +1,23 @@
+<script>
+export default {};
+</script>
+
+<template>
+ <div class="row gl-mt-5">
+ <div class="col-lg-4">
+ <h4 class="gl-mt-0">
+ {{ __('Saved Replies') }}
+ </h4>
+ <p>
+ {{
+ __(
+ 'Saved replies can be used when creating comments inside issues, merge requests, and epics.',
+ )
+ }}
+ </p>
+ </div>
+ <div class="col-lg-8">
+ <router-view />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/saved_replies/components/list.vue b/app/assets/javascripts/saved_replies/components/list.vue
new file mode 100644
index 00000000000..30089cfa53f
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/components/list.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlKeysetPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import savedRepliesQuery from '../queries/saved_replies.query.graphql';
+import ListItem from './list_item.vue';
+
+export default {
+ apollo: {
+ savedReplies: {
+ query: savedRepliesQuery,
+ update: (r) => r.currentUser?.savedReplies?.nodes,
+ result({ data }) {
+ const pageInfo = data.currentUser?.savedReplies?.pageInfo;
+
+ this.count = data.currentUser?.savedReplies?.count;
+
+ if (pageInfo) {
+ this.pageInfo = pageInfo;
+ }
+ },
+ },
+ },
+ components: {
+ GlLoadingIcon,
+ GlKeysetPagination,
+ GlSprintf,
+ ListItem,
+ },
+ data() {
+ return {
+ savedReplies: [],
+ count: 0,
+ pageInfo: {},
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="$apollo.queries.savedReplies.loading" size="lg" />
+ <template v-else>
+ <h5 class="gl-font-lg" data-testid="title">
+ <gl-sprintf :message="__('My saved replies (%{count})')">
+ <template #count>{{ count }}</template>
+ </gl-sprintf>
+ </h5>
+ <ul class="gl-list-style-none gl-p-0 gl-m-0">
+ <list-item v-for="reply in savedReplies" :key="reply.id" :reply="reply" />
+ </ul>
+ <gl-keyset-pagination
+ v-if="pageInfo.hasPreviousPage || pageInfo.hasNextPage"
+ v-bind="pageInfo"
+ class="gl-mt-4"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/saved_replies/components/list_item.vue b/app/assets/javascripts/saved_replies/components/list_item.vue
new file mode 100644
index 00000000000..dfa9a405dee
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/components/list_item.vue
@@ -0,0 +1,19 @@
+<script>
+export default {
+ props: {
+ reply: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-mb-5">
+ <div class="gl-display-flex gl-align-items-center">
+ <strong>{{ reply.name }}</strong>
+ </div>
+ <div class="gl-mt-3 gl-font-monospace">{{ reply.content }}</div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/saved_replies/index.js b/app/assets/javascripts/saved_replies/index.js
new file mode 100644
index 00000000000..5022ff62b10
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import routes from './routes';
+import App from './components/app.vue';
+
+export const initSavedReplies = () => {
+ Vue.use(VueApollo);
+ Vue.use(VueRouter);
+
+ const el = document.getElementById('js-saved-replies-root');
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+ const router = new VueRouter({
+ base: el.dataset.basePath,
+ mode: 'history',
+ routes,
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ router,
+ apolloProvider,
+ render(h) {
+ return h(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/saved_replies/pages/index.vue b/app/assets/javascripts/saved_replies/pages/index.vue
new file mode 100644
index 00000000000..38f51dbc365
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/pages/index.vue
@@ -0,0 +1,15 @@
+<script>
+import List from '../components/list.vue';
+
+export default {
+ components: {
+ List,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <list />
+ </div>
+</template>
diff --git a/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
new file mode 100644
index 00000000000..af1f12f3ceb
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/queries/saved_replies.query.graphql
@@ -0,0 +1,19 @@
+query savedReplies {
+ currentUser {
+ id
+ savedReplies {
+ nodes {
+ id
+ name
+ content
+ }
+ count
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ endCursor
+ startCursor
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/saved_replies/routes.js b/app/assets/javascripts/saved_replies/routes.js
new file mode 100644
index 00000000000..bd582a5ed86
--- /dev/null
+++ b/app/assets/javascripts/saved_replies/routes.js
@@ -0,0 +1,8 @@
+import IndexComponent from './pages/index.vue';
+
+export default [
+ {
+ path: '/',
+ component: IndexComponent,
+ },
+];
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index d4ee857c9c1..d71785d7fac 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,6 +1,5 @@
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import { queryToObject } from '~/lib/utils/url_utility';
-import refreshCounts from '~/pages/search/show/refresh_counts';
import syntaxHighlight from '~/syntax_highlight';
import { initSidebar, sidebarInitState } from './sidebar';
import { initSearchSort } from './sort';
@@ -24,8 +23,4 @@ export const initSearchApp = () => {
setHighlightClass(query.search); // Code Highlighting
initBlobRefSwitcher(); // Code Search Branch Picker
-
- if (!gon.features?.searchPageVerticalNav) {
- refreshCounts(); // Other Scope Tab Counts
- }
};
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 6f29864c0a2..2efc80fef75 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -2,28 +2,34 @@
import { mapState } from 'vuex';
import ScopeNavigation from '~/search/sidebar/components/scope_navigation.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS } from '../constants';
+import { SCOPE_ISSUES, SCOPE_MERGE_REQUESTS, SCOPE_BLOB } from '../constants';
import ResultsFilters from './results_filters.vue';
+import LanguageFilter from './language_filter.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
ResultsFilters,
ScopeNavigation,
+ LanguageFilter,
},
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['urlQuery']),
- showFilters() {
+ showIssueAndMergeFilters() {
return this.urlQuery.scope === SCOPE_ISSUES || this.urlQuery.scope === SCOPE_MERGE_REQUESTS;
},
+ showBlobFilter() {
+ return this.urlQuery.scope === SCOPE_BLOB && this.glFeatures.searchBlobsLanguageAggregation;
+ },
},
};
</script>
<template>
<section class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5">
- <scope-navigation v-if="glFeatures.searchPageVerticalNav" />
- <results-filters v-if="showFilters" />
+ <scope-navigation />
+ <results-filters v-if="showIssueAndMergeFilters" />
+ <language-filter v-if="showBlobFilter" />
</section>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
new file mode 100644
index 00000000000..b580d58b21b
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/checkbox_filter.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlFormCheckboxGroup, GlFormCheckbox } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { intersection } from 'lodash';
+import { NAV_LINK_COUNT_DEFAULT_CLASSES, LABEL_DEFAULT_CLASSES } from '../constants';
+import { formatSearchResultCount } from '../../store/utils';
+
+export default {
+ name: 'CheckboxFilter',
+ components: {
+ GlFormCheckboxGroup,
+ GlFormCheckbox,
+ },
+ props: {
+ filterData: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['query']),
+ scope() {
+ return this.query.scope;
+ },
+ queryFilters() {
+ return this.query[this.filterData?.filterParam] || [];
+ },
+ dataFilters() {
+ return Object.values(this.filterData?.filters || []);
+ },
+ flatDataFilterValues() {
+ return this.dataFilters.map(({ value }) => value);
+ },
+ selectedFilter: {
+ get() {
+ return intersection(this.flatDataFilterValues, this.queryFilters);
+ },
+ set(value) {
+ this.setQuery({ key: this.filterData?.filterParam, value });
+ },
+ },
+ labelCountClasses() {
+ return [...NAV_LINK_COUNT_DEFAULT_CLASSES, 'gl-text-gray-500'];
+ },
+ },
+ methods: {
+ ...mapActions(['setQuery']),
+ getFormatedCount(count) {
+ return formatSearchResultCount(count);
+ },
+ },
+ NAV_LINK_COUNT_DEFAULT_CLASSES,
+ LABEL_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <div class="gl-mx-5">
+ <h5 class="gl-mt-0">{{ filterData.header }}</h5>
+ <gl-form-checkbox-group v-model="selectedFilter">
+ <gl-form-checkbox
+ v-for="f in dataFilters"
+ :key="f.label"
+ :value="f.label"
+ class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
+ :class="$options.LABEL_DEFAULT_CLASSES"
+ >
+ <span
+ class="gl-flex-grow-1 gl-display-inline-flex gl-justify-content-space-between gl-w-full"
+ >
+ <span data-testid="label">
+ {{ f.label }}
+ </span>
+ <span v-if="f.count" :class="labelCountClasses" data-testid="labelCount">
+ {{ getFormatedCount(f.count) }}
+ </span>
+ </span>
+ </gl-form-checkbox>
+ </gl-form-checkbox-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
index fbfc24a94ae..e7aa3d61409 100644
--- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import RadioFilter from './radio_filter.vue';
@@ -8,19 +7,13 @@ export default {
components: {
RadioFilter,
},
- mixins: [glFeatureFlagsMixin()],
- computed: {
- ffBasedXPadding() {
- return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0';
- },
- },
confidentialFilterData,
};
</script>
<template>
<div>
- <radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" />
+ <radio-filter class="gl-px-5" :filter-data="$options.confidentialFilterData" />
<hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/components/language_filter.vue b/app/assets/javascripts/search/sidebar/components/language_filter.vue
new file mode 100644
index 00000000000..26ce204cb5c
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/components/language_filter.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlButton, GlAlert, GlForm } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { __, s__, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { DEFAULT_ITEM_LENGTH, MAX_ITEM_LENGTH } from '../constants/language_filter_data';
+import { HR_DEFAULT_CLASSES, ONLY_SHOW_MD } from '../constants';
+import { convertFiltersData } from '../utils';
+import CheckboxFilter from './checkbox_filter.vue';
+
+export default {
+ name: 'LanguageFilter',
+ components: {
+ CheckboxFilter,
+ GlButton,
+ GlAlert,
+ GlForm,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ data() {
+ return {
+ showAll: false,
+ };
+ },
+ i18n: {
+ showMore: s__('GlobalSearch|Show more'),
+ apply: __('Apply'),
+ showingMax: sprintf(s__('GlobalSearch|Showing top %{maxItems}'), { maxItems: MAX_ITEM_LENGTH }),
+ loadError: s__('GlobalSearch|Aggregations load error.'),
+ },
+ computed: {
+ ...mapState(['aggregations', 'sidebarDirty']),
+ ...mapGetters(['langugageAggregationBuckets']),
+ hasBuckets() {
+ return this.langugageAggregationBuckets.length > 0;
+ },
+ filtersData() {
+ return convertFiltersData(this.shortenedLanguageFilters);
+ },
+ shortenedLanguageFilters() {
+ if (!this.hasShowMore) {
+ return this.langugageAggregationBuckets;
+ }
+ if (this.showAll) {
+ return this.trimBuckets(MAX_ITEM_LENGTH);
+ }
+ return this.trimBuckets(DEFAULT_ITEM_LENGTH);
+ },
+ hasShowMore() {
+ return this.langugageAggregationBuckets.length > DEFAULT_ITEM_LENGTH;
+ },
+ hasOverMax() {
+ return this.langugageAggregationBuckets.length > MAX_ITEM_LENGTH;
+ },
+ dividerClasses() {
+ return [...HR_DEFAULT_CLASSES, ...ONLY_SHOW_MD];
+ },
+ },
+ async created() {
+ await this.fetchLanguageAggregation();
+ },
+ methods: {
+ ...mapActions(['applyQuery', 'fetchLanguageAggregation']),
+ onShowMore() {
+ this.showAll = true;
+ },
+ trimBuckets(length) {
+ return this.langugageAggregationBuckets.slice(0, length);
+ },
+ },
+ HR_DEFAULT_CLASSES,
+};
+</script>
+
+<template>
+ <gl-form
+ v-if="hasBuckets"
+ class="gl-pt-5 gl-md-pt-0 language-filter-checkbox"
+ @submit.prevent="applyQuery"
+ >
+ <hr :class="dividerClasses" />
+ <div
+ v-if="!aggregations.error"
+ class="gl-overflow-x-hidden gl-overflow-y-auto"
+ :class="{ 'language-filter-max-height': showAll }"
+ >
+ <checkbox-filter class="gl-px-5" :filter-data="filtersData" />
+ <span v-if="showAll && hasOverMax" data-testid="has-over-max-text">{{
+ $options.i18n.showingMax
+ }}</span>
+ </div>
+ <gl-alert v-else class="gl-mx-5" variant="danger" :dismissible="false">{{
+ $options.i18n.loadError
+ }}</gl-alert>
+ <div v-if="hasShowMore && !showAll" class="gl-px-5 language-filter-show-all">
+ <gl-button
+ data-testid="show-more-button"
+ category="tertiary"
+ variant="link"
+ size="small"
+ button-text-classes="gl-font-sm"
+ @click="onShowMore"
+ >
+ {{ $options.i18n.showMore }}
+ </gl-button>
+ </div>
+ <div v-if="!aggregations.error">
+ <hr :class="$options.HR_DEFAULT_CLASSES" />
+ <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-mx-5 gl-px-5">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ type="submit"
+ :disabled="!sidebarDirty"
+ data-testid="apply-button"
+ >
+ {{ $options.i18n.apply }}
+ </gl-button>
+ </div>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue
index ff7a044736d..4d9cc9d6450 100644
--- a/app/assets/javascripts/search/sidebar/components/results_filters.vue
+++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton, GlLink } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import { stateFilterData } from '../constants/state_filter_data';
import ConfidentialityFilter from './confidentiality_filter.vue';
@@ -15,24 +14,17 @@ export default {
StatusFilter,
ConfidentialityFilter,
},
- mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['urlQuery', 'sidebarDirty']),
showReset() {
return this.urlQuery.state || this.urlQuery.confidential;
},
- searchPageVerticalNavFeatureFlag() {
- return this.glFeatures.searchPageVerticalNav;
- },
showConfidentialityFilter() {
return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope);
},
showStatusFilter() {
return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope);
},
- ffBasedXPadding() {
- return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0';
- },
},
methods: {
...mapActions(['applyQuery', 'resetQuery']),
@@ -42,13 +34,10 @@ export default {
<template>
<form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery">
- <hr
- v-if="searchPageVerticalNavFeatureFlag"
- class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block"
- />
+ <hr class="gl-my-5 gl-mx-5 gl-border-gray-100 gl-display-none gl-md-display-block" />
<status-filter v-if="showStatusFilter" />
<confidentiality-filter v-if="showConfidentialityFilter" />
- <div class="gl-display-flex gl-align-items-center gl-mt-4" :class="ffBasedXPadding">
+ <div class="gl-display-flex gl-align-items-center gl-mt-4 gl-px-5">
<gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty">
{{ __('Apply') }}
</gl-button>
diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
index 3c280a5d696..5863381e2ef 100644
--- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
+++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue
@@ -5,6 +5,7 @@ import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
import { formatSearchResultCount } from '../../store/utils';
+import { slugifyWithUnderscore } from '../../../lib/utils/text_utility';
export default {
name: 'ScopeNavigation',
@@ -46,6 +47,9 @@ export default {
isActive(scope, index) {
return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0;
},
+ qaSelectorValue(item) {
+ return `${slugifyWithUnderscore(item.label)}_tab`;
+ },
},
NAV_LINK_DEFAULT_CLASSES,
NAV_LINK_COUNT_DEFAULT_CLASSES,
@@ -62,6 +66,7 @@ export default {
class="gl-mb-1"
:href="item.link"
:active="isActive(scope, index)"
+ :data-qa-selector="qaSelectorValue(item)"
@click="handleClick(scope)"
><span>{{ item.label }}</span
><span v-if="item.count" :class="countClasses(isActive(scope, index))">
diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue
index 4da96a41ef7..c3deabfcc26 100644
--- a/app/assets/javascripts/search/sidebar/components/status_filter.vue
+++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { stateFilterData } from '../constants/state_filter_data';
import RadioFilter from './radio_filter.vue';
@@ -8,19 +7,13 @@ export default {
components: {
RadioFilter,
},
- mixins: [glFeatureFlagsMixin()],
- computed: {
- ffBasedXPadding() {
- return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0';
- },
- },
stateFilterData,
};
</script>
<template>
<div>
- <radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" />
+ <radio-filter class="gl-px-5" :filter-data="$options.stateFilterData" />
<hr class="gl-my-5 gl-mx-5 gl-border-gray-100" />
</div>
</template>
diff --git a/app/assets/javascripts/search/sidebar/constants/language_filter_data.js b/app/assets/javascripts/search/sidebar/constants/language_filter_data.js
new file mode 100644
index 00000000000..df44a58a14b
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/constants/language_filter_data.js
@@ -0,0 +1,18 @@
+import { s__ } from '~/locale';
+
+export const DEFAULT_ITEM_LENGTH = 10;
+export const MAX_ITEM_LENGTH = 100;
+
+const header = s__('GlobalSearch|Language');
+
+const scopes = {
+ BLOBS: 'blobs',
+};
+
+const filterParam = 'language';
+
+export const languageFilterData = {
+ header,
+ scopes,
+ filterParam,
+};
diff --git a/app/assets/javascripts/search/sidebar/utils.js b/app/assets/javascripts/search/sidebar/utils.js
new file mode 100644
index 00000000000..5c08ad2f959
--- /dev/null
+++ b/app/assets/javascripts/search/sidebar/utils.js
@@ -0,0 +1,20 @@
+import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
+
+export const convertFiltersData = (rawBuckets) => {
+ return rawBuckets.reduce(
+ (acc, bucket) => {
+ return {
+ ...acc,
+ filters: {
+ ...acc.filters,
+ [bucket.key.toUpperCase()]: {
+ label: bucket.key,
+ value: bucket.key,
+ count: bucket.count,
+ },
+ },
+ };
+ },
+ { ...languageFilterData, filters: {} },
+ );
+};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 2a1b744561d..fc0817be882 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -6,7 +6,13 @@ import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, SIDEBAR_PARAMS } from './constants';
import * as types from './mutation_types';
-import { loadDataFromLS, setFrequentItemToLS, mergeById, isSidebarDirty } from './utils';
+import {
+ loadDataFromLS,
+ setFrequentItemToLS,
+ mergeById,
+ isSidebarDirty,
+ getAggregationsUrl,
+} from './utils';
export const fetchGroups = ({ commit }, search) => {
commit(types.REQUEST_GROUPS);
@@ -95,7 +101,7 @@ export const setQuery = ({ state, commit }, { key, value }) => {
};
export const applyQuery = ({ state }) => {
- visitUrl(setUrlParams({ ...state.query, page: null }));
+ visitUrl(setUrlParams({ ...state.query, page: null }, window.location.href, false, true));
};
export const resetQuery = ({ state }) => {
@@ -117,3 +123,16 @@ export const fetchSidebarCount = ({ commit, state }) => {
});
return Promise.all(promises);
};
+
+export const fetchLanguageAggregation = ({ commit }) => {
+ commit(types.REQUEST_AGGREGATIONS);
+ return axios
+ .get(getAggregationsUrl())
+ .then(({ data }) => {
+ commit(types.RECEIVE_AGGREGATIONS_SUCCESS, data);
+ })
+ .catch((e) => {
+ logError(e);
+ commit(types.RECEIVE_AGGREGATIONS_ERROR);
+ });
+};
diff --git a/app/assets/javascripts/search/store/constants.js b/app/assets/javascripts/search/store/constants.js
index e4f67f624ca..ba4fe85db9d 100644
--- a/app/assets/javascripts/search/store/constants.js
+++ b/app/assets/javascripts/search/store/constants.js
@@ -1,5 +1,6 @@
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
+import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
export const MAX_FREQUENT_ITEMS = 5;
@@ -9,6 +10,10 @@ 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];
+export const SIDEBAR_PARAMS = [
+ stateFilterData.filterParam,
+ confidentialFilterData.filterParam,
+ languageFilterData.filterParam,
+];
export const NUMBER_FORMATING_OPTIONS = { notation: 'compact', compactDisplay: 'short' };
diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js
index 650af5fa55a..0278239c144 100644
--- a/app/assets/javascripts/search/store/getters.js
+++ b/app/assets/javascripts/search/store/getters.js
@@ -1,3 +1,4 @@
+import { languageFilterData } from '~/search/sidebar/constants/language_filter_data';
import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY } from './constants';
export const frequentGroups = (state) => {
@@ -7,3 +8,11 @@ export const frequentGroups = (state) => {
export const frequentProjects = (state) => {
return state.frequentItems[PROJECTS_LOCAL_STORAGE_KEY];
};
+
+export const langugageAggregationBuckets = (state) => {
+ return (
+ state.aggregations.data.find(
+ (aggregation) => aggregation.name === languageFilterData.filterParam,
+ )?.buckets || []
+ );
+};
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
index 511b93cad2b..4ffbadcd083 100644
--- a/app/assets/javascripts/search/store/mutation_types.js
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -11,3 +11,7 @@ export const SET_SIDEBAR_DIRTY = 'SET_SIDEBAR_DIRTY';
export const LOAD_FREQUENT_ITEMS = 'LOAD_FREQUENT_ITEMS';
export const RECEIVE_NAVIGATION_COUNT = 'RECEIVE_NAVIGATION_COUNT';
+
+export const REQUEST_AGGREGATIONS = 'REQUEST_AGGREGATIONS';
+export const RECEIVE_AGGREGATIONS_SUCCESS = 'RECEIVE_AGGREGATIONS_SUCCESS';
+export const RECEIVE_AGGREGATIONS_ERROR = 'RECEIVE_AGGREGATIONS_ERROR';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index c1339845272..f9fd69d2211 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -36,4 +36,13 @@ export default {
const item = { ...state.navigation[key], count };
state.navigation = { ...state.navigation, [key]: item };
},
+ [types.REQUEST_AGGREGATIONS](state) {
+ state.aggregations = { fetching: true, error: false, data: [] };
+ },
+ [types.RECEIVE_AGGREGATIONS_SUCCESS](state, data) {
+ state.aggregations = { fetching: false, error: false, data: [...data] };
+ },
+ [types.RECEIVE_AGGREGATIONS_ERROR](state) {
+ state.aggregations = { fetching: false, error: true, data: [] };
+ },
};
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index b64231a8688..d85a135bb4e 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -14,5 +14,11 @@ const createState = ({ query, navigation }) => ({
},
sidebarDirty: false,
navigation,
+ aggregations: {
+ error: false,
+ fetching: false,
+ data: [],
+ },
});
+
export default createState;
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 0629bea3239..da6039f4758 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -1,7 +1,6 @@
<script>
import { GlSearchBoxByClick, GlButton } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue';
@@ -31,7 +30,6 @@ export default {
ProjectFilter,
MarkdownDrawer,
},
- mixins: [glFeatureFlagsMixin()],
props: {
groupInitialJson: {
type: Object,
@@ -70,9 +68,6 @@ export default {
showSyntaxOptions() {
return this.elasticsearchEnabled && this.isDefaultBranch;
},
- hasVerticalNav() {
- return this.glFeatures.searchPageVerticalNav;
- },
isDefaultBranch() {
return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName;
},
@@ -130,6 +125,6 @@ export default {
<project-filter :initial-data="projectInitialJson" />
</div>
</div>
- <hr v-if="hasVerticalNav" class="gl-mt-5 gl-mb-0 gl-border-gray-100" />
+ <hr class="gl-mt-5 gl-mb-0 gl-border-gray-100" />
</section>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index 7828efc358a..3ebd21609a6 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -4,6 +4,7 @@ import { __, s__ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue';
+import SafeHtml from '~/vue_shared/directives/safe_html';
import AutoDevOpsAlert from './auto_dev_ops_alert.vue';
import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue';
import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants';
@@ -51,6 +52,7 @@ export default {
UserCalloutDismisser,
TrainingProviderList,
},
+ directives: { SafeHtml },
inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'],
props: {
augmentedSecurityFeatures: {
@@ -143,7 +145,7 @@ export default {
variant="danger"
@dismiss="dismissAlert"
>
- {{ errorMessage }}
+ <span v-safe-html="errorMessage"></span>
</gl-alert>
<local-storage-sync
v-model="autoDevopsEnabledAlertDismissedProjects"
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 77216408c39..c87dcef6a93 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -9,7 +9,6 @@ import {
REPORT_TYPE_SECRET_DETECTION,
REPORT_TYPE_DEPENDENCY_SCANNING,
REPORT_TYPE_CONTAINER_SCANNING,
- REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
REPORT_TYPE_CORPUS_MANAGEMENT,
REPORT_TYPE_API_FUZZING,
@@ -105,18 +104,6 @@ export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath(
{ anchor: 'configuration' },
);
-export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning');
-export const CLUSTER_IMAGE_SCANNING_DESCRIPTION = __(
- 'Check your Kubernetes cluster images for known vulnerabilities.',
-);
-export const CLUSTER_IMAGE_SCANNING_HELP_PATH = helpPagePath(
- 'user/application_security/cluster_image_scanning/index',
-);
-export const CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH = helpPagePath(
- 'user/application_security/cluster_image_scanning/index',
- { anchor: 'configuration' },
-);
-
export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing');
export const COVERAGE_FUZZING_DESCRIPTION = __(
'Find bugs in your code with coverage-guided fuzzing.',
@@ -153,7 +140,6 @@ export const SCANNER_NAMES_MAP = {
DAST: DAST_SHORT_NAME,
API_FUZZING: API_FUZZING_NAME,
CONTAINER_SCANNING: CONTAINER_SCANNING_NAME,
- CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME,
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
@@ -213,13 +199,6 @@ export const securityFeatures = [
type: REPORT_TYPE_CONTAINER_SCANNING,
},
{
- name: CLUSTER_IMAGE_SCANNING_NAME,
- description: CLUSTER_IMAGE_SCANNING_DESCRIPTION,
- helpPath: CLUSTER_IMAGE_SCANNING_HELP_PATH,
- configurationHelpPath: CLUSTER_IMAGE_SCANNING_CONFIG_HELP_PATH,
- type: REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
- },
- {
name: SECRET_DETECTION_NAME,
description: SECRET_DETECTION_DESCRIPTION,
helpPath: SECRET_DETECTION_HELP_PATH,
diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js
index f2c3f28cefa..654263ba27b 100644
--- a/app/assets/javascripts/service_ping_consent.js
+++ b/app/assets/javascripts/service_ping_consent.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { createAlert, hideFlash } from './flash';
+import { createAlert } from './flash';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
@@ -18,7 +18,7 @@ export default () => {
};
const hideConsentMessage = () =>
- hideFlash(document.querySelector('.service-ping-consent-message'));
+ document.querySelector('.service-ping-consent-message .js-close')?.click();
axios
.put(url, data)
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 240e12ee597..323f6f23df6 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -1,6 +1,6 @@
<script>
import { GlIcon } from '@gitlab/ui';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
@@ -19,7 +19,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
computed: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index d17c8a123d5..73cd0044c16 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
@@ -67,7 +67,7 @@ export default {
},
issuableType: {
type: String,
- default: 'issue',
+ default: TYPE_ISSUE,
required: false,
},
},
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index cf07752a0b8..5cdebee04ad 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -1,4 +1,5 @@
<script>
+import { TYPE_ISSUE } from '~/issues/constants';
import CollapsedAssigneeList from './collapsed_assignee_list.vue';
import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue';
@@ -22,7 +23,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
computed: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
index 46bda26c327..fab856883cc 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
@@ -1,4 +1,5 @@
<script>
+import { TYPE_ISSUE } from '~/issues/constants';
import AssigneeAvatar from './assignee_avatar.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -15,7 +16,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
computed: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index f894ef0c42d..d2f0ceb19c9 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import CollapsedAssignee from './collapsed_assignee.vue';
@@ -41,7 +42,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
computed: {
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index fd51cd5bb16..ed29ccb3447 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { n__ } from '~/locale';
import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue';
@@ -16,7 +17,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
signedIn: {
type: Boolean,
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 7979f450fdd..caf3bb2f798 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -1,6 +1,7 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/flash';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
@@ -34,7 +35,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
issuableIid: {
type: String,
@@ -63,7 +64,7 @@ export default {
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
- return this.issuableType === 'issue';
+ return this.issuableType === TYPE_ISSUE;
},
queryVariables() {
return {
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index d6c679f2f07..8893e90b1e5 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -2,7 +2,7 @@
import { GlDropdownItem } from '@gitlab/ui';
import Vue from 'vue';
import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { __, n__ } from '~/locale';
import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -58,9 +58,9 @@ export default {
issuableType: {
type: String,
required: false,
- default: IssuableType.Issue,
+ default: TYPE_ISSUE,
validator(value) {
- return [IssuableType.Issue, IssuableType.MergeRequest, IssuableType.Alert].includes(value);
+ return [TYPE_ISSUE, IssuableType.MergeRequest, IssuableType.Alert].includes(value);
},
},
issuableId: {
@@ -118,7 +118,7 @@ export default {
computed: {
shouldEnableRealtime() {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
- return this.issuableType === IssuableType.Issue;
+ return this.issuableType === TYPE_ISSUE;
},
queryVariables() {
return {
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index 29298ef7627..ddbd8866680 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,6 +1,6 @@
<script>
import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { s__, sprintf } from '~/locale';
const AVAILABILITY_STATUS = {
@@ -21,7 +21,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: IssuableType.Issue,
+ default: TYPE_ISSUE,
},
},
computed: {
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 d83ae782e26..71f349bb87e 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,6 @@
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -21,7 +21,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
data() {
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
index 6afaee91d7a..1eeb725d5c9 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlAlert, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { TYPE_EPIC, WorkspaceType } from '~/issues/constants';
import { confidentialityInfoText } from '~/vue_shared/constants';
export default {
@@ -25,7 +25,7 @@ export default {
computed: {
confidentialBodyText() {
return confidentialityInfoText(
- this.issuableType === IssuableType.Epic ? WorkspaceType.group : WorkspaceType.project,
+ this.issuableType === TYPE_EPIC ? WorkspaceType.group : WorkspaceType.project,
this.issuableType,
);
},
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index dbedfe57325..f7526bcff3d 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -1,7 +1,7 @@
<script>
import { GlSprintf, GlButton } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import { confidentialityQueries } from '../../constants';
@@ -53,11 +53,14 @@ export default {
? this.$options.i18n.confidentialityOffWarning
: this.$options.i18n.confidentialityOnWarning;
},
+ isIssue() {
+ return this.issuableType === TYPE_ISSUE;
+ },
context() {
- return this.issuableType === IssuableType.Issue ? __('project') : __('group');
+ return this.isIssue ? __('project') : __('group');
},
workspacePath() {
- return this.issuableType === IssuableType.Issue
+ return this.isIssue
? {
projectPath: this.fullPath,
}
@@ -66,7 +69,7 @@ export default {
};
},
permissions() {
- return this.issuableType === IssuableType.Issue
+ return this.isIssue
? __('at least the Reporter role, the author, and assignees')
: __('at least the Reporter role');
},
diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
index 0660e4f58e4..c9ecaf4102f 100644
--- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
+++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue
@@ -3,7 +3,7 @@ import { GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE } from '~/graphql_shared/constants';
import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql';
import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql';
@@ -65,7 +65,7 @@ export default {
return this.contacts?.length;
},
queryVariables() {
- return { id: convertToGraphQLId(TYPE_ISSUE, this.issueId) };
+ return { id: convertToGraphQLId(TYPENAME_ISSUE, this.issueId) };
},
contactsLabel() {
return sprintf(n__('%{count} contact', '%{count} contacts', this.contactCount), {
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
index eb48732f558..77be8022ec0 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants';
@@ -142,7 +142,7 @@ export default {
return dateInWords(this.parsedDate, true);
},
workspacePath() {
- return this.issuableType === IssuableType.Issue
+ return this.issuableType === TYPE_ISSUE
? {
projectPath: this.fullPath,
}
@@ -235,7 +235,7 @@ export default {
help: __('Help'),
learnMore: __('Learn more'),
},
- dateHelpUrl: '/help/user/group/epics/index.md#start-date-and-due-date',
+ dateHelpUrl: '/help/user/group/epics/manage_epics.md#start-and-due-date-inheritance',
};
</script>
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql
index 2904857270e..d7456a71aff 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql
@@ -1,9 +1,9 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
-query issueLabels($fullPath: ID!, $iid: String) {
+query issueLabels($fullPath: ID!, $iid: String, $types: [IssueType!]) {
workspace: project(fullPath: $fullPath) {
id
- issuable: issue(iid: $iid) {
+ issuable: issue(iid: $iid, types: $types) {
id
labels {
nodes {
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql
new file mode 100644
index 00000000000..9ff7ce64d3b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql
@@ -0,0 +1,20 @@
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+#import "~/graphql_shared/fragments/label.fragment.graphql"
+
+mutation updateTestCaseLabels($input: UpdateIssueInput!) {
+ updateIssuableLabels: updateIssue(input: $input) {
+ issuable: issue {
+ id
+ updatedAt
+ updatedBy {
+ ...Author
+ }
+ labels {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
index b7b4bbac661..bf916e26a15 100644
--- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue
@@ -4,7 +4,7 @@ import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labe
import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
import { issuableLabelsQueries } from '../../../constants';
@@ -161,10 +161,16 @@ export default {
return !isDropdownVariantSidebar(this.variant);
},
variables() {
- return {
+ const queryVariables = {
iid: this.iid,
fullPath: this.fullPath,
};
+
+ if (this.issuableType === IssuableType.TestCase) {
+ queryVariables.types = ['TEST_CASE'];
+ }
+
+ return queryVariables;
},
update(data) {
return data.workspace?.issuable;
@@ -255,14 +261,15 @@ export default {
};
switch (this.issuableType) {
- case IssuableType.Issue:
+ case TYPE_ISSUE:
+ case IssuableType.TestCase:
return updateVariables;
case IssuableType.MergeRequest:
return {
...updateVariables,
operationMode: MutationOperationMode.Replace,
};
- case IssuableType.Epic:
+ case TYPE_EPIC:
return {
iid: currentIid,
groupPath: this.fullPath,
@@ -311,7 +318,8 @@ export default {
};
switch (this.issuableType) {
- case IssuableType.Issue:
+ case TYPE_ISSUE:
+ case IssuableType.TestCase:
return {
...removeVariables,
removeLabelIds: [labelId],
@@ -322,7 +330,7 @@ export default {
labelIds: [labelId],
operationMode: MutationOperationMode.Remove,
};
- case IssuableType.Epic:
+ case TYPE_EPIC:
return {
iid: this.iid,
removeLabelIds: [getIdFromGraphQLId(labelId)],
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index cdce6617591..9d8f1304911 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -1,6 +1,7 @@
<script>
import { GlIcon, GlTooltipDirective, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { createAlert } from '~/flash';
@@ -9,7 +10,6 @@ import eventHub from '../../event_hub';
import EditForm from './edit_form.vue';
export default {
- issue: 'issue',
locked: {
icon: 'lock',
class: 'value',
@@ -49,7 +49,7 @@ export default {
return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.movedMrSidebar;
},
issuableDisplayName() {
- const isInIssuePage = this.getNoteableData.targetType === this.$options.issue;
+ const isInIssuePage = this.getNoteableData.targetType === TYPE_ISSUE;
return isInIssuePage ? __('issue') : __('merge request');
},
isLocked() {
diff --git a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
index 1fff089eab4..8072154cd28 100644
--- a/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/milestone/milestone_dropdown.vue
@@ -1,8 +1,8 @@
<script>
import { GlDropdownItem } from '@gitlab/ui';
-import { TYPE_MILESTONE } from '~/graphql_shared/constants';
+import { TYPENAME_MILESTONE } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
import { __ } from '~/locale';
import { IssuableAttributeType } from '../../constants';
import SidebarDropdown from '../sidebar_dropdown.vue';
@@ -37,7 +37,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
},
},
inputName: {
@@ -71,7 +71,10 @@ export default {
data() {
return {
milestone: this.milestoneId
- ? { id: convertToGraphQLId(TYPE_MILESTONE, this.milestoneId), title: this.milestoneTitle }
+ ? {
+ id: convertToGraphQLId(TYPENAME_MILESTONE, this.milestoneId),
+ title: this.milestoneTitle,
+ }
: placeholderMilestone,
};
},
diff --git a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
index 02323e5a0c6..9f64ddc8721 100644
--- a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue
@@ -206,7 +206,7 @@ export default {
category="primary"
variant="confirm"
:disabled="!Boolean(selectedProject)"
- class="gl-text-center! issuable-move-button"
+ class="gl-w-full issuable-move-button"
@click="handleMoveClick"
>{{ __('Move') }}</gl-button
>
diff --git a/app/assets/javascripts/sidebar/components/move/move_issue_button.vue b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
new file mode 100644
index 00000000000..e1259fad6a7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/move/move_issue_button.vue
@@ -0,0 +1,71 @@
+<script>
+import ProjectSelect from '~/sidebar/components/move/issuable_move_dropdown.vue';
+import { __ } from '~/locale';
+import { createAlert } from '~/flash';
+import { visitUrl } from '~/lib/utils/url_utility';
+import moveIssueMutation from '../../queries/move_issue.mutation.graphql';
+
+export default {
+ name: 'MoveIssueButton',
+ components: { ProjectSelect },
+ inject: ['projectsAutocompleteEndpoint', 'projectFullPath', 'issueIid'],
+
+ i18n: {
+ title: __('Move issue'),
+ titleInProgress: __('Moving issue'),
+ moveErrorMessage: __('An error occurred while moving the issue.'),
+ },
+ data() {
+ return {
+ moveInProgress: false,
+ };
+ },
+ computed: {
+ dropdownButtonTitle() {
+ return this.moveInProgress ? this.$options.i18n.titleInProgress : this.$options.i18n.title;
+ },
+ },
+ methods: {
+ moveIssue(targetProject) {
+ this.moveInProgress = true;
+ return this.$apollo
+ .mutate({
+ mutation: moveIssueMutation,
+ variables: {
+ moveIssueInput: {
+ projectPath: this.projectFullPath,
+ iid: this.issueIid,
+ targetProjectPath: targetProject.full_path,
+ },
+ },
+ })
+ .then(({ data = {} }) => {
+ if (!data.issueMove) return;
+
+ const { errors } = data.issueMove;
+ if (errors?.length > 0) {
+ throw new Error(`Error moving the issue. Error message: ${errors[0].message}`);
+ }
+ visitUrl(data.issueMove?.issue.webUrl);
+ })
+ .catch((error) => {
+ this.moveInProgress = false;
+ createAlert({
+ message: this.$options.i18n.moveErrorMessage,
+ captureError: true,
+ error,
+ });
+ });
+ },
+ },
+};
+</script>
+<template>
+ <project-select
+ :projects-fetch-path="projectsAutocompleteEndpoint"
+ :dropdown-button-title="dropdownButtonTitle"
+ :dropdown-header-title="$options.i18n.title"
+ :move-in-progress="moveInProgress"
+ @move-issuable="moveIssue"
+ />
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index f69c027e201..56ac4c39e84 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -2,6 +2,7 @@
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf } from '~/locale';
import ReviewerAvatar from './reviewer_avatar.vue';
@@ -34,7 +35,7 @@ export default {
},
issuableType: {
type: String,
- default: 'issue',
+ default: TYPE_ISSUE,
required: false,
},
},
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index 7af8dcb4e3e..bd1d9fbff0c 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -1,6 +1,7 @@
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { TYPE_ISSUE } from '~/issues/constants';
import CollapsedReviewerList from './collapsed_reviewer_list.vue';
import UncollapsedReviewerList from './uncollapsed_reviewer_list.vue';
@@ -28,7 +29,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
computed: {
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index faa36f3d8d2..8dd58d33ecf 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -4,8 +4,8 @@
import Vue from 'vue';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/flash';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '../../event_hub';
import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql';
@@ -26,7 +26,6 @@ export default {
ReviewerTitle,
Reviewers,
},
- mixins: [glFeatureFlagsMixin()],
props: {
mediator: {
type: Object,
@@ -39,7 +38,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
issuableIid: {
type: String,
@@ -78,7 +77,7 @@ export default {
};
},
skip() {
- return !this.issuable?.id || !this.isRealtimeEnabled;
+ return !this.issuable?.id;
},
updateQuery(
_,
@@ -119,9 +118,6 @@ export default {
canUpdate() {
return this.issuable.userPermissions?.adminMergeRequest || false;
},
- isRealtimeEnabled() {
- return this.glFeatures.realtimeReviewers;
- },
},
created() {
this.store = new Store();
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 217ca2e2548..a3710d9534e 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { TYPE_ISSUE } from '~/issues/constants';
import { __, sprintf, s__ } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
@@ -30,7 +31,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: 'issue',
+ default: TYPE_ISSUE,
},
},
data() {
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
deleted file mode 100644
index 5b624c17b0c..00000000000
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ /dev/null
@@ -1,195 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlLoadingIcon,
- GlTooltip,
- GlSprintf,
- GlButton,
-} from '@gitlab/ui';
-import { createAlert } from '~/flash';
-import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql';
-import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants';
-import SeverityToken from './severity.vue';
-
-export default {
- i18n: I18N,
- components: {
- GlLoadingIcon,
- GlTooltip,
- GlSprintf,
- GlDropdown,
- GlDropdownItem,
- GlButton,
- SeverityToken,
- },
- inject: ['canUpdate'],
- props: {
- projectPath: {
- type: String,
- required: true,
- },
- iid: {
- type: String,
- required: true,
- },
- initialSeverity: {
- type: String,
- required: false,
- default: INCIDENT_SEVERITY.UNKNOWN.value,
- },
- issuableType: {
- type: String,
- required: false,
- default: ISSUABLE_TYPES.INCIDENT,
- validator: (value) => {
- // currently severity is supported only for incidents, but this list might be extended
- return [ISSUABLE_TYPES.INCIDENT].includes(value);
- },
- },
- },
- data() {
- return {
- isDropdownShowing: false,
- isUpdating: false,
- severity: this.initialSeverity,
- };
- },
- computed: {
- severitiesList() {
- switch (this.issuableType) {
- case ISSUABLE_TYPES.INCIDENT:
- return Object.values(INCIDENT_SEVERITY);
- default:
- return [];
- }
- },
- dropdownClass() {
- return this.isDropdownShowing ? 'show' : 'gl-display-none';
- },
- selectedItem() {
- return this.severitiesList.find((severity) => severity.value === this.severity);
- },
- },
- mounted() {
- document.addEventListener('click', this.handleOffClick);
- },
- beforeDestroy() {
- document.removeEventListener('click', this.handleOffClick);
- },
- methods: {
- handleOffClick(event) {
- if (!this.isDropdownShowing) {
- return;
- }
-
- if (!this.$refs.sidebarSeverity.contains(event.target)) {
- this.hideDropdown();
- }
- },
- hideDropdown() {
- this.isDropdownShowing = false;
- const event = new Event('hidden.gl.dropdown');
- this.$el.dispatchEvent(event);
- },
- toggleFormDropdown() {
- this.isDropdownShowing = !this.isDropdownShowing;
- },
- updateSeverity(value) {
- this.hideDropdown();
- this.isUpdating = true;
- this.$apollo
- .mutate({
- mutation: updateIssuableSeverity,
- variables: {
- iid: this.iid,
- severity: value,
- projectPath: this.projectPath,
- },
- })
- .then((resp) => {
- const {
- data: {
- issueSetSeverity: {
- errors = [],
- issue: { severity },
- },
- },
- } = resp;
-
- if (errors[0]) {
- throw errors[0];
- }
- this.severity = severity;
- })
- .catch(() =>
- createAlert({
- message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`,
- }),
- )
- .finally(() => {
- this.isUpdating = false;
- });
- },
- },
-};
-</script>
-
-<template>
- <div ref="sidebarSeverity" class="block">
- <div ref="severity" class="sidebar-collapsed-icon" @click="toggleFormDropdown">
- <severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" />
- <gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left">
- <gl-sprintf :message="$options.i18n.SEVERITY_VALUE">
- <template #severity>
- {{ selectedItem.label }}
- </template>
- </gl-sprintf>
- </gl-tooltip>
- </div>
-
- <div class="hide-collapsed">
- <div
- class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold"
- >
- {{ $options.i18n.SEVERITY }}
- <gl-button
- v-if="canUpdate"
- category="tertiary"
- size="small"
- class="gl-ml-auto hide-collapsed gl-mr-n2"
- data-testid="editButton"
- @click="toggleFormDropdown"
- @keydown.esc="hideDropdown"
- >
- {{ $options.i18n.EDIT }}
- </gl-button>
- </div>
-
- <gl-dropdown
- class="gl-mt-3"
- :class="dropdownClass"
- block
- :header-text="__('Assign severity')"
- :text="selectedItem.label"
- toggle-class="dropdown-menu-toggle gl-mb-2"
- @keydown.esc.native="hideDropdown"
- >
- <gl-dropdown-item
- v-for="option in severitiesList"
- :key="option.value"
- data-testid="severityDropdownItem"
- is-check-item
- :is-checked="option.value === severity"
- @click="updateSeverity(option.value)"
- >
- <severity-token :severity="option" />
- </gl-dropdown-item>
- </gl-dropdown>
-
- <gl-loading-icon v-if="isUpdating" size="sm" :inline="true" />
-
- <severity-token v-else-if="!isDropdownShowing" :severity="selectedItem" />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
new file mode 100644
index 00000000000..ecb9a2809a0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue
@@ -0,0 +1,154 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlTooltip, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql';
+import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants';
+import SeverityToken from './severity.vue';
+
+export default {
+ i18n: I18N,
+ components: {
+ GlTooltip,
+ GlSprintf,
+ GlDropdown,
+ GlDropdownItem,
+ SeverityToken,
+ SidebarEditableItem,
+ },
+ inject: ['canUpdate'],
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ initialSeverity: {
+ type: String,
+ required: false,
+ default: INCIDENT_SEVERITY.UNKNOWN.value,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: ISSUABLE_TYPES.INCIDENT,
+ validator: (value) => {
+ // currently severity is supported only for incidents, but this list might be extended
+ return [ISSUABLE_TYPES.INCIDENT].includes(value);
+ },
+ },
+ },
+ data() {
+ return {
+ isUpdating: false,
+ severity: this.initialSeverity,
+ };
+ },
+ computed: {
+ severitiesList() {
+ switch (this.issuableType) {
+ case ISSUABLE_TYPES.INCIDENT:
+ return Object.values(INCIDENT_SEVERITY);
+ default:
+ return [];
+ }
+ },
+ selectedItem() {
+ return this.severitiesList.find((severity) => severity.value === this.severity);
+ },
+ },
+ methods: {
+ updateSeverity(value) {
+ this.$refs.toggle.collapse();
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: updateIssuableSeverity,
+ variables: {
+ iid: this.iid,
+ severity: value,
+ projectPath: this.projectPath,
+ },
+ })
+ .then((resp) => {
+ const {
+ data: {
+ issueSetSeverity: {
+ errors = [],
+ issue: { severity },
+ },
+ },
+ } = resp;
+
+ if (errors[0]) {
+ throw errors[0];
+ }
+ this.severity = severity;
+ })
+ .catch(() =>
+ createAlert({
+ message: `${this.$options.i18n.UPDATE_SEVERITY_ERROR} ${this.$options.i18n.TRY_AGAIN}`,
+ }),
+ )
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
+ },
+};
+</script>
+
+<template>
+ <div ref="sidebarSeverity" class="block">
+ <sidebar-editable-item
+ ref="toggle"
+ :loading="isUpdating"
+ :title="$options.i18n.SEVERITY"
+ :can-edit="canUpdate"
+ @open="showDropdown"
+ >
+ <template #collapsed>
+ <div ref="severity" class="sidebar-collapsed-icon">
+ <severity-token :severity="selectedItem" :icon-size="14" :icon-only="true" />
+ <gl-tooltip :target="() => $refs.severity" boundary="viewport" placement="left">
+ <gl-sprintf :message="$options.i18n.SEVERITY_VALUE">
+ <template #severity>
+ {{ selectedItem.label }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </div>
+ <div class="hide-collapsed">
+ <severity-token :severity="selectedItem" />
+ </div>
+ </template>
+
+ <template #default>
+ <gl-dropdown
+ ref="dropdown"
+ class="gl-mt-3"
+ block
+ :header-text="__('Assign severity')"
+ :text="selectedItem.label"
+ >
+ <gl-dropdown-item
+ v-for="option in severitiesList"
+ :key="option.value"
+ data-testid="severityDropdownItem"
+ is-check-item
+ :is-checked="option.value === severity"
+ @click="updateSeverity(option.value)"
+ >
+ <severity-token :severity="option" />
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+ </sidebar-editable-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
index 26e2bc96f54..d68e4974ea4 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown.vue
@@ -8,7 +8,7 @@ import {
GlSearchBoxByType,
} from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
-import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
import { __ } from '~/locale';
import {
defaultEpicSort,
@@ -70,7 +70,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
},
},
workspaceType: {
@@ -155,7 +155,7 @@ export default {
},
isEpic() {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
- return this.issuableAttribute === IssuableType.Epic;
+ return this.issuableAttribute === TYPE_EPIC;
},
issuableAttributeQuery() {
return this.issuableAttributesQueries[this.issuableAttribute];
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 35667495ace..5df65c4aaaf 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -3,7 +3,7 @@ import { GlButton, GlIcon, GlLink, GlPopover, GlTooltipDirective } from '@gitlab
import { kebabCase, snakeCase } from 'lodash';
import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -71,7 +71,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
+ return [TYPE_ISSUE, IssuableType.MergeRequest].includes(value);
},
},
icon: {
@@ -153,7 +153,7 @@ export default {
},
isEpic() {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311
- return this.issuableAttribute === IssuableType.Epic;
+ return this.issuableAttribute === TYPE_EPIC;
},
formatIssuableAttribute() {
return {
@@ -188,7 +188,7 @@ export default {
fullPath: this.workspacePath,
attributeId:
this.issuableAttribute === IssuableAttributeType.Milestone &&
- this.issuableType === IssuableType.Issue
+ this.issuableType === TYPE_ISSUE
? getIdFromGraphQLId(id)
: id,
iid: this.iid,
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index 0fba1cb5e4e..cbe839d1112 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdownForm, GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_EPIC } from '~/issues/constants';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __, sprintf } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -105,7 +105,7 @@ export default {
return ICON_ON;
},
parentIsGroup() {
- return this.issuableType === IssuableType.Epic;
+ return this.issuableType === TYPE_EPIC;
},
subscribeDisabledDescription() {
return sprintf(__('Disabled by %{parent} owner'), {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
index ec8e1ee9952..964da3b6138 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue
@@ -10,8 +10,9 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/issues/constants';
import { formatDate } from '~/lib/utils/datetime_utility';
-import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import createTimelogMutation from '../../queries/create_timelog.mutation.graphql';
@@ -127,10 +128,10 @@ export default {
});
},
isIssue() {
- return this.issuableType === 'issue';
+ return this.issuableType === TYPE_ISSUE;
},
getGraphQLEntityType() {
- return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
+ return this.isIssue() ? TYPENAME_ISSUE : TYPENAME_MERGE_REQUEST;
},
updateSpentAtDate(val) {
this.spentAt = val;
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index 6f4ced06ddf..cffbb6466f2 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,8 +1,9 @@
<script>
import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/issues/constants';
import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import { __, s__ } from '~/locale';
import { timelogQueries } from '../../constants';
@@ -61,7 +62,7 @@ export default {
return this.removingIds.includes(timelogId);
},
isIssue() {
- return this.issuableType === 'issue';
+ return this.issuableType === TYPE_ISSUE;
},
getQueryVariables() {
return {
@@ -69,7 +70,7 @@ export default {
};
},
getGraphQLEntityType() {
- return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
+ return this.isIssue() ? TYPENAME_ISSUE : TYPENAME_MERGE_REQUEST;
},
extractTimelogs(data) {
const timelogs = data?.issuable?.timelogs?.nodes || [];
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index b32836dc87d..c645b1649d2 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -8,7 +8,7 @@ import {
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __ } from '~/locale';
@@ -173,10 +173,7 @@ export default {
return Boolean(this.showHelp);
},
isTimeReportSupported() {
- return (
- [IssuableType.Issue, IssuableType.MergeRequest].includes(this.issuableType) &&
- this.issuableId
- );
+ return [TYPE_ISSUE, IssuableType.MergeRequest].includes(this.issuableType) && this.issuableId;
},
timeTrackingIconTitle() {
return this.showHelpState ? '' : HOW_TO_TRACK_TIME;
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 825a89daf58..14491226b15 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -3,8 +3,9 @@ import { s__, __, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
-import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { IssuableType, TYPE_EPIC, TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
+import updateTestCaseLabelsMutation from './components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql';
import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql';
import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.graphql';
@@ -63,7 +64,7 @@ export const defaultEpicSort = 'TITLE_ASC';
export const epicIidPattern = /^&(?<iid>\d+)$/;
export const assigneesQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: getIssueAssignees,
subscription: issuableAssigneesSubscription,
mutation: updateIssueAssigneesMutation,
@@ -79,13 +80,13 @@ export const assigneesQueries = {
};
export const participantsQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueParticipantsQuery,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
},
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicParticipantsQuery,
},
[IssuableType.Alert]: {
@@ -95,7 +96,7 @@ export const participantsQueries = {
};
export const userSearchQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: userSearchQuery,
},
[IssuableType.MergeRequest]: {
@@ -104,24 +105,24 @@ export const userSearchQueries = {
};
export const confidentialityQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueConfidentialQuery,
mutation: updateIssueConfidentialMutation,
},
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicConfidentialQuery,
mutation: updateEpicConfidentialMutation,
},
};
export const referenceQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueReferenceQuery,
},
[IssuableType.MergeRequest]: {
query: mergeRequestReferenceQuery,
},
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicReferenceQuery,
},
};
@@ -136,7 +137,7 @@ export const workspaceLabelsQueries = {
};
export const issuableLabelsQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
issuableQuery: issueLabelsQuery,
mutation: updateIssueLabelsMutation,
mutationName: 'updateIssue',
@@ -146,11 +147,16 @@ export const issuableLabelsQueries = {
mutation: updateMergeRequestLabelsMutation,
mutationName: 'mergeRequestSetLabels',
},
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
issuableQuery: epicLabelsQuery,
mutation: updateEpicLabelsMutation,
mutationName: 'updateEpic',
},
+ [IssuableType.TestCase]: {
+ issuableQuery: issueLabelsQuery,
+ mutation: updateTestCaseLabelsMutation,
+ mutationName: 'updateTestCaseLabels',
+ },
};
export const dateTypes = {
@@ -172,11 +178,11 @@ export const dateFields = {
};
export const subscribedQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueSubscribedQuery,
mutation: updateIssueSubscriptionMutation,
},
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicSubscribedQuery,
mutation: updateEpicSubscriptionMutation,
},
@@ -192,7 +198,7 @@ export const Tracking = {
};
export const timeTrackingQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueTimeTrackingQuery,
},
[IssuableType.MergeRequest]: {
@@ -201,25 +207,25 @@ export const timeTrackingQueries = {
};
export const dueDateQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueDueDateQuery,
mutation: updateIssueDueDateMutation,
},
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicDueDateQuery,
mutation: updateEpicDueDateMutation,
},
};
export const startDateQueries = {
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicStartDateQuery,
mutation: updateEpicStartDateMutation,
},
};
export const timelogQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: getIssueTimelogsQuery,
},
[IssuableType.MergeRequest]: {
@@ -230,7 +236,7 @@ export const timelogQueries = {
export const noAttributeId = null;
export const issuableMilestoneQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: projectIssueMilestoneQuery,
mutation: projectIssueMilestoneMutation,
},
@@ -241,7 +247,7 @@ export const issuableMilestoneQueries = {
};
export const milestonesQueries = {
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: {
[WorkspaceType.group]: groupMilestonesQuery,
[WorkspaceType.project]: projectMilestonesQuery,
@@ -277,10 +283,10 @@ export const issuableAttributesQueries = {
};
export const todoQueries = {
- [IssuableType.Epic]: {
+ [TYPE_EPIC]: {
query: epicTodoQuery,
},
- [IssuableType.Issue]: {
+ [TYPE_ISSUE]: {
query: issueTodoQuery,
},
[IssuableType.MergeRequest]: {
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
deleted file mode 100644
index 2cce27df598..00000000000
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import $ from 'jquery';
-import { escape } from 'lodash';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { createAlert } from '~/flash';
-import { __ } from '~/locale';
-
-function isValidProjectId(id) {
- return id > 0;
-}
-
-class SidebarMoveIssue {
- constructor(mediator, dropdownToggle, confirmButton) {
- this.mediator = mediator;
-
- this.$dropdownToggle = $(dropdownToggle);
- this.$confirmButton = $(confirmButton);
-
- this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this);
- }
-
- init() {
- this.initDropdown();
- this.addEventListeners();
- }
-
- destroy() {
- this.removeEventListeners();
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdownToggle, {
- search: {
- fields: ['name_with_namespace'],
- },
- showMenuAbove: true,
- selectable: true,
- filterable: true,
- filterRemote: true,
- multiSelect: false,
- // Keep the dropdown open after selecting an option
- shouldPropagate: false,
- data: (searchTerm, callback) => {
- this.mediator
- .fetchAutocompleteProjects(searchTerm)
- .then(callback)
- .catch(() =>
- createAlert({
- message: __('An error occurred while fetching projects autocomplete.'),
- }),
- );
- },
- renderRow: (project) => `
- <li>
- <a href="#" class="js-move-issue-dropdown-item">
- ${escape(project.name_with_namespace)}
- </a>
- </li>
- `,
- clicked: (options) => {
- const project = options.selectedObj;
- const selectedProjectId = options.isMarking ? project.id : 0;
- this.mediator.setMoveToProjectId(selectedProjectId);
-
- this.$confirmButton.prop('disabled', !isValidProjectId(selectedProjectId));
- },
- });
- }
-
- addEventListeners() {
- this.$confirmButton.on('click', this.onConfirmClickedWrapper);
- }
-
- removeEventListeners() {
- this.$confirmButton.off('click', this.onConfirmClickedWrapper);
- }
-
- onConfirmClicked() {
- if (isValidProjectId(this.mediator.store.moveToProjectId)) {
- this.$confirmButton.disable().addClass('is-loading');
-
- this.mediator.moveIssue().catch(() => {
- createAlert({ message: __('An error occurred while moving the issue.') });
- this.$confirmButton.enable().removeClass('is-loading');
- });
- }
- }
-}
-
-export default SidebarMoveIssue;
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index a308dc8d13c..fb024d818da 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,22 +1,22 @@
-import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPENAME_ISSUE, TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { gqlClient } from '~/issues/list/graphql';
import {
- isInIssuePage,
isInDesignPage,
isInIncidentPage,
+ isInIssuePage,
isInMRPage,
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import Translate from '~/vue_shared/translate';
+import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue';
@@ -34,7 +34,7 @@ import SidebarParticipantsWidget from './components/participants/sidebar_partici
import SidebarReferenceWidget from './components/copy/sidebar_reference_widget.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue';
-import SidebarSeverity from './components/severity/sidebar_severity.vue';
+import SidebarSeverityWidget from './components/severity/sidebar_severity_widget.vue';
import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue';
import StatusDropdown from './components/status/status_dropdown.vue';
import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue';
@@ -43,8 +43,8 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin
import SidebarTodoWidget from './components/todo_toggle/sidebar_todo_widget.vue';
import { IssuableAttributeType } from './constants';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
-import SidebarMoveIssue from './lib/sidebar_move_issue';
import trackShowInviteMemberLink from './track_invite_members';
+import MoveIssueButton from './components/move/move_issue_button.vue';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -75,12 +75,12 @@ function mountSidebarTodoWidget() {
fullPath: projectPath,
issuableId:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? convertToGraphQLId(TYPE_ISSUE, id)
- : convertToGraphQLId(TYPE_MERGE_REQUEST, id),
+ ? convertToGraphQLId(TYPENAME_ISSUE, id)
+ : convertToGraphQLId(TYPENAME_MERGE_REQUEST, id),
issuableIid: iid,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
},
}),
@@ -124,7 +124,7 @@ function mountSidebarAssigneesDeprecated(mediator) {
signedIn: Object.prototype.hasOwnProperty.call(el.dataset, 'signedIn'),
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
issuableId: id,
assigneeAvailabilityStatus,
@@ -142,7 +142,7 @@ function mountSidebarAssigneesWidget() {
const { id, iid, fullPath, editable } = getSidebarOptions();
const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage();
- const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest;
+ const issuableType = isIssuablePage ? TYPE_ISSUE : IssuableType.MergeRequest;
// eslint-disable-next-line no-new
new Vue({
el,
@@ -205,7 +205,7 @@ function mountSidebarReviewers(mediator) {
projectPath: fullPath,
field: el.dataset.field,
issuableType:
- isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest,
},
}),
});
@@ -276,7 +276,7 @@ function mountSidebarMilestoneWidget() {
workspacePath: projectPath,
iid: issueIid,
issuableType:
- isInIssuePage() || isInDesignPage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ isInIssuePage() || isInDesignPage() ? TYPE_ISSUE : IssuableType.MergeRequest,
issuableAttribute: IssuableAttributeType.Milestone,
icon: 'clock',
},
@@ -313,7 +313,7 @@ export function mountMilestoneDropdown() {
attrWorkspacePath: fullPath,
canAdminMilestone,
inputName,
- issuableType: isInIssuePage() ? IssuableType.Issue : IssuableType.MergeRequest,
+ issuableType: isInIssuePage() ? TYPE_ISSUE : IssuableType.MergeRequest,
milestoneId,
milestoneTitle,
projectMilestonesPath,
@@ -357,7 +357,7 @@ export function mountSidebarLabelsWidget() {
variant: DropdownVariant.Sidebar,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
workspaceType: 'project',
attrWorkspacePath: el.dataset.projectPath,
@@ -397,7 +397,7 @@ function mountSidebarConfidentialityWidget() {
fullPath,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
},
}),
@@ -425,7 +425,7 @@ function mountSidebarDueDateWidget() {
props: {
iid: String(iid),
fullPath,
- issuableType: IssuableType.Issue,
+ issuableType: TYPE_ISSUE,
},
}),
});
@@ -453,7 +453,7 @@ function mountSidebarReferenceWidget() {
props: {
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
},
}),
@@ -505,7 +505,7 @@ function mountSidebarParticipantsWidget() {
fullPath,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
},
}),
@@ -535,7 +535,7 @@ function mountSidebarSubscriptionsWidget() {
fullPath,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
- ? IssuableType.Issue
+ ? TYPE_ISSUE
: IssuableType.MergeRequest,
},
}),
@@ -576,8 +576,8 @@ function mountSidebarTimeTracking() {
});
}
-function mountSidebarSeverity() {
- const el = document.querySelector('.js-sidebar-severity-root');
+function mountSidebarSeverityWidget() {
+ const el = document.querySelector('.js-sidebar-severity-widget-root');
if (!el) {
return null;
@@ -587,13 +587,13 @@ function mountSidebarSeverity() {
return new Vue({
el,
- name: 'SidebarSeverityRoot',
+ name: 'SidebarSeverityWidgetRoot',
apolloProvider,
provide: {
canUpdate: editable,
},
render: (createElement) =>
- createElement(SidebarSeverity, {
+ createElement(SidebarSeverityWidget, {
props: {
projectPath: fullPath,
iid: String(iid),
@@ -701,6 +701,95 @@ export function mountSubscriptionsDropdown() {
});
}
+export function mountMoveIssueButton() {
+ const el = document.querySelector('.js-sidebar-move-issue-block');
+
+ if (!el) {
+ return null;
+ }
+
+ const { projectsAutocompleteEndpoint } = getSidebarOptions();
+ const { projectFullPath, issueIid } = el.dataset;
+
+ Vue.use(VueApollo);
+
+ return new Vue({
+ el,
+ name: 'MoveIssueDropdownRoot',
+ apolloProvider,
+ provide: {
+ projectsAutocompleteEndpoint,
+ projectFullPath,
+ issueIid,
+ },
+ render: (createElement) => createElement(MoveIssueButton),
+ });
+}
+
+export function mountAssigneesDropdown() {
+ const el = document.querySelector('.js-assignee-dropdown');
+ const assigneeIdsInput = document.querySelector('.js-assignee-ids-input');
+
+ if (!el || !assigneeIdsInput) {
+ return null;
+ }
+
+ const { fullPath } = el.dataset;
+ const currentUser = {
+ id: gon?.current_user_id,
+ username: gon?.current_username,
+ name: gon?.current_user_fullname,
+ avatarUrl: gon?.current_user_avatar_url,
+ };
+
+ return new Vue({
+ el,
+ apolloProvider,
+ data() {
+ return {
+ selectedUserName: '',
+ value: [],
+ };
+ },
+ methods: {
+ onSelectedUnassigned() {
+ assigneeIdsInput.value = 0;
+ this.value = [];
+ this.selectedUserName = __('Unassigned');
+ },
+ onSelected(selected) {
+ assigneeIdsInput.value = selected.map((user) => getIdFromGraphQLId(user.id));
+ this.value = selected;
+ this.selectedUserName = selected.map((user) => user.name).join(', ');
+ },
+ },
+ render(h) {
+ const component = this;
+
+ return h(UserSelect, {
+ props: {
+ text: component.selectedUserName || __('Select assignee'),
+ headerText: __('Assign to'),
+ fullPath,
+ currentUser,
+ value: component.value,
+ },
+ on: {
+ input(selected) {
+ if (!selected.length) {
+ component.onSelectedUnassigned();
+ return;
+ }
+
+ component.onSelected(selected);
+ },
+ },
+ class: 'gl-w-full',
+ });
+ },
+ });
+}
+
const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget;
@@ -725,14 +814,9 @@ export function mountSidebar(mediator, store) {
mountSidebarSubscriptionsWidget();
mountCopyEmailToClipboard();
mountSidebarTimeTracking();
- mountSidebarSeverity();
+ mountSidebarSeverityWidget();
mountSidebarEscalationStatus();
-
- new SidebarMoveIssue(
- mediator,
- $('.js-move-issue'),
- $('.js-move-issue-confirmation-button'),
- ).init();
+ mountMoveIssueButton();
}
export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql
index d350072425b..e3ed3c5089b 100644
--- a/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql
@@ -1,5 +1,9 @@
mutation moveIssue($moveIssueInput: IssueMoveInput!) {
issueMove(input: $moveIssueInput) {
+ issue {
+ id
+ webUrl
+ }
errors
}
}
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index 00d3177b75a..af267f65502 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -1,4 +1,4 @@
-import { TYPE_USER } from '~/graphql_shared/constants';
+import { TYPENAME_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
@@ -54,7 +54,7 @@ export default class SidebarService {
return gqClient.mutate({
mutation: reviewerRereviewMutation,
variables: {
- userId: convertToGraphQLId(TYPE_USER, `${userId}`),
+ userId: convertToGraphQLId(TYPENAME_USER, `${userId}`),
projectPath: this.fullPath,
iid: this.iid.toString(),
},
diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js
index 6d0e4770e1c..277d43e43a4 100644
--- a/app/assets/javascripts/snippet/snippet_show.js
+++ b/app/assets/javascripts/snippet/snippet_show.js
@@ -3,11 +3,13 @@ import initDeprecatedNotes from '~/init_deprecated_notes';
import SnippetsAppFactory from '~/snippets';
import SnippetsShow from '~/snippets/components/show.vue';
import ZenMode from '~/zen_mode';
+import { initReportAbuse } from '~/projects/report_abuse';
SnippetsAppFactory(document.getElementById('js-snippet-view'), SnippetsShow);
initDeprecatedNotes();
loadAwardsHandler();
+initReportAbuse();
// eslint-disable-next-line no-new
new ZenMode();
diff --git a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue b/app/assets/javascripts/super_sidebar/components/bottom_bar.vue
deleted file mode 100644
index fea29458f45..00000000000
--- a/app/assets/javascripts/super_sidebar/components/bottom_bar.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- i18n: {
- help: __('Help'),
- new: __('New'),
- },
-};
-</script>
-
-<template>
- <div class="bottom-links gl-p-3">
- <a href="#" class="gl-text-black-normal"
- ><gl-icon name="question-o" class="gl-mr-3 gl-text-gray-300 gl-text-black-normal!" />{{
- $options.i18n.help
- }}</a
- >
- </div>
-</template>
diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue
index d790e61ca31..62a1e5a6b20 100644
--- a/app/assets/javascripts/super_sidebar/components/counter.vue
+++ b/app/assets/javascripts/super_sidebar/components/counter.vue
@@ -40,9 +40,9 @@ export default {
:is="component"
:aria-label="ariaLabel"
:href="href"
- class="counter gl-relative gl-display-inline-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-black-normal gl-border gl-border-gray-a-08 gl-font-sm gl-font-weight-bold"
+ class="counter gl-display-block gl-flex-grow-1 gl-text-center gl-py-3 gl-bg-gray-10 gl-rounded-base gl-text-gray-900 gl-border gl-border-gray-a-08 gl-font-sm gl-hover-text-gray-900 gl-hover-text-decoration-none"
>
<gl-icon aria-hidden="true" :name="icon" />
- <span aria-hidden="true">{{ count }}</span>
+ <span v-if="count" aria-hidden="true" class="gl-ml-1">{{ count }}</span>
</component>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue
new file mode 100644
index 00000000000..e92a6cbf5f5
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlDisclosureDropdown,
+ GlTooltip,
+ },
+ i18n: {
+ createNew: __('Create new...'),
+ },
+ props: {
+ groups: {
+ type: Array,
+ required: true,
+ },
+ },
+ toggleId: 'create-menu-toggle',
+};
+</script>
+
+<template>
+ <div>
+ <gl-disclosure-dropdown
+ category="tertiary"
+ icon="plus"
+ :items="groups"
+ no-caret
+ text-sr-only
+ :toggle-text="$options.i18n.createNew"
+ :toggle-id="$options.toggleId"
+ />
+ <gl-tooltip :target="`#${$options.toggleId}`" placement="bottom" container="#super-sidebar">
+ {{ $options.i18n.createNew }}
+ </gl-tooltip>
+ </div>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/help_center.vue b/app/assets/javascripts/super_sidebar/components/help_center.vue
new file mode 100644
index 00000000000..8e7c7efa631
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/help_center.vue
@@ -0,0 +1,178 @@
+<script>
+import { GlBadge, GlButton, GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui';
+import GitlabVersionCheckBadge from '~/gitlab_version_check/components/gitlab_version_check_badge.vue';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { STORAGE_KEY } from '~/whats_new/utils/notification';
+
+export default {
+ components: {
+ GlBadge,
+ GlButton,
+ GlDisclosureDropdown,
+ GlDisclosureDropdownGroup,
+ GitlabVersionCheckBadge,
+ },
+ i18n: {
+ help: __('Help'),
+ support: __('Support'),
+ docs: __('GitLab documentation'),
+ plans: __('Compare GitLab plans'),
+ forum: __('Community forum'),
+ contribute: __('Contribute to GitLab'),
+ feedback: __('Provide feedback'),
+ shortcuts: __('Keyboard shortcuts'),
+ version: __('Your GitLab version'),
+ whatsnew: __("What's new"),
+ },
+ props: {
+ sidebarData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showWhatsNewNotification: this.shouldShowWhatsNewNotification(),
+ };
+ },
+ computed: {
+ itemGroups() {
+ return {
+ versionCheck: {
+ items: [
+ {
+ text: this.$options.i18n.version,
+ href: helpPagePath('update/index'),
+ version: `${this.sidebarData.gitlab_version.major}.${this.sidebarData.gitlab_version.minor}`,
+ },
+ ],
+ },
+ helpLinks: {
+ items: [
+ { text: this.$options.i18n.help, href: helpPagePath() },
+ { text: this.$options.i18n.support, href: this.sidebarData.support_path },
+ { text: this.$options.i18n.docs, href: 'https://docs.gitlab.com' },
+ { text: this.$options.i18n.plans, href: `${PROMO_URL}/pricing` },
+ { text: this.$options.i18n.forum, href: 'https://forum.gitlab.com/' },
+ {
+ text: this.$options.i18n.contribute,
+ href: helpPagePath('', { anchor: 'contributing-to-gitlab' }),
+ },
+ { text: this.$options.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' },
+ ],
+ },
+ helpActions: {
+ items: [
+ {
+ text: this.$options.i18n.shortcuts,
+ action: this.showKeyboardShortcuts,
+ shortcut: '?',
+ },
+ this.sidebarData.display_whats_new && {
+ text: this.$options.i18n.whatsnew,
+ action: this.showWhatsNew,
+ count:
+ this.showWhatsNewNotification &&
+ this.sidebarData.whats_new_most_recent_release_items_count,
+ },
+ ].filter(Boolean),
+ },
+ };
+ },
+ updateSeverity() {
+ return this.sidebarData.gitlab_version_check?.severity;
+ },
+ },
+ methods: {
+ shouldShowWhatsNewNotification() {
+ if (
+ !this.sidebarData.display_whats_new ||
+ localStorage.getItem(STORAGE_KEY) === this.sidebarData.whats_new_version_digest
+ ) {
+ return false;
+ }
+ return true;
+ },
+
+ handleAction({ action }) {
+ if (action) {
+ action();
+ }
+ },
+
+ showKeyboardShortcuts() {
+ this.$refs.dropdown.close();
+ window?.toggleShortcutsHelp();
+ },
+
+ async showWhatsNew() {
+ this.$refs.dropdown.close();
+ this.showWhatsNewNotification = false;
+
+ if (!this.toggleWhatsNewDrawer) {
+ const appEl = document.getElementById('whats-new-app');
+ const { default: toggleWhatsNewDrawer } = await import(
+ /* webpackChunkName: 'whatsNewApp' */ '~/whats_new'
+ );
+ this.toggleWhatsNewDrawer = toggleWhatsNewDrawer;
+ this.toggleWhatsNewDrawer(appEl);
+ } else {
+ this.toggleWhatsNewDrawer();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown ref="dropdown">
+ <template #toggle>
+ <gl-button category="tertiary" icon="question-o" class="btn-with-notification">
+ <span v-if="showWhatsNewNotification" class="notification"></span>
+ {{ $options.i18n.help }}
+ </gl-button>
+ </template>
+
+ <gl-disclosure-dropdown-group
+ v-if="sidebarData.show_version_check"
+ :group="itemGroups.versionCheck"
+ >
+ <template #list-item="{ item }">
+ <a
+ :href="item.href"
+ tabindex="-1"
+ class="gl-display-flex gl-flex-direction-column gl-line-height-24 gl-text-gray-900 gl-hover-text-gray-900 gl-hover-text-decoration-none"
+ >
+ <span class="gl-font-sm gl-font-weight-bold">
+ {{ item.text }}
+ <gl-emoji data-name="rocket" />
+ </span>
+ <span>
+ <span class="gl-mr-2">{{ item.version }}</span>
+ <gitlab-version-check-badge v-if="updateSeverity" :status="updateSeverity" size="sm" />
+ </span>
+ </a>
+ </template>
+ </gl-disclosure-dropdown-group>
+
+ <gl-disclosure-dropdown-group
+ :group="itemGroups.helpLinks"
+ :bordered="sidebarData.show_version_check"
+ />
+
+ <gl-disclosure-dropdown-group :group="itemGroups.helpActions" bordered @action="handleAction">
+ <template #list-item="{ item }">
+ <button
+ tabindex="-1"
+ class="gl-bg-transparent gl-w-full gl-border-none gl-display-flex gl-justify-content-space-between gl-p-0 gl-text-gray-900"
+ >
+ {{ item.text }}
+ <gl-badge v-if="item.count" pill size="sm" variant="info">{{ item.count }}</gl-badge>
+ <kbd v-else-if="item.shortcut" class="flat">?</kbd>
+ </button>
+ </template>
+ </gl-disclosure-dropdown-group>
+ </gl-disclosure-dropdown>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
new file mode 100644
index 00000000000..edc13e305cf
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/merge_request_menu.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlBadge, GlDisclosureDropdown } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlBadge,
+ GlDisclosureDropdown,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ navigate() {
+ this.$refs.link.click();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown :items="items" placement="center" @action="navigate">
+ <template #toggle>
+ <slot></slot>
+ </template>
+ <template #list-item="{ item }">
+ <a
+ ref="link"
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-hover-text-gray-900 gl-hover-text-decoration-none gl-text-gray-900"
+ :href="item.href"
+ tabindex="-1"
+ >
+ {{ item.text }}
+ <gl-badge pill size="sm" variant="neutral">{{ item.count || 0 }}</gl-badge>
+ </a>
+ </template>
+ </gl-disclosure-dropdown>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
index e2eac64f5ad..c4b769dcf24 100644
--- a/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
+++ b/app/assets/javascripts/super_sidebar/components/super_sidebar.vue
@@ -4,7 +4,7 @@ import { context } from '../mock_data';
import UserBar from './user_bar.vue';
import ContextSwitcherToggle from './context_switcher_toggle.vue';
import ContextSwitcher from './context_switcher.vue';
-import BottomBar from './bottom_bar.vue';
+import HelpCenter from './help_center.vue';
export default {
context,
@@ -13,7 +13,7 @@ export default {
UserBar,
ContextSwitcherToggle,
ContextSwitcher,
- BottomBar,
+ HelpCenter,
},
props: {
sidebarData: {
@@ -31,7 +31,8 @@ export default {
<template>
<aside
- class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08 gl-z-index-9999"
+ id="super-sidebar"
+ class="super-sidebar gl-fixed gl-bottom-0 gl-left-0 gl-display-flex gl-flex-direction-column gl-bg-gray-10 gl-border-r gl-border-gray-a-08"
data-testid="super-sidebar"
>
<user-bar :sidebar-data="sidebarData" />
@@ -42,8 +43,8 @@ export default {
<context-switcher />
</gl-collapse>
</div>
- <div class="gl-px-3">
- <bottom-bar />
+ <div class="gl-p-3">
+ <help-center :sidebar-data="sidebarData" />
</div>
</div>
</aside>
diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue
index 7ee1776bf07..ee72e8eafb4 100644
--- a/app/assets/javascripts/super_sidebar/components/user_bar.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue
@@ -1,10 +1,12 @@
<script>
-import { GlAvatar, GlDropdown, GlIcon } from '@gitlab/ui';
+import { GlAvatar, GlDropdown, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import SafeHtml from '~/vue_shared/directives/safe_html';
import NewNavToggle from '~/nav/components/new_nav_toggle.vue';
import logo from '../../../../views/shared/_logo.svg';
+import CreateMenu from './create_menu.vue';
import Counter from './counter.vue';
+import MergeRequestMenu from './merge_request_menu.vue';
export default {
logo,
@@ -12,15 +14,19 @@ export default {
GlAvatar,
GlDropdown,
GlIcon,
+ CreateMenu,
NewNavToggle,
Counter,
+ MergeRequestMenu,
},
i18n: {
+ createNew: __('Create new...'),
issues: __('Issues'),
mergeRequests: __('Merge requests'),
todoList: __('To-Do list'),
},
directives: {
+ GlTooltip: GlTooltipDirective,
SafeHtml,
},
inject: ['rootPath', 'toggleNewNavEndpoint'],
@@ -39,11 +45,7 @@ export default {
<div class="gl-flex-grow-1">
<a v-safe-html="$options.logo" :href="rootPath"></a>
</div>
- <gl-dropdown variant="link" no-caret>
- <template #button-content>
- <gl-icon name="plus" class="gl-vertical-align-middle gl-text-black-normal" />
- </template>
- </gl-dropdown>
+ <create-menu :groups="sidebarData.create_new_menu_groups" />
<button class="gl-border-none">
<gl-icon name="search" class="gl-vertical-align-middle" />
</button>
@@ -56,17 +58,29 @@ export default {
</div>
<div class="gl-display-flex gl-justify-content-space-between gl-px-3 gl-py-2 gl-gap-2">
<counter
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.issues"
+ class="gl-flex-basis-third"
icon="issues"
:count="sidebarData.assigned_open_issues_count"
:href="sidebarData.issues_dashboard_path"
:label="$options.i18n.issues"
/>
+ <merge-request-menu
+ class="gl-flex-basis-third gl-display-block!"
+ :items="sidebarData.merge_request_menu"
+ >
+ <counter
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.mergeRequests"
+ class="gl-w-full"
+ tabindex="-1"
+ icon="merge-request-open"
+ :count="sidebarData.total_merge_requests_count"
+ :label="$options.i18n.mergeRequests"
+ />
+ </merge-request-menu>
<counter
- icon="merge-request-open"
- :count="sidebarData.assigned_open_merge_requests_count"
- :label="$options.i18n.mergeRequests"
- />
- <counter
+ v-gl-tooltip:super-sidebar.hover.bottom="$options.i18n.todoList"
+ class="gl-flex-basis-third"
icon="todo-done"
:count="sidebarData.todos_pending_count"
href="/dashboard/todos"
diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue
index eecf32f83df..0ae97a47170 100644
--- a/app/assets/javascripts/terms/components/app.vue
+++ b/app/assets/javascripts/terms/components/app.vue
@@ -2,7 +2,6 @@
import { GlButton, GlIntersectionObserver } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import csrf from '~/lib/utils/csrf';
@@ -26,6 +25,9 @@ export default {
data() {
return {
acceptDisabled: true,
+ observer: new MutationObserver(() => {
+ this.setScrollableViewportHeight();
+ }),
};
},
computed: {
@@ -34,23 +36,10 @@ export default {
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);
- });
+ this.observer.observe(document.body, { childList: true, subtree: true });
},
beforeDestroy() {
- this.$options.flashElements.forEach((flashElement) => {
- flashElement.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
- });
+ this.observer.disconnect();
},
methods: {
renderGFM() {
@@ -70,10 +59,6 @@ export default {
scrollHeight - clientHeight
}px)`;
},
- handleFlashClose(event) {
- this.setScrollableViewportHeight();
- event.target.removeEventListener(FLASH_CLOSED_EVENT, this.handleFlashClose);
- },
trackTrialAcceptTerms,
},
};
@@ -96,7 +81,7 @@ export default {
</gl-intersection-observer>
</div>
</div>
- <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end">
+ <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end gl-p-5">
<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" />
diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue
new file mode 100644
index 00000000000..feaf9072ee2
--- /dev/null
+++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue
@@ -0,0 +1,258 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlCard,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlToggle,
+} from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import inboundAddProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql';
+import inboundRemoveProjectCIJobTokenScopeMutation from '../graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql';
+import inboundUpdateCIJobTokenScopeMutation from '../graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql';
+import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_job_token_scope.query.graphql';
+import inboundGetProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql';
+import TokenProjectsTable from './token_projects_table.vue';
+
+export default {
+ i18n: {
+ toggleLabelTitle: s__('CICD|Allow access to this project with a CI_JOB_TOKEN'),
+ toggleHelpText: s__(
+ `CICD|Manage which projects can use their CI_JOB_TOKEN to access this project. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`,
+ ),
+ cardHeaderTitle: s__(
+ 'CICD|Allow CI job tokens from the following projects to access this project',
+ ),
+ settingDisabledMessage: s__(
+ 'CICD|Enable feature to allow job token access by the following projects.',
+ ),
+ addProject: __('Add project'),
+ cancel: __('Cancel'),
+ addProjectPlaceholder: __('Paste project path (i.e. gitlab-org/gitlab)'),
+ projectsFetchError: __('There was a problem fetching the projects'),
+ scopeFetchError: __('There was a problem fetching the job token scope value'),
+ },
+ fields: [
+ {
+ key: 'project',
+ label: __('Project with access'),
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-text-right',
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-10p',
+ },
+ ],
+ components: {
+ GlAlert,
+ GlButton,
+ GlCard,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlToggle,
+ TokenProjectsTable,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ inboundJobTokenScopeEnabled: {
+ query: inboundGetCIJobTokenScopeQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update({ project }) {
+ return project.ciCdSettings.inboundJobTokenScopeEnabled;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.scopeFetchError });
+ },
+ },
+ projects: {
+ query: inboundGetProjectsWithCIJobTokenScopeQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update({ project }) {
+ return project?.ciJobTokenScope?.inboundAllowlist?.nodes ?? [];
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.projectsFetchError });
+ },
+ },
+ },
+ data() {
+ return {
+ inboundJobTokenScopeEnabled: null,
+ targetProjectPath: '',
+ projects: [],
+ };
+ },
+ computed: {
+ isProjectPathEmpty() {
+ return this.targetProjectPath === '';
+ },
+ ciJobTokenHelpPage() {
+ return helpPagePath('ci/jobs/ci_job_token#allow-access-to-your-project-with-a-job-token');
+ },
+ },
+ methods: {
+ async updateCIJobTokenScope() {
+ try {
+ const {
+ data: {
+ ciCdSettingsUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: inboundUpdateCIJobTokenScopeMutation,
+ variables: {
+ input: {
+ fullPath: this.fullPath,
+ inboundJobTokenScopeEnabled: this.inboundJobTokenScopeEnabled,
+ },
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ this.inboundJobTokenScopeEnabled = !this.inboundJobTokenScopeEnabled;
+ createAlert({ message: error.message });
+ }
+ },
+ async addProject() {
+ try {
+ const {
+ data: {
+ ciJobTokenScopeAddProject: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: inboundAddProjectCIJobTokenScopeMutation,
+ variables: {
+ projectPath: this.fullPath,
+ targetProjectPath: this.targetProjectPath,
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ createAlert({ message: error.message });
+ } finally {
+ this.clearTargetProjectPath();
+ this.getProjects();
+ }
+ },
+ async removeProject(removeTargetPath) {
+ try {
+ const {
+ data: {
+ ciJobTokenScopeRemoveProject: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: inboundRemoveProjectCIJobTokenScopeMutation,
+ variables: {
+ projectPath: this.fullPath,
+ targetProjectPath: removeTargetPath,
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ createAlert({ message: error.message });
+ } finally {
+ this.getProjects();
+ }
+ },
+ clearTargetProjectPath() {
+ this.targetProjectPath = '';
+ },
+ getProjects() {
+ this.$apollo.queries.projects.refetch();
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
+ <template v-else>
+ <gl-toggle
+ v-model="inboundJobTokenScopeEnabled"
+ :label="$options.i18n.toggleLabelTitle"
+ @change="updateCIJobTokenScope"
+ >
+ <template #help>
+ <gl-sprintf :message="$options.i18n.toggleHelpText">
+ <template #link="{ content }">
+ <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
+
+ <div>
+ <gl-card class="gl-mt-5 gl-mb-3">
+ <template #header>
+ <h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
+ </template>
+ <template #default>
+ <gl-form-input
+ v-model="targetProjectPath"
+ :placeholder="$options.i18n.addProjectPlaceholder"
+ />
+ </template>
+ <template #footer>
+ <gl-button variant="confirm" :disabled="isProjectPathEmpty" @click="addProject">
+ {{ $options.i18n.addProject }}
+ </gl-button>
+ <gl-button @click="clearTargetProjectPath">{{ $options.i18n.cancel }}</gl-button>
+ </template>
+ </gl-card>
+ <gl-alert
+ v-if="!inboundJobTokenScopeEnabled"
+ class="gl-mb-3"
+ variant="warning"
+ :dismissible="false"
+ :show-icon="false"
+ >
+ {{ $options.i18n.settingDisabledMessage }}
+ </gl-alert>
+ <token-projects-table
+ :projects="projects"
+ :table-fields="$options.fields"
+ @removeProject="removeProject"
+ />
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/token_access/components/opt_in_jwt.vue b/app/assets/javascripts/token_access/components/opt_in_jwt.vue
new file mode 100644
index 00000000000..c774f37b1e4
--- /dev/null
+++ b/app/assets/javascripts/token_access/components/opt_in_jwt.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlLink, GlLoadingIcon, GlSprintf, GlToggle } from '@gitlab/ui';
+import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
+import { createAlert } from '~/flash';
+import { __, s__ } from '~/locale';
+import updateOptInJwtMutation from '../graphql/mutations/update_opt_in_jwt.mutation.graphql';
+import getOptInJwtSettingQuery from '../graphql/queries/get_opt_in_jwt_setting.query.graphql';
+import { LIMIT_JWT_ACCESS_SNIPPET, OPT_IN_JWT_HELP_LINK } from '../constants';
+
+export default {
+ i18n: {
+ labelText: s__('CICD|Limit JSON Web Token (JWT) access'),
+ helpText: s__(
+ `CICD|The JWT must be manually declared in each job that needs it. When disabled, the token is always available in all jobs in the pipeline. %{linkStart}Learn more.%{linkEnd}`,
+ ),
+ expandedText: s__(
+ 'CICD|Use the %{codeStart}secrets%{codeEnd} keyword to configure a job with a JWT.',
+ ),
+ copyToClipboard: __('Copy to clipboard'),
+ fetchError: s__('CICD|There was a problem fetching the token access settings.'),
+ updateError: s__('CICD|An error occurred while update the setting. Please try again.'),
+ },
+ components: {
+ CodeInstruction,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlToggle,
+ },
+ inject: ['fullPath'],
+ apollo: {
+ optInJwt: {
+ query: getOptInJwtSettingQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update({
+ project: {
+ ciCdSettings: { optInJwt },
+ },
+ }) {
+ return optInJwt;
+ },
+ error() {
+ createAlert({ message: this.$options.i18n.fetchError });
+ },
+ },
+ },
+ data() {
+ return {
+ optInJwt: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.optInJwt.loading;
+ },
+ },
+ methods: {
+ async updateOptInJwt() {
+ try {
+ const {
+ data: {
+ ciCdSettingsUpdate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: updateOptInJwtMutation,
+ variables: {
+ input: {
+ fullPath: this.fullPath,
+ optInJwt: this.optInJwt,
+ },
+ },
+ });
+
+ if (errors.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.updateError });
+ }
+ },
+ },
+ OPT_IN_JWT_HELP_LINK,
+ LIMIT_JWT_ACCESS_SNIPPET,
+};
+</script>
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-5" />
+ <template v-else>
+ <gl-toggle
+ v-model="optInJwt"
+ class="gl-mt-5"
+ :label="$options.i18n.labelText"
+ @change="updateOptInJwt"
+ >
+ <template #help>
+ <gl-sprintf :message="$options.i18n.helpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.OPT_IN_JWT_HELP_LINK" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
+ <div v-if="optInJwt" class="gl-mt-5" data-testid="opt-in-jwt-expanded-section">
+ <gl-sprintf :message="$options.i18n.expandedText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ <code-instruction
+ class="gl-mt-3"
+ :instruction="$options.LIMIT_JWT_ACCESS_SNIPPET"
+ :copy-text="$options.i18n.copyToClipboard"
+ multiline
+ />
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue
index fe99f3e1fdd..0deae1a1d82 100644
--- a/app/assets/javascripts/token_access/components/token_access.vue
+++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue
@@ -35,6 +35,27 @@ export default {
projectsFetchError: __('There was a problem fetching the projects'),
scopeFetchError: __('There was a problem fetching the job token scope value'),
},
+ fields: [
+ {
+ key: 'project',
+ label: __('Project that can be accessed'),
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
+ },
+ {
+ key: 'namespace',
+ label: __('Namespace'),
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-40p',
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-text-right',
+ thClass: 'gl-border-t-none!',
+ columnClass: 'gl-w-10p',
+ },
+ ],
components: {
GlAlert,
GlButton,
@@ -93,7 +114,7 @@ export default {
return this.targetProjectPath === '';
},
ciJobTokenHelpPage() {
- return helpPagePath('ci/jobs/ci_job_token');
+ return helpPagePath('ci/jobs/ci_job_token#limit-your-projects-job-token-access');
},
},
methods: {
@@ -228,7 +249,11 @@ export default {
>
{{ $options.i18n.settingDisabledMessage }}
</gl-alert>
- <token-projects-table :projects="projects" @removeProject="removeProject" />
+ <token-projects-table
+ :projects="projects"
+ :table-fields="$options.fields"
+ @removeProject="removeProject"
+ />
</div>
</template>
</div>
diff --git a/app/assets/javascripts/token_access/components/token_access_app.vue b/app/assets/javascripts/token_access/components/token_access_app.vue
new file mode 100644
index 00000000000..59d59757735
--- /dev/null
+++ b/app/assets/javascripts/token_access/components/token_access_app.vue
@@ -0,0 +1,27 @@
+<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import OutboundTokenAccess from './outbound_token_access.vue';
+import InboundTokenAccess from './inbound_token_access.vue';
+import OptInJwt from './opt_in_jwt.vue';
+
+export default {
+ components: {
+ OutboundTokenAccess,
+ InboundTokenAccess,
+ OptInJwt,
+ },
+ mixins: [glFeatureFlagMixin()],
+ computed: {
+ inboundTokenAccessEnabled() {
+ return this.glFeatures.ciInboundJobTokenScope;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <inbound-token-access v-if="inboundTokenAccessEnabled" class="gl-pb-5" />
+ <outbound-token-access class="gl-py-5" />
+ <opt-in-jwt />
+ </div>
+</template>
diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue
index ce33478cbee..c00dd882895 100644
--- a/app/assets/javascripts/token_access/components/token_projects_table.vue
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -1,32 +1,11 @@
<script>
import { GlButton, GlTable } from '@gitlab/ui';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export default {
i18n: {
emptyText: s__('CI/CD|No projects have been added to the scope'),
},
- fields: [
- {
- key: 'project',
- label: __('Projects that can be accessed'),
- thClass: 'gl-border-t-none!',
- columnClass: 'gl-w-40p',
- },
- {
- key: 'namespace',
- label: __('Namespace'),
- thClass: 'gl-border-t-none!',
- columnClass: 'gl-w-40p',
- },
- {
- key: 'actions',
- label: '',
- tdClass: 'gl-text-right',
- thClass: 'gl-border-t-none!',
- columnClass: 'gl-w-10p',
- },
- ],
components: {
GlButton,
GlTable,
@@ -41,6 +20,10 @@ export default {
type: Array,
required: true,
},
+ tableFields: {
+ type: Array,
+ required: true,
+ },
},
methods: {
removeProject(project) {
@@ -52,7 +35,7 @@ export default {
<template>
<gl-table
:items="projects"
- :fields="$options.fields"
+ :fields="tableFields"
:tbody-tr-attr="{ 'data-testid': 'projects-token-table-row' }"
:empty-text="$options.i18n.emptyText"
show-empty
diff --git a/app/assets/javascripts/token_access/constants.js b/app/assets/javascripts/token_access/constants.js
new file mode 100644
index 00000000000..fb2128462f0
--- /dev/null
+++ b/app/assets/javascripts/token_access/constants.js
@@ -0,0 +1,14 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const LIMIT_JWT_ACCESS_SNIPPET = `job_name:
+ id_tokens:
+ ID_TOKEN_1: # or any other name
+ aud: "..." # sub-keyword to configure the token's audience
+ secrets:
+ TEST_SECRET:
+ vault: db/prod
+`;
+
+export const OPT_IN_JWT_HELP_LINK = helpPagePath('ci/secrets/id_token_authentication', {
+ anchor: 'automatic-id-token-authentication-with-hashicorp-vault',
+});
diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql
new file mode 100644
index 00000000000..f030a892af2
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_add_project_ci_job_token_scope.mutation.graphql
@@ -0,0 +1,7 @@
+mutation inboundAddProjectCIJobTokenScope($projectPath: ID!, $targetProjectPath: ID!) {
+ ciJobTokenScopeAddProject(
+ input: { projectPath: $projectPath, targetProjectPath: $targetProjectPath, direction: INBOUND }
+ ) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql
new file mode 100644
index 00000000000..cc6736bb80d
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql
@@ -0,0 +1,7 @@
+mutation inboundRemoveProjectCIJobTokenScope($projectPath: ID!, $targetProjectPath: ID!) {
+ ciJobTokenScopeRemoveProject(
+ input: { projectPath: $projectPath, targetProjectPath: $targetProjectPath, direction: INBOUND }
+ ) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
new file mode 100644
index 00000000000..aac9feab237
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql
@@ -0,0 +1,8 @@
+mutation inboundUpdateCIJobTokenScope($input: CiCdSettingsUpdateInput!) {
+ ciCdSettingsUpdate(input: $input) {
+ ciCdSettings {
+ inboundJobTokenScopeEnabled
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql
new file mode 100644
index 00000000000..c12b5646423
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/mutations/update_opt_in_jwt.mutation.graphql
@@ -0,0 +1,8 @@
+mutation updateOptInJwt($input: CiCdSettingsUpdateInput!) {
+ ciCdSettingsUpdate(input: $input) {
+ ciCdSettings {
+ optInJwt
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql
new file mode 100644
index 00000000000..a1a216b7dc3
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/queries/get_opt_in_jwt_setting.query.graphql
@@ -0,0 +1,8 @@
+query getOptInJwtSetting($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ ciCdSettings {
+ optInJwt
+ }
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql
new file mode 100644
index 00000000000..68d506a6c41
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql
@@ -0,0 +1,8 @@
+query inboundGetCIJobTokenScope($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ ciCdSettings {
+ inboundJobTokenScopeEnabled
+ }
+ }
+}
diff --git a/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql b/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql
new file mode 100644
index 00000000000..c51bdcbf7d2
--- /dev/null
+++ b/app/assets/javascripts/token_access/graphql/queries/inbound_get_projects_with_ci_job_token_scope.query.graphql
@@ -0,0 +1,18 @@
+query inboundGetProjectsWithCIJobTokenScope($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ ciJobTokenScope {
+ inboundAllowlist {
+ nodes {
+ id
+ name
+ namespace {
+ id
+ fullPath
+ }
+ fullPath
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
index 6a29883290a..0253abe393e 100644
--- a/app/assets/javascripts/token_access/index.js
+++ b/app/assets/javascripts/token_access/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import TokenAccess from './components/token_access.vue';
+import TokenAccessApp from './components/token_access_app.vue';
Vue.use(VueApollo);
@@ -25,7 +25,7 @@ export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
fullPath,
},
render(createElement) {
- return createElement(TokenAccess);
+ return createElement(TokenAccessApp);
},
});
};
diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js
index 6014f1ba3ee..df527e24d93 100644
--- a/app/assets/javascripts/tracking/get_standard_context.js
+++ b/app/assets/javascripts/tracking/get_standard_context.js
@@ -10,7 +10,7 @@ export default function getStandardContext({ extra = {} } = {}) {
...data,
source: SNOWPLOW_JS_SOURCE,
google_analytics_id: getCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME) ?? '',
- extra: extra || data.extra,
+ extra: { ...data.extra, ...extra },
},
};
}
diff --git a/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue b/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue
new file mode 100644
index 00000000000..9322171cad8
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/components/usage_quotas_app.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlSprintf, GlTab, GlTabs } from '@gitlab/ui';
+import { USAGE_QUOTAS_TITLE, USAGE_QUOTAS_SUBTITLE } from '../constants';
+
+export default {
+ name: 'UsageQuotasApp',
+ components: { GlSprintf, GlTab, GlTabs },
+ inject: ['namespaceName'],
+ computed: {
+ placeholder() {
+ return `storage_app_placeholder`;
+ },
+ },
+ USAGE_QUOTAS_TITLE,
+ USAGE_QUOTAS_SUBTITLE,
+};
+</script>
+
+<template>
+ <section>
+ <h1>{{ $options.USAGE_QUOTAS_TITLE }}</h1>
+ <p data-testid="usage-quotas-page-subtitle">
+ <gl-sprintf :message="$options.USAGE_QUOTAS_SUBTITLE">
+ <template #namespaceName>
+ <strong>
+ {{ namespaceName }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-tabs>
+ <gl-tab title="Storage"> {{ placeholder }} </gl-tab>
+ </gl-tabs>
+ </section>
+</template>
diff --git a/app/assets/javascripts/usage_quotas/constants.js b/app/assets/javascripts/usage_quotas/constants.js
new file mode 100644
index 00000000000..f637d241778
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/constants.js
@@ -0,0 +1,7 @@
+import { s__ } from '~/locale';
+
+export const USAGE_QUOTAS_TITLE = s__('UsageQuota|Usage Quotas');
+
+export const USAGE_QUOTAS_SUBTITLE = s__(
+ 'UsageQuota|Usage of group resources across the projects in the %{namespaceName} group',
+);
diff --git a/app/assets/javascripts/usage_quotas/index.js b/app/assets/javascripts/usage_quotas/index.js
new file mode 100644
index 00000000000..e1032cd8d54
--- /dev/null
+++ b/app/assets/javascripts/usage_quotas/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import UsageQuotasApp from './components/usage_quotas_app.vue';
+
+export default () => {
+ const el = document.getElementById('js-usage-quotas-view');
+
+ if (!el) {
+ return false;
+ }
+
+ const { namespaceName } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'UsageQuotasView',
+ provide: {
+ namespaceName,
+ },
+ render(createElement) {
+ return createElement(UsageQuotasApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue
index aabb7fde396..0e41a214888 100644
--- a/app/assets/javascripts/users/profile/components/report_abuse_button.vue
+++ b/app/assets/javascripts/users/profile/components/report_abuse_button.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
@@ -14,8 +14,9 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['reportedUserId', 'reportedFromUrl'],
i18n: {
- reportAbuse: __('Report abuse to administrator'),
+ reportAbuse: s__('ReportAbuse|Report abuse to administrator'),
},
data() {
return {
@@ -28,11 +29,8 @@ export default {
},
},
methods: {
- openDrawer() {
- this.open = true;
- },
- closeDrawer() {
- this.open = false;
+ toggleDrawer(open) {
+ this.open = open;
},
hideTooltips() {
this.$root.$emit(BV_HIDE_TOOLTIP);
@@ -47,9 +45,14 @@ export default {
category="primary"
:aria-label="buttonTooltipText"
icon="error"
- @click="openDrawer"
+ @click="toggleDrawer(true)"
@mouseout="hideTooltips"
/>
- <abuse-category-selector :show-drawer="open" @close-drawer="closeDrawer" />
+ <abuse-category-selector
+ :reported-user-id="reportedUserId"
+ :reported-from-url="reportedFromUrl"
+ :show-drawer="open"
+ @close-drawer="toggleDrawer(false)"
+ />
</span>
</template>
diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js
index 37f8e3ac471..c6b85489785 100644
--- a/app/assets/javascripts/users/profile/index.js
+++ b/app/assets/javascripts/users/profile/index.js
@@ -10,7 +10,12 @@ export const initReportAbuse = () => {
return new Vue({
el,
- provide: { reportAbusePath, reportedUserId, reportedFromUrl },
+ name: 'ReportAbuseButtonRoot',
+ provide: {
+ reportAbusePath,
+ reportedUserId: parseInt(reportedUserId, 10),
+ reportedFromUrl,
+ },
render(createElement) {
return createElement(ReportAbuseButton);
},
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 7c1204c511c..1af47b020f7 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -447,7 +447,7 @@ function UsersSelect(currentUser, els, options = {}) {
hidden() {
if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass(elsClassName)) {
- if (window.gon?.features?.realtimeReviewers) {
+ if (!$dropdown.closest('.merge-request-form').length) {
$dropdown.data('deprecatedJQueryDropdown').clearMenu();
$dropdown.closest('.selectbox').children('input[type="hidden"]').remove();
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index 5339d7faf85..917ed259dd0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -35,6 +35,12 @@ export default {
return sprintf(__('%{widget} options'), { widget: this.widget });
},
+ hasOneOption() {
+ return this.tertiaryButtons.length === 1;
+ },
+ hasMultipleOptions() {
+ return this.tertiaryButtons.length > 1;
+ },
},
methods: {
onClickAction(action) {
@@ -75,34 +81,59 @@ export default {
<template>
<div class="gl-display-flex gl-align-items-flex-start">
- <gl-dropdown
- v-if="tertiaryButtons.length"
- v-gl-tooltip
- :title="__('Options')"
- :text="dropdownLabel"
- icon="ellipsis_v"
- no-caret
- category="tertiary"
- right
- lazy
- text-sr-only
- size="small"
- toggle-class="gl-p-2!"
- class="gl-display-block gl-md-display-none!"
- >
- <gl-dropdown-item
+ <template v-if="hasOneOption">
+ <gl-button
v-for="(btn, index) in tertiaryButtons"
+ :id="btn.id"
:key="index"
+ v-gl-tooltip.hover
+ :title="setTooltip(btn)"
:href="btn.href"
:target="btn.target"
+ :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
:data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="actionButtonQaSelector(btn)"
:data-method="btn.dataMethod"
+ :icon="btn.icon"
+ :data-testid="btn.testId || 'extension-actions-button'"
+ :variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
+ category="tertiary"
+ size="small"
+ class="gl-md-display-block gl-float-left"
@click="onClickAction(btn)"
>
{{ btn.text }}
- </gl-dropdown-item>
- </gl-dropdown>
- <template v-if="tertiaryButtons.length">
+ </gl-button>
+ </template>
+ <template v-if="hasMultipleOptions">
+ <gl-dropdown
+ v-gl-tooltip
+ :title="__('Options')"
+ :text="dropdownLabel"
+ icon="ellipsis_v"
+ no-caret
+ category="tertiary"
+ right
+ lazy
+ text-sr-only
+ size="small"
+ toggle-class="gl-p-2!"
+ class="gl-display-block gl-md-display-none!"
+ >
+ <gl-dropdown-item
+ v-for="(btn, index) in tertiaryButtons"
+ :key="index"
+ :href="btn.href"
+ :target="btn.target"
+ :data-clipboard-text="btn.dataClipboardText"
+ :data-method="btn.dataMethod"
+ @click="onClickAction(btn)"
+ >
+ {{ btn.text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-button
v-for="(btn, index) in tertiaryButtons"
:id="btn.id"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
index eb93f42e2f3..4b65d6fd9ac 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -2,6 +2,7 @@
import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__, __ } from '~/locale';
import eventHub from '../../event_hub';
@@ -61,6 +62,7 @@ export default {
fetchingApprovals: true,
hasApprovalAuthError: false,
isApproving: false,
+ updatedCount: 0,
};
},
computed: {
@@ -139,9 +141,11 @@ export default {
this.fetchingApprovals = false;
})
.catch(() =>
- createAlert({
- message: FETCH_ERROR,
- }),
+ this.alerts.push(
+ createAlert({
+ message: FETCH_ERROR,
+ }),
+ ),
);
},
methods: {
@@ -154,22 +158,26 @@ export default {
this.updateApproval(
() => this.service.approveMergeRequest(),
() =>
- createAlert({
- message: APPROVE_ERROR,
- }),
+ this.alerts.push(
+ createAlert({
+ message: APPROVE_ERROR,
+ }),
+ ),
);
},
approveWithAuth(data) {
this.updateApproval(
() => this.service.approveMergeRequestWithAuth(data),
(error) => {
- if (error && error.response && error.response.status === 401) {
+ if (error && error.response && error.response.status === HTTP_STATUS_UNAUTHORIZED) {
this.hasApprovalAuthError = true;
return;
}
- createAlert({
- message: APPROVE_ERROR,
- });
+ this.alerts.push(
+ createAlert({
+ message: APPROVE_ERROR,
+ }),
+ );
},
);
},
@@ -177,9 +185,11 @@ export default {
this.updateApproval(
() => this.service.unapproveMergeRequest(),
() =>
- createAlert({
- message: UNAPPROVE_ERROR,
- }),
+ this.alerts.push(
+ createAlert({
+ message: UNAPPROVE_ERROR,
+ }),
+ ),
);
},
updateApproval(serviceFn, errFn) {
@@ -188,6 +198,7 @@ export default {
return serviceFn()
.then((data) => {
this.mr.setApprovals(data);
+ this.updatedCount += 1;
if (!window.gon?.features?.realtimeMrStatusChange) {
eventHub.$emit('MRWidgetUpdateRequested');
@@ -241,10 +252,10 @@ export default {
/>
<approvals-summary
v-else
- :approved="isApproved"
- :approvals-left="approvals.approvals_left || 0"
- :rules-left="approvals.approvalRuleNamesLeft"
- :approvers="approvedBy"
+ :project-path="mr.targetProjectFullPath"
+ :iid="`${mr.iid}`"
+ :updated-count="updatedCount"
+ :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable"
/>
</div>
<div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules">
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 d7255eb6ad2..697d953874c 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,4 +1,5 @@
<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
import { toNounSeriesText } from '~/lib/utils/grammar';
import { n__, sprintf } from '~/locale';
import {
@@ -7,32 +8,68 @@ import {
APPROVED_BY_OTHERS,
} from '~/vue_merge_request_widget/components/approvals/messages';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/mappers';
+import approvedByQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql';
export default {
+ apollo: {
+ approvalState: {
+ query: approvedByQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ iid: this.iid,
+ };
+ },
+ update: (data) => data.project.mergeRequest,
+ },
+ },
components: {
+ GlSkeletonLoader,
UserAvatarList,
},
props: {
- approved: {
- type: Boolean,
+ projectPath: {
+ type: String,
required: true,
},
- approvalsLeft: {
- type: Number,
+ iid: {
+ type: String,
required: true,
},
- rulesLeft: {
- type: Array,
+ updatedCount: {
+ type: Number,
required: false,
- default: () => [],
+ default: 0,
},
- approvers: {
- type: Array,
+ multipleApprovalRulesAvailable: {
+ type: Boolean,
required: false,
- default: () => [],
+ default: false,
},
},
+ data() {
+ return {
+ approvalState: {},
+ };
+ },
computed: {
+ approvers() {
+ return this.approvalState.approvedBy?.nodes || [];
+ },
+ approved() {
+ return this.approvalState.approved || this.approvalState.approvedBy?.nodes.length > 0;
+ },
+ approvalsLeft() {
+ return this.approvalState.approvalsLeft || 0;
+ },
+ rulesLeft() {
+ return getApprovalRuleNamesLeft(
+ this.multipleApprovalRulesAvailable,
+ (this.approvalState.approvalState?.rules || []).filter((r) => !r.approved),
+ );
+ },
approvalLeftMessage() {
if (this.rulesLeft.length) {
return sprintf(
@@ -81,32 +118,53 @@ export default {
if (!this.currentUserId) {
return false;
}
- return this.approvers.some((approver) => approver.id === this.currentUserId);
+ return this.approvers.some(
+ (approver) => getIdFromGraphQLId(approver.id) === this.currentUserId,
+ );
},
approvedByOthers() {
if (!this.currentUserId) {
return false;
}
- return this.approvers.some((approver) => approver.id !== this.currentUserId);
+ return this.approvers.some(
+ (approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId,
+ );
},
currentUserId() {
return gon.current_user_id;
},
},
+ watch: {
+ updatedCount() {
+ this.$apollo.queries.approvalState.refetch();
+ },
+ },
};
</script>
<template>
<div data-qa-selector="approvals_summary_content">
- <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
- <template v-if="hasApprovers">
- <span v-if="approvalLeftMessage">{{ message }}</span>
- <span v-else class="gl-font-weight-bold">{{ message }}</span>
- <user-avatar-list
- class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
- :img-size="24"
- :items="approvers"
- />
+ <div
+ v-if="$apollo.queries.approvalState.loading"
+ class="gl-display-inline-block gl-vertical-align-middle"
+ style="width: 132px; height: 24px"
+ >
+ <gl-skeleton-loader :width="132" :height="24">
+ <rect width="100" height="24" x="0" y="0" rx="4" />
+ <circle cx="120" cy="12" r="12" />
+ </gl-skeleton-loader>
+ </div>
+ <template v-else>
+ <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
+ <template v-if="hasApprovers">
+ <span v-if="approvalLeftMessage">{{ message }}</span>
+ <span v-else class="gl-font-weight-bold">{{ message }}</span>
+ <user-avatar-list
+ class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
+ :img-size="24"
+ :items="approvers"
+ />
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql
new file mode 100644
index 00000000000..c8cae6a8885
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/queries/approved_by.query.graphql
@@ -0,0 +1,16 @@
+query approvedBy($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ id
+ mergeRequest(iid: $iid) {
+ id
+ approvedBy {
+ nodes {
+ id
+ name
+ avatarUrl
+ webUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue b/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue
new file mode 100644
index 00000000000..bef1d79a655
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/bold_text.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+
+export default {
+ name: 'BoldText',
+ components: {
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message">
+ <template #bold="{ content }">
+ <span class="gl-font-weight-bold" v-text="content"></span>
+ </template>
+ </gl-sprintf>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 7cfc9431c2a..b78293a9815 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
@@ -293,7 +293,7 @@ export default {
}
},
onClickedAction(action) {
- if (action.fullReport) {
+ if (action.trackFullReportClicked) {
this.telemetry?.fullReportClicked();
}
},
@@ -323,16 +323,25 @@ export default {
data-testid="widget-extension-top-level"
>
<div
- class="gl-flex-grow-1 gl-display-flex gl-align-items-center"
+ class="gl-flex-grow-1 gl-display-flex gl-align-items-center gl-flex-wrap"
data-testid="widget-extension-top-level-summary"
>
- <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
- <template v-else-if="hasFetchError">{{ widgetErrorText }}</template>
+ <div v-if="isLoadingSummary" class="gl-w-full gl-line-height-normal">
+ {{ widgetLoadingText }}
+ </div>
+ <div v-else-if="hasFetchError" class="gl-w-full gl-line-height-normal">
+ {{ widgetErrorText }}
+ </div>
<template v-else>
- <span v-safe-html="hydratedSummary.subject"></span>
+ <div
+ v-safe-html="hydratedSummary.subject"
+ class="gl-w-full gl-line-height-normal"
+ ></div>
<template v-if="hydratedSummary.meta">
- <br />
- <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span>
+ <div
+ v-safe-html="hydratedSummary.meta"
+ class="gl-w-full gl-font-sm gl-line-height-normal"
+ ></div>
</template>
</template>
</div>
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 d8a361066f4..2dec95c3fda 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
@@ -11,6 +11,7 @@ import {
import SafeHtml from '~/vue_shared/directives/safe_html';
import { s__, n__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -86,6 +87,10 @@ export default {
},
},
computed: {
+ downstreamPipelines() {
+ const downstream = this.pipeline.triggered;
+ return keepLatestDownstreamPipelines(downstream);
+ },
hasPipeline() {
return this.pipeline && Object.keys(this.pipeline).length > 0;
},
@@ -196,14 +201,13 @@ export default {
<div class="ci-widget-content">
<div class="media-body">
<div
- class="gl-font-weight-bold"
data-testid="pipeline-info-container"
data-qa-selector="merge_request_pipeline_info_content"
>
- {{ pipeline.details.event_type_name || pipeline.details.name }}
+ {{ pipeline.details.event_type_name }}
<gl-link
:href="pipeline.path"
- class="pipeline-id gl-font-weight-normal pipeline-number"
+ class="pipeline-id"
data-testid="pipeline-id"
data-qa-selector="pipeline_link"
>#{{ pipeline.id }}</gl-link
@@ -275,7 +279,7 @@ export default {
<span class="gl-align-items-center gl-display-inline-flex">
<pipeline-mini-graph
v-if="pipeline.details.stages"
- :downstream-pipelines="pipeline.triggered"
+ :downstream-pipelines="downstreamPipelines"
:is-merge-train="isMergeTrain"
:pipeline-path="pipeline.path"
:stages="pipeline.details.stages"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue
index ecf08f78f57..34a1d1facda 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/report_widget_container.vue
@@ -5,16 +5,32 @@ export default {
hasChildren: false,
};
},
- updated() {
- this.hasChildren = this.checkSlots();
- },
mounted() {
- this.hasChildren = this.checkSlots();
+ const setHasChildren = () => {
+ this.hasChildren = Boolean(this.$el.innerText.trim());
+ };
+
+ // Set initial.
+ setHasChildren();
+
+ if (!this.hasChildren) {
+ // Observe children changed.
+ this.observer = new MutationObserver(() => {
+ setHasChildren();
+
+ if (this.hasChildren) {
+ this.observer.disconnect();
+ this.observer = undefined;
+ }
+ });
+
+ this.observer.observe(this.$el, { childList: true, subtree: true });
+ }
},
- methods: {
- checkSlots() {
- return this.$scopedSlots.default?.()?.some((c) => c.tag);
- },
+ beforeUnmount() {
+ if (this.observer) {
+ this.observer.disconnect();
+ }
},
};
</script>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index e5688091cc7..6d7ec607557 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -1,17 +1,23 @@
<script>
import { s__ } from '~/locale';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import StateContainer from '../state_container.vue';
import { DETAILED_MERGE_STATUS } from '../../constants';
export default {
i18n: {
- approvalNeeded: s__('mrWidget|Merge blocked: all required approvals must be given.'),
+ approvalNeeded: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} all required approvals must be given.',
+ ),
blockingMergeRequests: s__(
- 'mrWidget|Merge blocked: you can only merge after the above items are resolved.',
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} you can only merge after the above items are resolved.',
+ ),
+ externalStatusChecksFailed: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} all status checks must pass.',
),
- externalStatusChecksFailed: s__('mrWidget|Merge blocked: all status checks must pass.'),
},
components: {
+ BoldText,
StateContainer,
},
props: {
@@ -38,10 +44,8 @@ export default {
<template>
<state-container :mr="mr" status="failed">
- <span
- class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"
- >
- {{ failedText }}
+ <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
+ <bold-text :message="failedText" />
</span>
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 79e878431ed..837f8b32637 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -1,9 +1,17 @@
<script>
+import { s__ } from '~/locale';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import StateContainer from '../state_container.vue';
+const message = s__(
+ 'mrWidget|%{boldStart}Merge unavailable:%{boldEnd} merge requests are read-only on archived projects.',
+);
+
export default {
name: 'MRWidgetArchived',
+ message,
components: {
+ BoldText,
StateContainer,
},
props: {
@@ -17,8 +25,6 @@ export default {
<template>
<state-container :mr="mr" status="failed">
- <span class="gl-font-weight-bold">
- {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
- </span>
+ <bold-text :message="$options.message" />
</state-container>
</template>
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 922075516f3..670bd36d61e 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
@@ -16,8 +16,6 @@ export default {
</script>
<template>
<state-container :mr="mr" status="loading">
- <span class="gl-font-weight-bold">
- {{ s__('mrWidget|Checking if merge request can be merged…') }}
- </span>
+ {{ s__('mrWidget|Checking if merge request can be merged…') }}
</state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index a5d982fe221..83d718f5a54 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,5 +1,7 @@
<script>
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
import conflictsStateQuery from '../../queries/states/conflicts.query.graphql';
@@ -8,6 +10,7 @@ import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetConflicts',
components: {
+ BoldText,
GlSkeletonLoader,
GlButton,
StateContainer,
@@ -55,6 +58,17 @@ export default {
);
},
},
+ i18n: {
+ shouldBeRebased: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} fast-forward merge is not possible. To merge this request, first rebase locally.',
+ ),
+ shouldBeResolved: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} merge conflicts must be resolved.',
+ ),
+ usersWriteBranches: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} Users who can write to the source or target branches can resolve the conflicts.',
+ ),
+ },
};
</script>
<template>
@@ -67,22 +81,13 @@ export default {
</gl-skeleton-loader>
</template>
<template v-if="!isLoading">
- <span v-if="state.shouldBeRebased" class="bold gl-ml-0! gl-text-body!">
- {{
- s__(`mrWidget|Merge blocked: fast-forward merge is not possible.
- To merge this request, first rebase locally.`)
- }}
+ <span v-if="state.shouldBeRebased" class="gl-ml-0! gl-text-body!">
+ <bold-text :message="$options.i18n.shouldBeRebased" />
</span>
<template v-else>
- <span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2">
- {{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }}
- <span v-if="!userPermissions.canMerge">
- {{
- s__(
- `mrWidget|Users who can write to the source or target branches can resolve the conflicts.`,
- )
- }}
- </span>
+ <span class="gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2">
+ <bold-text v-if="userPermissions.canMerge" :message="$options.i18n.shouldBeResolved" />
+ <bold-text v-else :message="$options.i18n.usersWriteBranches" />
</span>
</template>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 8a7f15d8d1a..bfc2c282f4c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -101,12 +101,14 @@ export default {
</span>
</state-container>
<state-container v-else :mr="mr" status="failed" :actions="actions">
- <span class="gl-font-weight-bold">
- <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error">
- {{ mergeError }}
- </span>
- <span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
- <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
+ <span
+ v-if="mr.mergeError"
+ class="has-error-message gl-font-weight-bold"
+ data-testid="merge-error"
+ >
+ {{ mergeError }}
</span>
+ <span v-else class="gl-font-weight-bold"> {{ s__('mrWidget|Merge failed.') }} </span>
+ <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
</state-container>
</template>
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 51ac2576f75..c94718ca756 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
@@ -2,6 +2,7 @@
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import eventHub from '../../event_hub';
import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants';
import StatusIcon from '../mr_widget_status_icon.vue';
@@ -12,6 +13,7 @@ const { MERGE_FAILURE } = transitions;
export default {
name: 'MRWidgetMerging',
components: {
+ BoldText,
StatusIcon,
},
props: {
@@ -83,11 +85,9 @@ export default {
<template>
<div class="mr-widget-body mr-state-locked media">
<status-icon status="loading" />
- <div class="media-body">
- <h4>
- {{ mergeStatus.message }}
- <gl-emoji :data-name="mergeStatus.emoji" />
- </h4>
+ <div class="media-body" data-testid="merging-state">
+ <bold-text :message="mergeStatus.message" />
+ <gl-emoji :data-name="mergeStatus.emoji" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 5e073bf7c04..f1ddf94597b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -63,12 +63,14 @@ export default {
<status-icon :show-disabled-button="true" status="failed" />
<div class="media-body space-children">
- <span class="gl-font-weight-bold js-branch-text" data-testid="widget-content">
- <gl-sprintf :message="warning">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
+ <span class="js-branch-text" data-testid="widget-content">
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="warning">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </span>
{{ restore }}
<gl-icon
v-gl-tooltip
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
index d837551a813..536e61e57d3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
@@ -1,9 +1,17 @@
<script>
+import { s__ } from '~/locale';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import StatusIcon from '../mr_widget_status_icon.vue';
+const message = s__(
+ 'mrWidget|%{boldStart}Ready to be merged automatically.%{boldEnd} Ask someone with write access to this repository to merge this request.',
+);
+
export default {
name: 'MRWidgetNotAllowed',
+ message,
components: {
+ BoldText,
StatusIcon,
},
};
@@ -13,12 +21,7 @@ export default {
<div class="mr-widget-body media">
<status-icon status="success" />
<div class="media-body space-children">
- <span class="gl-font-weight-bold">
- {{
- s__(`mrWidget|Ready to be merged automatically.
-Ask someone with write access to this repository to merge this request`)
- }}
- </span>
+ <bold-text :message="$options.message" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
index 13920daca15..beb6310992f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -1,10 +1,18 @@
<script>
+import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import StatusIcon from '../mr_widget_status_icon.vue';
+const message = s__(
+ "mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. It's waiting for a manual action to continue.",
+);
+
export default {
name: 'MRWidgetPipelineBlocked',
+ message,
components: {
+ BoldText,
StatusIcon,
},
mixins: [glFeatureFlagMixin()],
@@ -14,13 +22,7 @@ export default {
<div class="mr-widget-body media">
<status-icon status="failed" />
<div class="media-body space-children">
- <span class="gl-font-weight-bold">
- {{
- s__(
- `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`,
- )
- }}
- </span>
+ <bold-text :message="$options.message" />
</div>
</div>
</template>
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 d687f0346c7..ec6c2cf34c0 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,16 +1,24 @@
<script>
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import simplePoll from '~/lib/utils/simple_poll';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
import StateContainer from '../state_container.vue';
+const i18n = {
+ rebaseError: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} the source branch must be rebased onto the target branch.',
+ ),
+};
+
export default {
name: 'MRWidgetRebase',
+ i18n,
apollo: {
state: {
query: rebaseQuery,
@@ -21,6 +29,7 @@ export default {
},
},
components: {
+ BoldText,
GlSkeletonLoader,
GlButton,
StateContainer,
@@ -69,9 +78,6 @@ export default {
}
return 'success';
},
- fastForwardMergeText() {
- return __('Merge blocked: the source branch must be rebased onto the target branch.');
- },
showRebaseWithoutPipeline() {
return (
!this.mr.onlyAllowMergeIfPipelineSucceeds ||
@@ -146,29 +152,29 @@ export default {
<template v-if="!isLoading">
<span
v-if="rebaseInProgress || isMakingRequest"
- class="gl-ml-0! gl-text-body! gl-font-weight-bold"
+ class="gl-ml-0! gl-text-body!"
data-testid="rebase-message"
- >{{ __('Rebase in progress') }}</span
+ >{{ s__('mrWidget|Rebase in progress') }}</span
>
<span
v-if="!rebaseInProgress && !canPushToSourceBranch"
- class="gl-text-body! gl-font-weight-bold gl-ml-0!"
+ class="gl-text-body! gl-ml-0!"
data-testid="rebase-message"
- >{{ fastForwardMergeText }}</span
>
+ <bold-text :message="$options.i18n.rebaseError" />
+ </span>
<div
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
>
<span
v-if="!rebasingError"
- class="gl-font-weight-bold gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
+ class="gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
data-testid="rebase-message"
data-qa-selector="no_fast_forward_message_content"
- >{{
- __('Merge blocked: the source branch must be rebased onto the target branch.')
- }}</span
>
+ <bold-text :message="$options.i18n.rebaseError" />
+ </span>
<span
v-else
class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
@@ -187,7 +193,7 @@ export default {
class="gl-align-self-start"
@click="rebase"
>
- {{ __('Rebase') }}
+ {{ s__('mrWidget|Rebase') }}
</gl-button>
<gl-button
v-if="showRebaseWithoutPipeline"
@@ -199,7 +205,7 @@ export default {
class="gl-align-self-start gl-mr-2"
@click="rebaseWithoutCi"
>
- {{ __('Rebase without pipeline') }}
+ {{ s__('mrWidget|Rebase without pipeline') }}
</gl-button>
</template>
</state-container>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index 853895a4296..1896851952b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -2,11 +2,13 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import StatusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'PipelineFailed',
components: {
+ BoldText,
GlLink,
GlSprintf,
StatusIcon,
@@ -24,7 +26,10 @@ export default {
},
i18n: {
failedMessage: s__(
- `mrWidget|Merge blocked: pipeline must succeed. Push a commit that fixes the failure, or %{linkStart}learn about other solutions.%{linkEnd}`,
+ `mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. Push a commit that fixes the failure or %{linkStart}learn about other solutions.%{linkEnd}`,
+ ),
+ blockedMessage: s__(
+ "mrWidget|%{boldStart}Merge blocked:%{boldEnd} pipeline must succeed. It's waiting for a manual action to continue.",
),
},
};
@@ -34,20 +39,17 @@ export default {
<div class="mr-widget-body media">
<status-icon status="failed" />
<div class="media-body space-children">
- <span class="gl-font-weight-bold">
- <span v-if="mr.isPipelineBlocked">
- {{
- s__(
- `mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`,
- )
- }}
- </span>
+ <span>
+ <bold-text v-if="mr.isPipelineBlocked" :message="$options.i18n.blockedMessage" />
<gl-sprintf v-else :message="$options.i18n.failedMessage">
<template #link="{ content }">
<gl-link :href="troubleshootingDocsPath" target="_blank">
{{ content }}
</gl-link>
</template>
+ <template #bold="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
</gl-sprintf>
</span>
</div>
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 23b163e2c6a..bb8990a48b1 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
@@ -15,6 +15,7 @@ import { isEmpty } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import { createAlert } from '~/flash';
+import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __, s__, n__ } from '~/locale';
@@ -98,7 +99,7 @@ export default {
},
variables() {
return {
- issuableId: convertToGraphQLId('MergeRequest', this.mr?.id),
+ issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id),
};
},
updateQuery(
@@ -524,6 +525,7 @@ export default {
v-model="removeSourceBranch"
:disabled="isRemoveSourceBranchButtonDisabled"
class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5 gl-mb-3 gl-md-mb-0"
+ data-testid="delete-source-branch-checkbox"
>
{{ __('Delete source branch') }}
</gl-form-checkbox>
@@ -634,6 +636,7 @@ export default {
v-gl-tooltip.hover.focus="__('Select merge moment')"
:disabled="isMergeButtonDisabled"
variant="confirm"
+ data-testid="merge-immediately-dropdown"
data-qa-selector="merge_moment_dropdown"
toggle-class="btn-icon js-merge-moment"
>
@@ -643,7 +646,8 @@ export default {
</template>
<gl-dropdown-item
icon-name="warning"
- button-class="accept-merge-request js-merge-immediately-button"
+ button-class="accept-merge-request"
+ data-testid="merge-immediately-button"
data-qa-selector="merge_immediately_menu_item"
@click="handleMergeImmediatelyButtonClick"
>
@@ -697,7 +701,11 @@ export default {
:merge-commit-path="mr.mergeCommitPath"
/>
</li>
- <li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
+ <li
+ v-if="mr.state !== 'closed'"
+ class="gl-line-height-normal"
+ data-testid="source-branch-deleted-text"
+ >
{{ sourceBranchDeletedText }}
</li>
<li v-if="mr.relatedLinks" class="gl-line-height-normal">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index 27919f90cc3..2aa345b420e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -1,11 +1,13 @@
<script>
import { GlButton } from '@gitlab/ui';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import { I18N_SHA_MISMATCH } from '../../i18n';
import StateContainer from '../state_container.vue';
export default {
name: 'ShaMismatch',
components: {
+ BoldText,
GlButton,
StateContainer,
},
@@ -24,10 +26,10 @@ export default {
<template>
<state-container :mr="mr" status="failed">
<span
- class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
+ class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
data-qa-selector="head_mismatch_content"
>
- {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }}
+ <bold-text :message="$options.i18n.I18N_SHA_MISMATCH.warningMessage" />
</span>
<template #actions>
<gl-button
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 9f3748599dc..0fd5551979d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -1,11 +1,17 @@
<script>
import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
import notesEventHub from '~/notes/event_hub';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import StateContainer from '../state_container.vue';
+const message = s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} all threads must be resolved.');
+
export default {
name: 'UnresolvedDiscussions',
+ message,
components: {
+ BoldText,
GlButton,
StateContainer,
},
@@ -25,10 +31,8 @@ export default {
<template>
<state-container :mr="mr" status="failed">
- <span
- class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"
- >
- {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
+ <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!">
+ <bold-text :message="$options.message" />
</span>
<template #actions>
<gl-button
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 211fbba305f..02d4f2499fe 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
@@ -2,18 +2,23 @@
import { GlButton } from '@gitlab/ui';
import { produce } from 'immer';
import { createAlert } from '~/flash';
-import toast from '~/vue_shared/plugins/global_toast';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+import MergeRequest from '~/merge_request';
+import BoldText from '~/vue_merge_request_widget/components/bold_text.vue';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import getStateQuery from '../../queries/get_state.query.graphql';
import draftQuery from '../../queries/states/draft.query.graphql';
import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql';
import StateContainer from '../state_container.vue';
-import eventHub from '../../event_hub';
+
+// Export for testing
+export const MSG_SOMETHING_WENT_WRONG = __('Something went wrong. Please try again.');
+export const MSG_MARK_READY = s__('mrWidget|Mark as ready');
export default {
name: 'WorkInProgress',
components: {
+ BoldText,
GlButton,
StateContainer,
},
@@ -62,7 +67,7 @@ export default {
) {
if (errors?.length) {
createAlert({
- message: __('Something went wrong. Please try again.'),
+ message: MSG_SOMETHING_WENT_WRONG,
});
return;
@@ -109,19 +114,12 @@ export default {
},
},
}) => {
- toast(__('Marked as ready. Merging is now allowed.'));
- document.querySelector(
- '.merge-request .detail-page-description .title',
- ).textContent = title;
-
- if (!window.gon?.features?.realtimeMrStatusChange) {
- eventHub.$emit('MRWidgetUpdateRequested');
- }
+ MergeRequest.toggleDraftStatus(title, true);
},
)
.catch(() =>
createAlert({
- message: __('Something went wrong. Please try again.'),
+ message: MSG_SOMETHING_WENT_WRONG,
}),
)
.finally(() => {
@@ -129,13 +127,19 @@ export default {
});
},
},
+ i18n: {
+ removeDraftStatus: s__(
+ 'mrWidget|%{boldStart}Merge blocked:%{boldEnd} Select %{boldStart}Mark as ready%{boldEnd} to remove it from Draft status.',
+ ),
+ },
+ MSG_MARK_READY,
};
</script>
<template>
<state-container :mr="mr" status="failed">
- <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1">
- {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }}
+ <span class="gl-ml-0! gl-text-body! gl-flex-grow-1">
+ <bold-text :message="$options.i18n.removeDraftStatus" />
</span>
<template #actions>
<gl-button
@@ -148,7 +152,7 @@ export default {
data-testid="removeWipButton"
@click="handleRemoveDraft"
>
- {{ s__('mrWidget|Mark as ready') }}
+ {{ $options.MSG_MARK_READY }}
</gl-button>
</template>
</state-container>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
index 7343c98938c..73129a86877 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -2,6 +2,7 @@
import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { logError } from '~/lib/logger';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { sprintf, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
@@ -17,8 +18,12 @@ import ActionButtons from './action_buttons.vue';
const FETCH_TYPE_COLLAPSED = 'collapsed';
const FETCH_TYPE_EXPANDED = 'expanded';
const WIDGET_PREFIX = 'Widget';
+const MISSING_RESPONSE_HEADERS =
+ 'MR Widget: raesponse object should contain status and headers object. Make sure to include that in your `fetchCollapsedData` and `fetchExpandedData` functions.';
export default {
+ MISSING_RESPONSE_HEADERS,
+
components: {
ActionButtons,
StatusIcon,
@@ -92,6 +97,23 @@ export default {
type: Boolean,
required: true,
},
+ /**
+ * A button is composed of the following properties:
+ *
+ * {
+ * "id": string,
+ * "href": string,
+ * "dataMethod": string,
+ * "dataClipboardText": string,
+ * "icon": string,
+ * "variant": string,
+ * "loading": boolean,
+ * "testId":string,
+ * "text": string,
+ * "class": string | Object,
+ * "trackFullReportClicked": boolean,
+ * }
+ */
actionButtons: {
type: Array,
required: false,
@@ -182,7 +204,7 @@ export default {
},
methods: {
onActionClick(action) {
- if (action.fullReport) {
+ if (action.trackFullReportClicked) {
this.telemetryHub?.fullReportClicked();
}
},
@@ -225,6 +247,14 @@ export default {
},
method: 'fetchData',
successCallback: (response) => {
+ if (
+ typeof response.status === 'undefined' ||
+ typeof response.headers === 'undefined'
+ ) {
+ logError(MISSING_RESPONSE_HEADERS);
+ throw new Error(MISSING_RESPONSE_HEADERS);
+ }
+
const headers = normalizeHeaders(response.headers);
if (headers['POLL-INTERVAL']) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 7109bed7743..85ae298fcea 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -4,9 +4,7 @@ import { stateToComponentMap as classStateMap, stateKey } from './stores/state_m
export const SUCCESS = 'success';
export const WARNING = 'warning';
-export const DANGER = 'danger';
export const INFO = 'info';
-export const CONFIRM = 'confirm';
export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds';
@@ -28,39 +26,39 @@ export const SP_ICON_NAME = 'status_notfound';
export const MERGE_ACTIVE_STATUS_PHRASES = [
{
- message: s__('mrWidget|Merging! Drum roll, please…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Drum roll, please…'),
emoji: 'drum',
},
{
- message: s__("mrWidget|Merging! We're almost there…"),
+ message: s__("mrWidget|%{boldStart}Merging!%{boldEnd} We're almost there…"),
emoji: 'sparkles',
},
{
- message: s__('mrWidget|Merging! Changes will land soon…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Changes will land soon…'),
emoji: 'airplane_arriving',
},
{
- message: s__('mrWidget|Merging! Changes are being shipped…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Changes are being shipped…'),
emoji: 'ship',
},
{
- message: s__("mrWidget|Merging! Everything's good…"),
+ message: s__("mrWidget|%{boldStart}Merging!%{boldEnd} Everything's good…"),
emoji: 'relieved',
},
{
- message: s__('mrWidget|Merging! This is going to be great…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} This is going to be great…'),
emoji: 'heart_eyes',
},
{
- message: s__('mrWidget|Merging! Lift-off in 5… 4… 3…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Lift-off in 5… 4… 3…'),
emoji: 'rocket',
},
{
- message: s__('mrWidget|Merging! The changes are leaving the station…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} The changes are leaving the station…'),
emoji: 'bullettrain_front',
},
{
- message: s__('mrWidget|Merging! Take a deep breath and relax…'),
+ message: s__('mrWidget|%{boldStart}Merging!%{boldEnd} Take a deep breath and relax…'),
emoji: 'sunglasses',
},
];
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 ca95e1b5de8..ff225afbc7b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -44,7 +44,12 @@ export default {
console.log('Hello world');
},
},
- { text: 'Full report', href: this.conflictsDocsPath, target: '_blank', fullReport: true },
+ {
+ text: 'Full report',
+ href: this.conflictsDocsPath,
+ target: '_blank',
+ trackFullReportClicked: true,
+ },
];
},
shouldCollapse() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue
index f0b20adc5cf..6155a912683 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue
@@ -70,6 +70,9 @@ export default {
artifacts() {
return this.reportArtifacts || [];
},
+ hasSecurityReports() {
+ return this.artifacts.length > 0;
+ },
},
methods: {
handleIsLoading(value) {
@@ -99,6 +102,7 @@ export default {
<template>
<mr-widget
+ v-if="hasSecurityReports"
:has-error="hasError"
:error-text="$options.i18n.apiError"
:status-icon-name="$options.icons.warning"
@@ -108,7 +112,7 @@ export default {
:summary="$options.i18n.scansHaveRun"
@is-loading="handleIsLoading"
>
- <template v-if="artifacts.length > 0" #action-buttons>
+ <template #action-buttons>
<div class="gl-ml-3">
<gl-dropdown
v-gl-tooltip
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
index 626a99f7d64..c5cbed4a280 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
@@ -115,7 +115,7 @@ export default {
href: report.job_path,
text: this.$options.i18n.fullLog,
target: '_blank',
- fullReport: true,
+ trackFullReportClicked: true,
};
actions.push(action);
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
index 97b9b59e2c3..6ac462d4ad5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -1,6 +1,7 @@
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status';
import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue';
import { EXTENSION_ICONS } from '../../constants';
import {
@@ -74,7 +75,7 @@ export default {
text: this.$options.i18n.fullReport,
href: `${this.pipeline.path}/test_report`,
target: '_blank',
- fullReport: true,
+ trackFullReportClicked: true,
testId: 'full-report-link',
});
@@ -91,7 +92,7 @@ export default {
...response,
data: {
hasSuiteError: suites.some((suite) => suite.status === ERROR_STATUS),
- parsingInProgress: status === 204,
+ parsingInProgress: status === HTTP_STATUS_NO_CONTENT,
...data,
summary: {
recentlyFailed: countRecentlyFailedTests(suites),
diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js
index 5380bcae003..5ca56074031 100644
--- a/app/assets/javascripts/vue_merge_request_widget/i18n.js
+++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js
@@ -17,7 +17,7 @@ export const SQUASH_BEFORE_MERGE = {
};
export const I18N_SHA_MISMATCH = {
- warningMessage: __('Merge blocked: new changes were just added.'),
+ warningMessage: s__('mrWidget|%{boldStart}Merge blocked:%{boldEnd} new changes were just added.'),
actionButtonLabel: __('Review changes'),
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mappers.js b/app/assets/javascripts/vue_merge_request_widget/mappers.js
new file mode 100644
index 00000000000..63c4c3dc871
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mappers.js
@@ -0,0 +1,3 @@
+export function getApprovalRuleNamesLeft(_, rules) {
+ return rules;
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
index 943011949fd..7d0871f696b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js
@@ -1,14 +1,15 @@
-import { hideFlash } from '~/flash';
-
export default {
+ data() {
+ return {
+ alerts: [],
+ };
+ },
methods: {
clearError() {
this.$emit('clearError');
this.hasApprovalAuthError = false;
- const flashEl = document.querySelector('.flash-alert');
- if (flashEl) {
- hideFlash(flashEl);
- }
+ this.alerts.forEach((alert) => alert.dismiss());
+ this.alerts = [];
},
refreshApprovals() {
return this.service.fetchApprovals().then((data) => {
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 00024a594dc..ecbee6544ab 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
@@ -15,8 +15,9 @@ import notify from '~/lib/utils/notify';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
+import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { setFaviconOverlay } from '../lib/utils/favicon';
+import { setFaviconOverlay } from '~/lib/utils/favicon';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
@@ -120,7 +121,7 @@ export default {
},
variables() {
return {
- issuableId: convertToGraphQLId('MergeRequest', this.mr?.id),
+ issuableId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.mr?.id),
};
},
updateQuery(
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 85df2ea63c8..f6a7ef58c10 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
@@ -30,6 +30,7 @@ export default class MergeRequestStore {
this.machineValue = this.stateMachine.value;
this.mergeDetailsCollapsed = window.innerWidth < 768;
this.mergeError = data.mergeError;
+ this.multipleApprovalRulesAvailable = data.multiple_approval_rules_available || false;
this.id = data.id;
this.setPaths(data);
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 3c73f42b6b1..634b7da3def 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
@@ -34,7 +34,7 @@ export default {
<template>
<li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!">
- <div class="gl-display-inline-flex gl-align-items-center">
+ <div class="gl-display-inline-flex gl-align-items-center gl-relative">
<div
class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6"
>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index 49181bb847d..3a3929fba9b 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -16,7 +16,7 @@ export default {
handleBlobRichViewer(this.$refs.content, this.type);
},
safeHtmlConfig: {
- ADD_TAGS: ['copy-code'],
+ ADD_TAGS: ['gl-emoji', 'copy-code'],
},
};
</script>
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 271cfd210a6..52a5d6e1b86 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -42,11 +42,6 @@ export default {
required: false,
default: true,
},
- iconClasses: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
title() {
@@ -73,7 +68,7 @@ export default {
:href="detailsPath"
@click="$emit('ciStatusBadgeClick')"
>
- <ci-icon :status="status" :css-classes="iconClasses" />
+ <ci-icon :status="status" />
<template v-if="showText">
<span class="gl-ml-2">{{ status.text }}</span>
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js
new file mode 100644
index 00000000000..0fb5a2d5534
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js
@@ -0,0 +1,16 @@
+import { __, s__ } from '~/locale';
+
+export const RESET_LABEL = __('Reset');
+export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.');
+
+// Groups
+export const GROUP_TOGGLE_TEXT = __('Search for a group');
+export const GROUP_HEADER_TEXT = __('Select a group');
+export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.');
+export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.');
+
+// Projects
+export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project');
+export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project');
+export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.');
+export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.');
diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
index d295052e2ce..45c50dce8ce 100644
--- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue
+++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue
@@ -1,28 +1,15 @@
<script>
import { debounce } from 'lodash';
-import { GlFormGroup, GlAlert, GlCollapsibleListbox } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
-import axios from '~/lib/utils/axios_utils';
-import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
-import Api from '~/api';
+import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui';
import { __ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { groupsPath } from './utils';
-import {
- TOGGLE_TEXT,
- RESET_LABEL,
- FETCH_GROUPS_ERROR,
- FETCH_GROUP_ERROR,
- QUERY_TOO_SHORT_MESSAGE,
-} from './constants';
+import { RESET_LABEL, QUERY_TOO_SHORT_MESSAGE } from './constants';
const MINIMUM_QUERY_LENGTH = 3;
-const GROUPS_PER_PAGE = 20;
export default {
components: {
GlFormGroup,
- GlAlert,
GlCollapsibleListbox,
},
props: {
@@ -48,13 +35,20 @@ export default {
required: false,
default: false,
},
- parentGroupID: {
+ headerText: {
type: String,
- required: false,
- default: null,
+ required: true,
},
- groupsFilter: {
+ defaultToggleText: {
type: String,
+ required: true,
+ },
+ fetchItems: {
+ type: Function,
+ required: true,
+ },
+ fetchInitialSelectionText: {
+ type: Function,
required: false,
default: null,
},
@@ -63,10 +57,10 @@ export default {
return {
pristine: true,
searching: false,
- hasMoreGroups: true,
+ hasMoreItems: true,
infiniteScrollLoading: false,
searchString: '',
- groups: [],
+ items: [],
page: 1,
selectedValue: null,
selectedText: null,
@@ -78,14 +72,14 @@ export default {
set(value) {
this.selectedValue = value;
this.selectedText =
- value === null ? null : this.groups.find((group) => group.value === value).full_name;
+ value === null ? null : this.items.find((item) => item.value === value).text;
},
get() {
return this.selectedValue;
},
},
toggleText() {
- return this.selectedText ?? this.$options.i18n.toggleText;
+ return this.selectedText ?? this.defaultToggleText;
},
resetButtonLabel() {
return this.clearable ? RESET_LABEL : '';
@@ -109,90 +103,64 @@ export default {
search: debounce(function debouncedSearch(searchString) {
this.searchString = searchString;
if (this.isSearchQueryTooShort) {
- this.groups = [];
+ this.items = [];
} else {
- this.fetchGroups();
+ this.fetchEntities();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- async fetchGroups(page = 1) {
+ async fetchEntities(page = 1) {
if (page === 1) {
this.searching = true;
- this.groups = [];
- this.hasMoreGroups = true;
+ this.items = [];
+ this.hasMoreItems = true;
} else {
this.infiniteScrollLoading = true;
}
- try {
- const { data, headers } = await axios.get(
- Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)),
- {
- params: {
- search: this.searchString,
- per_page: GROUPS_PER_PAGE,
- page,
- },
- },
- );
- const groups = data.length ? data : data.results || [];
-
- this.groups.push(
- ...groups.map((group) => ({
- ...group,
- value: String(group.id),
- })),
- );
+ const { items, totalPages } = await this.fetchItems(this.searchString, page);
- const { totalPages } = parseIntPagination(normalizeHeaders(headers));
- if (page === totalPages) {
- this.hasMoreGroups = false;
- }
+ this.items.push(...items);
- this.page = page;
- this.searching = false;
- this.infiniteScrollLoading = false;
- } catch (error) {
- this.handleError({ message: FETCH_GROUPS_ERROR, error });
+ if (page === totalPages) {
+ this.hasMoreItems = false;
}
+
+ this.page = page;
+ this.searching = false;
+ this.infiniteScrollLoading = false;
},
async fetchInitialSelection() {
if (!this.initialSelection) {
this.pristine = false;
return;
}
- this.searching = true;
- try {
- const group = await Api.group(this.initialSelection);
- this.selectedValue = this.initialSelection;
- this.selectedText = group.full_name;
- this.pristine = false;
- this.searching = false;
- } catch (error) {
- this.handleError({ message: FETCH_GROUP_ERROR, error });
+
+ if (!this.fetchInitialSelectionText) {
+ throw new Error(
+ '`initialSelection` is provided but lacks `fetchInitialSelectionText` to retrieve the corresponding text',
+ );
}
+
+ this.searching = true;
+ const name = await this.fetchInitialSelectionText(this.initialSelection);
+ this.selectedValue = this.initialSelection;
+ this.selectedText = name;
+ this.pristine = false;
+ this.searching = false;
},
onShown() {
- if (!this.searchString && !this.groups.length) {
- this.fetchGroups();
+ if (!this.searchString && !this.items.length) {
+ this.fetchEntities();
}
},
onReset() {
this.selected = null;
},
onBottomReached() {
- this.fetchGroups(this.page + 1);
- },
- handleError({ message, error }) {
- Sentry.captureException(error);
- this.errorMessage = message;
- },
- dismissError() {
- this.errorMessage = '';
+ this.fetchEntities(this.page + 1);
},
},
i18n: {
- toggleText: TOGGLE_TEXT,
- selectGroup: __('Select a group'),
noResultsText: __('No results found.'),
searchQueryTooShort: QUERY_TOO_SHORT_MESSAGE,
},
@@ -201,20 +169,21 @@ export default {
<template>
<gl-form-group :label="label">
- <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{
- errorMessage
- }}</gl-alert>
+ <slot name="error"></slot>
+ <template v-if="Boolean($scopedSlots.label)" #label>
+ <slot name="label"></slot>
+ </template>
<gl-collapsible-listbox
ref="listbox"
v-model="selected"
- :header-text="$options.i18n.selectGroup"
+ :header-text="headerText"
:reset-button-label="resetButtonLabel"
:toggle-text="toggleText"
:loading="searching && pristine"
:searching="searching"
- :items="groups"
+ :items="items"
:no-results-text="noResultsText"
- :infinite-scroll="hasMoreGroups"
+ :infinite-scroll="hasMoreItems"
:infinite-scroll-loading="infiniteScrollLoading"
searchable
@shown="onShown"
@@ -223,10 +192,7 @@ export default {
@bottom-reached="onBottomReached"
>
<template #list-item="{ item }">
- <div class="gl-font-weight-bold">
- {{ item.full_name }}
- </div>
- <div class="gl-text-gray-300">{{ item.full_path }}</div>
+ <slot name="list-item" :item="item"></slot>
</template>
</gl-collapsible-listbox>
<input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" />
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue
new file mode 100644
index 00000000000..ff137d764ee
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue
@@ -0,0 +1,137 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import axios from '~/lib/utils/axios_utils';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
+import Api, { DEFAULT_PER_PAGE } from '~/api';
+import { groupsPath } from './utils';
+import {
+ GROUP_TOGGLE_TEXT,
+ GROUP_HEADER_TEXT,
+ FETCH_GROUPS_ERROR,
+ FETCH_GROUP_ERROR,
+} from './constants';
+import EntitySelect from './entity_select.vue';
+
+export default {
+ components: {
+ GlAlert,
+ EntitySelect,
+ },
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ inputName: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ initialSelection: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ clearable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ parentGroupID: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ groupsFilter: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ errorMessage: '',
+ };
+ },
+ methods: {
+ async fetchGroups(searchString = '', page = 1) {
+ let groups = [];
+ let totalPages = 0;
+ try {
+ const { data = [], headers } = await axios.get(
+ Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)),
+ {
+ params: {
+ search: searchString,
+ per_page: DEFAULT_PER_PAGE,
+ page,
+ },
+ },
+ );
+ groups = data.map((group) => ({
+ ...group,
+ text: group.full_name,
+ value: String(group.id),
+ }));
+
+ totalPages = parseIntPagination(normalizeHeaders(headers)).totalPages;
+ } catch (error) {
+ this.handleError({ message: FETCH_GROUPS_ERROR, error });
+ }
+ return { items: groups, totalPages };
+ },
+ async fetchGroupName(groupId) {
+ let groupName = '';
+ try {
+ const group = await Api.group(groupId);
+ groupName = group.full_name;
+ } catch (error) {
+ this.handleError({ message: FETCH_GROUP_ERROR, error });
+ }
+ return groupName;
+ },
+ handleError({ message, error }) {
+ Sentry.captureException(error);
+ this.errorMessage = message;
+ },
+ dismissError() {
+ this.errorMessage = '';
+ },
+ },
+ i18n: {
+ toggleText: GROUP_TOGGLE_TEXT,
+ selectGroup: GROUP_HEADER_TEXT,
+ },
+};
+</script>
+
+<template>
+ <entity-select
+ :label="label"
+ :input-name="inputName"
+ :input-id="inputId"
+ :initial-selection="initialSelection"
+ :clearable="clearable"
+ :header-text="$options.i18n.selectGroup"
+ :default-toggle-text="$options.i18n.toggleText"
+ :fetch-items="fetchGroups"
+ :fetch-initial-selection-text="fetchGroupName"
+ >
+ <template #error>
+ <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{
+ errorMessage
+ }}</gl-alert>
+ </template>
+ <template #list-item="{ item }">
+ <div class="gl-font-weight-bold">
+ {{ item.full_name }}
+ </div>
+ <div class="gl-text-gray-300">{{ item.full_path }}</div>
+ </template>
+ </entity-select>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js
index dbfac8a0339..dbfac8a0339 100644
--- a/app/assets/javascripts/vue_shared/components/group_select/init_group_selects.js
+++ b/app/assets/javascripts/vue_shared/components/entity_select/init_group_selects.js
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
new file mode 100644
index 00000000000..1afbeda74c4
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/entity_select/init_project_selects.js
@@ -0,0 +1,48 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import ProjectSelect from './project_select.vue';
+
+const SELECTOR = '.js-vue-project-select';
+
+export const initProjectSelects = () => {
+ if (process.env.NODE_ENV !== 'production' && document.querySelector(SELECTOR) === null) {
+ // eslint-disable-next-line no-console
+ console.warn(`Attempted to initialize ProjectSelect but '${SELECTOR}' not found in the page`);
+ }
+
+ document.querySelectorAll(SELECTOR).forEach((el) => {
+ const {
+ label,
+ inputName,
+ inputId,
+ groupId,
+ userId,
+ orderBy,
+ selected: initialSelection,
+ } = el.dataset;
+ const includeSubgroups = parseBoolean(el.dataset.includeSubgroups);
+ const membership = parseBoolean(el.dataset.membership);
+ const hasHtmlLabel = parseBoolean(el.dataset.hasHtmlLabel);
+
+ return new Vue({
+ el,
+ name: 'ProjectSelectRoot',
+ render(createElement) {
+ return createElement(ProjectSelect, {
+ props: {
+ label,
+ hasHtmlLabel,
+ inputName,
+ inputId,
+ groupId,
+ userId,
+ orderBy,
+ includeSubgroups,
+ membership,
+ initialSelection,
+ },
+ });
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
new file mode 100644
index 00000000000..393991d746e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import Api from '~/api';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import {
+ PROJECT_TOGGLE_TEXT,
+ PROJECT_HEADER_TEXT,
+ FETCH_PROJECTS_ERROR,
+ FETCH_PROJECT_ERROR,
+} from './constants';
+import EntitySelector from './entity_select.vue';
+
+export default {
+ components: {
+ GlAlert,
+ EntitySelector,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ hasHtmlLabel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ inputName: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ userId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ includeSubgroups: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ membership: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ orderBy: {
+ type: String,
+ required: false,
+ default: 'similarity',
+ },
+ initialSelection: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ errorMessage: '',
+ };
+ },
+ methods: {
+ async fetchProjects(searchString = '') {
+ let projects = [];
+ try {
+ const { data = [] } = await (() => {
+ const commonParams = {
+ order_by: this.orderBy,
+ simple: true,
+ };
+
+ if (this.groupId) {
+ return Api.groupProjects(this.groupId, searchString, {
+ ...commonParams,
+ with_shared: true,
+ include_subgroups: this.includeSubgroups,
+ simple: true,
+ });
+ }
+ // Note: the whole userId handling supports a single project selector that is slated for
+ // removal. Once we have deleted app/views/clusters/clusters/_advanced_settings.html.haml,
+ // we should be able to clean this up.
+ if (this.userId) {
+ return Api.userProjects(
+ this.userId,
+ searchString,
+ {
+ with_shared: true,
+ include_subgroups: this.includeSubgroups,
+ },
+ (res) => ({ data: res }),
+ );
+ }
+ return Api.projects(searchString, {
+ ...commonParams,
+ membership: this.membership,
+ });
+ })();
+ projects = data.map((item) => ({
+ text: item.name_with_namespace || item.name,
+ value: String(item.id),
+ }));
+ } catch (error) {
+ this.handleError({ message: FETCH_PROJECTS_ERROR, error });
+ }
+ return { items: projects, totalPages: 1 };
+ },
+ async fetchProjectName(projectId) {
+ let projectName = '';
+ try {
+ const { data: project } = await Api.project(projectId);
+ projectName = project.name_with_namespace;
+ } catch (error) {
+ this.handleError({ message: FETCH_PROJECT_ERROR, error });
+ }
+ return projectName;
+ },
+ handleError({ message, error }) {
+ Sentry.captureException(error);
+ this.errorMessage = message;
+ },
+ dismissError() {
+ this.errorMessage = '';
+ },
+ },
+ i18n: {
+ searchForProject: PROJECT_TOGGLE_TEXT,
+ selectProject: PROJECT_HEADER_TEXT,
+ },
+};
+</script>
+
+<template>
+ <entity-selector
+ :label="label"
+ :input-name="inputName"
+ :input-id="inputId"
+ :initial-selection="initialSelection"
+ :header-text="$options.i18n.selectProject"
+ :default-toggle-text="$options.i18n.searchForProject"
+ :fetch-items="fetchProjects"
+ :fetch-initial-selection-text="fetchProjectName"
+ clearable
+ >
+ <template v-if="hasHtmlLabel" #label>
+ <span v-safe-html="label"></span>
+ </template>
+ <template #error>
+ <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{
+ errorMessage
+ }}</gl-alert>
+ </template>
+ </entity-selector>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/group_select/utils.js b/app/assets/javascripts/vue_shared/components/entity_select/utils.js
index 0a4622269f4..0a4622269f4 100644
--- a/app/assets/javascripts/vue_shared/components/group_select/utils.js
+++ b/app/assets/javascripts/vue_shared/components/entity_select/utils.js
diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue
index adf34f822ed..6a10557c6bc 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/file_icon.vue
@@ -1,7 +1,7 @@
<script>
+import { getIconForFile } from '@gitlab/svgs/src/file_icon_map';
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { FILE_SYMLINK_MODE } from '../constants';
-import getIconForFile from './file_icon/file_icon_map';
/* This is a re-usable vue component for rendering a svg sprite
icon
@@ -88,7 +88,7 @@ export default {
<gl-loading-icon v-if="loading" size="sm" :inline="true" />
<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 }" />
+ <use :href="spriteHref" />
</svg>
<gl-icon
v-else
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
deleted file mode 100644
index 8686d317c8a..00000000000
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ /dev/null
@@ -1,610 +0,0 @@
-const fileExtensionIcons = {
- html: 'html',
- htm: 'html',
- html_vm: 'html',
- asp: 'html',
- jade: 'pug',
- pug: 'pug',
- md: 'markdown',
- markdown: 'markdown',
- mdown: 'markdown',
- mkd: 'markdown',
- mkdn: 'markdown',
- rst: 'markdown',
- blink: 'blink',
- css: 'css',
- scss: 'sass',
- sass: 'sass',
- less: 'less',
- json: 'json',
- yaml: 'yaml',
- yml: 'yaml',
- xml: 'xml',
- plist: 'xml',
- xsd: 'xml',
- dtd: 'xml',
- xsl: 'xml',
- xslt: 'xml',
- resx: 'xml',
- iml: 'xml',
- xquery: 'xml',
- tmLanguage: 'xml',
- manifest: 'xml',
- project: 'xml',
- png: 'image',
- jpeg: 'image',
- jpg: 'image',
- gif: 'image',
- svg: 'image',
- ico: 'image',
- tif: 'image',
- tiff: 'image',
- psd: 'image',
- psb: 'image',
- ami: 'image',
- apx: 'image',
- bmp: 'image',
- bpg: 'image',
- brk: 'image',
- cur: 'image',
- dds: 'image',
- dng: 'image',
- exr: 'image',
- fpx: 'image',
- gbr: 'image',
- img: 'image',
- jbig2: 'image',
- jb2: 'image',
- jng: 'image',
- jxr: 'image',
- pbm: 'image',
- pgf: 'image',
- pic: 'image',
- raw: 'image',
- webp: 'image',
- js: 'javascript',
- ejs: 'javascript',
- esx: 'javascript',
- jsx: 'react',
- tsx: 'react',
- ini: 'settings',
- dlc: 'settings',
- dll: 'settings',
- config: 'settings',
- conf: 'settings',
- properties: 'settings',
- prop: 'settings',
- settings: 'settings',
- option: 'settings',
- props: 'settings',
- toml: 'settings',
- prefs: 'settings',
- ts: 'typescript',
- marko: 'markojs',
- pdf: 'pdf',
- xlsx: 'table',
- xls: 'table',
- ods: 'table',
- csv: 'table',
- tsv: 'table',
- vscodeignore: 'vscode',
- vsixmanifest: 'vscode',
- vsix: 'vscode',
- suo: 'visualstudio',
- sln: 'visualstudio',
- csproj: 'visualstudio',
- vb: 'visualstudio',
- pdb: 'database',
- sql: 'database',
- pks: 'database',
- pkb: 'database',
- accdb: 'database',
- mdb: 'database',
- sqlite: 'database',
- cs: 'csharp',
- zip: 'zip',
- tar: 'zip',
- gz: 'zip',
- xz: 'zip',
- bzip2: 'zip',
- gzip: 'zip',
- rar: 'zip',
- tgz: 'zip',
- exe: 'exe',
- msi: 'exe',
- java: 'java',
- jar: 'java',
- jsp: 'java',
- c: 'c',
- m: 'c',
- h: 'h',
- cc: 'cpp',
- cpp: 'cpp',
- mm: 'cpp',
- cxx: 'cpp',
- hpp: 'hpp',
- go: 'go',
- py: 'python',
- url: 'url',
- sh: 'console',
- ksh: 'console',
- csh: 'console',
- tcsh: 'console',
- zsh: 'console',
- bash: 'console',
- bat: 'console',
- cmd: 'console',
- ps1: 'powershell',
- psm1: 'powershell',
- psd1: 'powershell',
- ps1xml: 'powershell',
- psc1: 'powershell',
- pssc: 'powershell',
- gradle: 'gradle',
- doc: 'word',
- docx: 'word',
- odt: 'word',
- rtf: 'word',
- cer: 'certificate',
- cert: 'certificate',
- crt: 'certificate',
- pub: 'key',
- key: 'key',
- pem: 'key',
- asc: 'key',
- gpg: 'key',
- woff: 'font',
- woff2: 'font',
- ttf: 'font',
- eot: 'font',
- suit: 'font',
- otf: 'font',
- bmap: 'font',
- fnt: 'font',
- odttf: 'font',
- ttc: 'font',
- font: 'font',
- fonts: 'font',
- sui: 'font',
- ntf: 'font',
- mrf: 'font',
- lib: 'lib',
- bib: 'lib',
- rb: 'ruby',
- erb: 'ruby',
- fs: 'fsharp',
- fsx: 'fsharp',
- fsi: 'fsharp',
- fsproj: 'fsharp',
- swift: 'swift',
- ino: 'arduino',
- dockerignore: 'docker',
- dockerfile: 'docker',
- tex: 'tex',
- cls: 'tex',
- sty: 'tex',
- pptx: 'powerpoint',
- ppt: 'powerpoint',
- pptm: 'powerpoint',
- potx: 'powerpoint',
- pot: 'powerpoint',
- potm: 'powerpoint',
- ppsx: 'powerpoint',
- ppsm: 'powerpoint',
- pps: 'powerpoint',
- ppam: 'powerpoint',
- ppa: 'powerpoint',
- odp: 'powerpoint',
- webm: 'movie',
- mkv: 'movie',
- flv: 'movie',
- vob: 'movie',
- ogv: 'movie',
- ogg: 'music',
- gifv: 'movie',
- avi: 'movie',
- mov: 'movie',
- qt: 'movie',
- wmv: 'movie',
- yuv: 'movie',
- rm: 'movie',
- rmvb: 'movie',
- mp4: 'movie',
- m4v: 'movie',
- mpg: 'movie',
- mp2: 'movie',
- mpeg: 'movie',
- mpe: 'movie',
- mpv: 'movie',
- m2v: 'movie',
- vdi: 'virtual',
- vbox: 'virtual',
- ics: 'email',
- mp3: 'music',
- flac: 'music',
- m4a: 'music',
- wma: 'music',
- aiff: 'music',
- coffee: 'coffee',
- txt: 'document',
- graphql: 'graphql',
- rs: 'rust',
- raml: 'raml',
- xaml: 'xaml',
- hs: 'haskell',
- kt: 'kotlin',
- kts: 'kotlin',
- patch: 'git',
- lua: 'lua',
- clj: 'clojure',
- cljs: 'clojure',
- groovy: 'groovy',
- r: 'r',
- rmd: 'r',
- dart: 'dart',
- as: 'actionscript',
- mxml: 'mxml',
- ahk: 'autohotkey',
- swf: 'flash',
- swc: 'swc',
- cmake: 'cmake',
- asm: 'assembly',
- a51: 'assembly',
- inc: 'assembly',
- nasm: 'assembly',
- s: 'assembly',
- ms: 'assembly',
- agc: 'assembly',
- ags: 'assembly',
- aea: 'assembly',
- argus: 'assembly',
- mitigus: 'assembly',
- binsource: 'assembly',
- vue: 'vue',
- ml: 'ocaml',
- mli: 'ocaml',
- cmx: 'ocaml',
- lock: 'lock',
- hbs: 'handlebars',
- mustache: 'handlebars',
- pl: 'perl',
- pm: 'perl',
- hx: 'haxe',
- pp: 'puppet',
- ex: 'elixir',
- exs: 'elixir',
- ls: 'livescript',
- erl: 'erlang',
- twig: 'twig',
- jl: 'julia',
- elm: 'elm',
- pure: 'purescript',
- tpl: 'smarty',
- styl: 'stylus',
- re: 'reason',
- rei: 'reason',
- cmj: 'bucklescript',
- merlin: 'merlin',
- v: 'verilog',
- vhd: 'verilog',
- sv: 'verilog',
- svh: 'verilog',
- nb: 'mathematica',
- wl: 'wolframlanguage',
- wls: 'wolframlanguage',
- njk: 'nunjucks',
- nunjucks: 'nunjucks',
- robot: 'robot',
- sol: 'solidity',
- au3: 'autoit',
- haml: 'haml',
- yang: 'yang',
- tf: 'terraform',
- tfvars: 'terraform',
- tfstate: 'terraform',
- applescript: 'applescript',
- cake: 'cake',
- feature: 'cucumber',
- nim: 'nim',
- nimble: 'nim',
- apib: 'apiblueprint',
- apiblueprint: 'apiblueprint',
- tag: 'riot',
- vfl: 'vfl',
- kl: 'kl',
- pcss: 'postcss',
- sss: 'postcss',
- todo: 'todo',
- cfml: 'coldfusion',
- cfc: 'coldfusion',
- lucee: 'coldfusion',
- cabal: 'cabal',
- nix: 'nix',
- slim: 'slim',
- http: 'http',
- rest: 'http',
- rql: 'restql',
- restql: 'restql',
- kv: 'kivy',
- graphcool: 'graphcool',
- sbt: 'sbt',
- cr: 'crystal',
- cu: 'cuda',
- cuh: 'cuda',
- log: 'log',
-};
-
-const twoFileExtensionIcons = {
- 'gradle.kts': 'gradle',
- 'md.rendered': 'markdown',
- 'markdown.rendered': 'markdown',
- 'mdown.rendered': 'markdown',
- 'mkd.rendered': 'markdown',
- 'mkdn.rendered': 'markdown',
- 'YAML-tmLanguage': 'yaml',
- 'sln.dotsettings': 'settings',
- 'sln.dotsettings.user': 'settings',
- 'd.ts': 'typescript-def',
- 'code-workplace': 'vscode',
- '7z': 'zip',
- 'c++': 'cpp',
- 'vbox-prev': 'virtual',
- 'js.map': 'javascript-map',
- 'css.map': 'css-map',
- 'spec.ts': 'test-ts',
- 'test.ts': 'test-ts',
- 'ts.snap': 'test-ts',
- 'spec.tsx': 'test-jsx',
- 'test.tsx': 'test-jsx',
- 'tsx.snap': 'test-jsx',
- 'spec.jsx': 'test-jsx',
- 'test.jsx': 'test-jsx',
- 'jsx.snap': 'test-jsx',
- 'spec.js': 'test-js',
- 'test.js': 'test-js',
- 'js.snap': 'test-js',
- 'routing.ts': 'angular-routing',
- 'routing.js': 'angular-routing',
- 'module.ts': 'angular',
- 'module.js': 'angular',
- 'ng-template': 'angular',
- 'component.ts': 'angular-component',
- 'component.js': 'angular-component',
- 'guard.ts': 'angular-guard',
- 'guard.js': 'angular-guard',
- 'service.ts': 'angular-service',
- 'service.js': 'angular-service',
- 'pipe.ts': 'angular-pipe',
- 'pipe.js': 'angular-pipe',
- 'filter.js': 'angular-pipe',
- 'directive.ts': 'angular-directive',
- 'directive.js': 'angular-directive',
- 'resolver.ts': 'angular-resolver',
- 'resolver.js': 'angular-resolver',
- 'tf.json': 'terraform',
- 'blade.php': 'laravel',
- 'inky.php': 'laravel',
- 'reducer.ts': 'ngrx-reducer',
- 'rootReducer.ts': 'ngrx-reducer',
- 'state.ts': 'ngrx-state',
- 'actions.ts': 'ngrx-actions',
- 'effects.ts': 'ngrx-effects',
- 'drone.yml': 'drone',
-};
-
-const fileNameIcons = {
- '.jscsrc': 'json',
- '.jshintrc': 'json',
- 'tsconfig.json': 'json',
- 'tslint.json': 'json',
- 'composer.lock': 'json',
- '.jsbeautifyrc': 'json',
- '.esformatter': 'json',
- 'cdp.pid': 'json',
- '.htaccess': 'xml',
- '.jshintignore': 'settings',
- '.buildignore': 'settings',
- makefile: 'settings',
- '.mrconfig': 'settings',
- '.yardopts': 'settings',
- 'gradle.properties': 'gradle',
- gradlew: 'gradle',
- 'gradle-wrapper.properties': 'gradle',
- COPYING: 'certificate',
- 'COPYING.LESSER': 'certificate',
- LICENSE: 'certificate',
- LICENCE: 'certificate',
- 'LICENSE.md': 'certificate',
- 'LICENCE.md': 'certificate',
- 'LICENSE.txt': 'certificate',
- 'LICENCE.txt': 'certificate',
- '.gitlab-license': 'certificate',
- dockerfile: 'docker',
- 'docker-compose.yml': 'docker',
- '.mailmap': 'email',
- '.gitignore': 'git',
- '.gitconfig': 'git',
- '.gitattributes': 'git',
- '.gitmodules': 'git',
- '.gitkeep': 'git',
- 'git-history': 'git',
- '.Rhistory': 'r',
- 'cmakelists.txt': 'cmake',
- 'cmakecache.txt': 'cmake',
- 'angular-cli.json': 'angular',
- '.angular-cli.json': 'angular',
- '.vfl': 'vfl',
- '.kl': 'kl',
- 'postcss.config.js': 'postcss',
- '.postcssrc.js': 'postcss',
- 'project.graphcool': 'graphcool',
- 'webpack.js': 'webpack',
- 'webpack.ts': 'webpack',
- 'webpack.base.js': 'webpack',
- 'webpack.base.ts': 'webpack',
- 'webpack.config.js': 'webpack',
- 'webpack.config.ts': 'webpack',
- 'webpack.common.js': 'webpack',
- 'webpack.common.ts': 'webpack',
- 'webpack.config.common.js': 'webpack',
- 'webpack.config.common.ts': 'webpack',
- 'webpack.config.common.babel.js': 'webpack',
- 'webpack.config.common.babel.ts': 'webpack',
- 'webpack.dev.js': 'webpack',
- 'webpack.dev.ts': 'webpack',
- 'webpack.config.dev.js': 'webpack',
- 'webpack.config.dev.ts': 'webpack',
- 'webpack.config.dev.babel.js': 'webpack',
- 'webpack.config.dev.babel.ts': 'webpack',
- 'webpack.prod.js': 'webpack',
- 'webpack.prod.ts': 'webpack',
- 'webpack.server.js': 'webpack',
- 'webpack.server.ts': 'webpack',
- 'webpack.client.js': 'webpack',
- 'webpack.client.ts': 'webpack',
- 'webpack.config.server.js': 'webpack',
- 'webpack.config.server.ts': 'webpack',
- 'webpack.config.client.js': 'webpack',
- 'webpack.config.client.ts': 'webpack',
- 'webpack.config.production.babel.js': 'webpack',
- 'webpack.config.production.babel.ts': 'webpack',
- 'webpack.config.prod.babel.js': 'webpack',
- 'webpack.config.prod.babel.ts': 'webpack',
- 'webpack.config.prod.js': 'webpack',
- 'webpack.config.prod.ts': 'webpack',
- 'webpack.config.production.js': 'webpack',
- 'webpack.config.production.ts': 'webpack',
- 'webpack.config.staging.js': 'webpack',
- 'webpack.config.staging.ts': 'webpack',
- 'webpack.config.babel.js': 'webpack',
- 'webpack.config.babel.ts': 'webpack',
- 'webpack.config.base.babel.js': 'webpack',
- 'webpack.config.base.babel.ts': 'webpack',
- 'webpack.config.base.js': 'webpack',
- 'webpack.config.base.ts': 'webpack',
- 'webpack.config.staging.babel.js': 'webpack',
- 'webpack.config.staging.babel.ts': 'webpack',
- 'webpack.config.coffee': 'webpack',
- 'webpack.config.test.js': 'webpack',
- 'webpack.config.test.ts': 'webpack',
- 'webpack.config.vendor.js': 'webpack',
- 'webpack.config.vendor.ts': 'webpack',
- 'webpack.config.vendor.production.js': 'webpack',
- 'webpack.config.vendor.production.ts': 'webpack',
- 'webpack.test.js': 'webpack',
- 'webpack.test.ts': 'webpack',
- 'webpack.dist.js': 'webpack',
- 'webpack.dist.ts': 'webpack',
- 'webpackfile.js': 'webpack',
- 'webpackfile.ts': 'webpack',
- 'ionic.config.json': 'ionic',
- '.io-config.json': 'ionic',
- 'gulpfile.js': 'gulp',
- 'gulpfile.ts': 'gulp',
- 'gulpfile.babel.js': 'gulp',
- 'package.json': 'nodejs',
- 'package-lock.json': 'nodejs',
- '.nvmrc': 'nodejs',
- '.npmignore': 'npm',
- '.npmrc': 'npm',
- '.yarnrc': 'yarn',
- '.yarnrc.yml': 'yarn',
- 'yarn.lock': 'yarn',
- '.yarnclean': 'yarn',
- '.yarn-integrity': 'yarn',
- 'yarn-error.log': 'yarn',
- 'androidmanifest.xml': 'android',
- '.env': 'tune',
- '.env.example': 'tune',
- '.babelrc': 'babel',
- 'contributing.md': 'contributing',
- 'contributing.md.rendered': 'contributing',
- 'readme.md': 'readme',
- 'readme.md.rendered': 'readme',
- changelog: 'changelog',
- 'changelog.md': 'changelog',
- 'changelog.md.rendered': 'changelog',
- CREDITS: 'credits',
- 'credits.txt': 'credits',
- 'credits.md': 'credits',
- 'credits.md.rendered': 'credits',
- '.flowconfig': 'flow',
- 'favicon.png': 'favicon',
- 'karma.conf.js': 'karma',
- 'karma.conf.ts': 'karma',
- 'karma.conf.coffee': 'karma',
- 'karma.config.js': 'karma',
- 'karma.config.ts': 'karma',
- 'karma-main.js': 'karma',
- 'karma-main.ts': 'karma',
- '.bithoundrc': 'bithound',
- 'appveyor.yml': 'appveyor',
- '.travis.yml': 'travis',
- 'protractor.conf.js': 'protractor',
- 'protractor.conf.ts': 'protractor',
- 'protractor.conf.coffee': 'protractor',
- 'protractor.config.js': 'protractor',
- 'protractor.config.ts': 'protractor',
- 'fuse.js': 'fusebox',
- procfile: 'heroku',
- '.editorconfig': 'editorconfig',
- '.gitlab-ci.yml': 'gitlab',
- '.bowerrc': 'bower',
- 'bower.json': 'bower',
- '.eslintrc.js': 'eslint',
- '.eslintrc.yaml': 'eslint',
- '.eslintrc.yml': 'eslint',
- '.eslintrc.json': 'eslint',
- '.eslintrc': 'eslint',
- '.eslintignore': 'eslint',
- 'code_of_conduct.md': 'conduct',
- 'code_of_conduct.md.rendered': 'conduct',
- '.watchmanconfig': 'watchman',
- 'aurelia.json': 'aurelia',
- 'mocha.opts': 'mocha',
- jenkinsfile: 'jenkins',
- 'firebase.json': 'firebase',
- '.firebaserc': 'firebase',
- Rakefile: 'ruby',
- 'rollup.config.js': 'rollup',
- 'rollup.config.ts': 'rollup',
- 'rollup-config.js': 'rollup',
- 'rollup-config.ts': 'rollup',
- 'rollup.config.prod.js': 'rollup',
- 'rollup.config.prod.ts': 'rollup',
- 'rollup.config.dev.js': 'rollup',
- 'rollup.config.dev.ts': 'rollup',
- 'rollup.config.prod.vendor.js': 'rollup',
- 'rollup.config.prod.vendor.ts': 'rollup',
- '.hhconfig': 'hack',
- '.stylelintrc': 'stylelint',
- 'stylelint.config.js': 'stylelint',
- '.stylelintrc.json': 'stylelint',
- '.stylelintrc.yaml': 'stylelint',
- '.stylelintrc.yml': 'stylelint',
- '.stylelintrc.js': 'stylelint',
- '.stylelintignore': 'stylelint',
- '.codeclimate.yml': 'code-climate',
- '.prettierrc': 'prettier',
- 'prettier.config.js': 'prettier',
- '.prettierrc.js': 'prettier',
- '.prettierrc.json': 'prettier',
- '.prettierrc.yaml': 'prettier',
- '.prettierrc.yml': 'prettier',
- '.prettierignore': 'prettier',
- 'nodemon.json': 'nodemon',
- '.sonarrc': 'sonar',
- browserslist: 'browserlist',
- '.browserslistrc': 'browserlist',
- '.snyk': 'snyk',
- '.drone.yml': 'drone',
-};
-
-export default function getIconForFile(name) {
- return (
- fileNameIcons[name] ||
- twoFileExtensionIcons[name ? name.split('.').slice(-2).join('.') : ''] ||
- fileExtensionIcons[name ? name.split('.').pop().toLowerCase() : ''] ||
- ''
- );
-}
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 8a3a174f414..dfeb12d5cf5 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -43,11 +43,6 @@ export default {
isBlob() {
return this.file.type === 'blob';
},
- levelIndentation() {
- return {
- marginLeft: this.level ? `${this.level * 8}px` : null,
- };
- },
fileClass() {
return {
'file-open': this.isBlob && this.file.opened,
@@ -144,7 +139,6 @@ export default {
>
<span
ref="textOutput"
- :style="levelIndentation"
class="file-row-name"
:title="file.name"
data-qa-selector="file_name_content"
@@ -198,6 +192,7 @@ export default {
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
+ margin-left: calc(var(--level) * 16px);
}
.file-row-name .file-row-icon {
diff --git a/app/assets/javascripts/vue_shared/components/file_tree.vue b/app/assets/javascripts/vue_shared/components/file_tree.vue
index e7817b8f910..2e0cdbb12f9 100644
--- a/app/assets/javascripts/vue_shared/components/file_tree.vue
+++ b/app/assets/javascripts/vue_shared/components/file_tree.vue
@@ -20,11 +20,16 @@ export default {
return this.file.isHeader ? 0 : this.level + 1;
},
},
+ methods: {
+ hasChildren(childFile) {
+ return childFile.tree?.length;
+ },
+ },
};
</script>
<template>
- <div>
+ <div :style="{ '--level': level }">
<component
:is="fileRowComponent"
:level="level"
@@ -39,6 +44,8 @@ export default {
:file-row-component="fileRowComponent"
:level="childFilesLevel"
:file="childFile"
+ :class="{ 'tree-list-parent': hasChildren(childFile) }"
+ class="gl-relative"
v-bind="$attrs"
v-on="$listeners"
/>
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 993b4c11c0e..5b98af8c732 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
@@ -50,6 +50,7 @@ export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee');
export const TOKEN_TITLE_AUTHOR = __('Author');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
export const TOKEN_TITLE_CONTACT = s__('Crm|Contact');
+export const TOKEN_TITLE_GROUP = __('Group');
export const TOKEN_TITLE_LABEL = __('Label');
export const TOKEN_TITLE_MILESTONE = __('Milestone');
export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
@@ -67,6 +68,7 @@ export const TOKEN_TYPE_ASSIGNEE = 'assignee';
export const TOKEN_TYPE_AUTHOR = 'author';
export const TOKEN_TYPE_CONFIDENTIAL = 'confidential';
export const TOKEN_TYPE_CONTACT = 'contact';
+export const TOKEN_TYPE_GROUP = 'group';
export const TOKEN_TYPE_EPIC = 'epic';
// As health status gets reused between issue lists and boards
// this is in the shared constants. Until we have not decoupled the EE filtered search bar
@@ -85,5 +87,4 @@ export const TOKEN_TYPE_STATUS = 'status';
export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch';
export const TOKEN_TYPE_TYPE = 'type';
export const TOKEN_TYPE_WEIGHT = 'weight';
-
export const TOKEN_TYPE_SEARCH_WITHIN = 'in';
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
index e0fa06c159e..c8aeac75645 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue
@@ -2,6 +2,7 @@
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import { ITEM_TYPE } from '~/groups/constants';
+import { TYPENAME_CRM_CONTACT } from '~/graphql_shared/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/flash';
import { isPositiveInteger } from '~/lib/utils/number_utils';
@@ -93,7 +94,7 @@ export default {
return `${getIdFromGraphQLId(contact.id)}`;
},
formatContactGraphQLId(id) {
- return convertToGraphQLId('CustomerRelations::Contact', id);
+ return convertToGraphQLId(TYPENAME_CRM_CONTACT, id);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
index 3f030c8698c..ff0571031b5 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue
@@ -2,6 +2,7 @@
import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import { ITEM_TYPE } from '~/groups/constants';
+import { TYPENAME_CRM_ORGANIZATION } from '~/graphql_shared/constants';
import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/flash';
import { isPositiveInteger } from '~/lib/utils/number_utils';
@@ -90,7 +91,7 @@ export default {
return `${getIdFromGraphQLId(organization.id)}`;
},
formatOrganizationGraphQLId(id) {
- return convertToGraphQLId('CustomerRelations::Organization', id);
+ return convertToGraphQLId(TYPENAME_CRM_ORGANIZATION, id);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 71c50ef292a..9449e071a0d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -79,6 +79,9 @@ export default {
// labels.json and /groups/:id/labels & /projects/:id/labels
// return response differently.
this.labels = Array.isArray(res) ? res : res.data;
+ if (this.config.fetchLatestLabels) {
+ this.fetchLatestLabels(searchTerm);
+ }
})
.catch(() =>
createAlert({
@@ -89,6 +92,21 @@ export default {
this.loading = false;
});
},
+ fetchLatestLabels(searchTerm) {
+ this.config
+ .fetchLatestLabels(searchTerm)
+ .then((res) => {
+ // We'd want to avoid doing this check but
+ // labels.json and /groups/:id/labels & /projects/:id/labels
+ // return response differently.
+ this.labels = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() =>
+ createAlert({
+ message: __('There was a problem fetching latest labels.'),
+ }),
+ );
+ },
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/group_select/constants.js b/app/assets/javascripts/vue_shared/components/group_select/constants.js
deleted file mode 100644
index 06537d682fe..00000000000
--- a/app/assets/javascripts/vue_shared/components/group_select/constants.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { __ } from '~/locale';
-
-export const TOGGLE_TEXT = __('Search for a group');
-export const RESET_LABEL = __('Reset');
-export const FETCH_GROUPS_ERROR = __('Unable to fetch groups. Reload the page to try again.');
-export const FETCH_GROUP_ERROR = __('Unable to fetch group. Reload the page to try again.');
-export const QUERY_TOO_SHORT_MESSAGE = __('Enter at least three characters to search.');
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 8e459cc21ac..28baabbdb81 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -4,7 +4,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { glEmojiTag } from '~/emoji';
import { __, sprintf } from '~/locale';
-import CiIconBadge from './ci_badge_link.vue';
+import CiBadgeLink from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
/**
@@ -16,7 +16,7 @@ import TimeagoTooltip from './time_ago_tooltip.vue';
*/
export default {
components: {
- CiIconBadge,
+ CiBadgeLink,
TimeagoTooltip,
GlButton,
GlAvatarLink,
@@ -120,7 +120,7 @@ export default {
data-testid="ci-header-content"
>
<section class="header-main-content gl-mr-3">
- <ci-icon-badge :status="status" />
+ <ci-badge-link class="gl-mr-3" :status="status" />
<strong data-testid="ci-header-item-text">{{ item }}</strong>
diff --git a/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue
new file mode 100644
index 00000000000..b704cec2475
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/incubation/incubation_alert.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'IncubationAlert',
+ components: { GlAlert, GlLink },
+ props: {
+ featureName: {
+ type: String,
+ required: true,
+ },
+ linkToFeedbackIssue: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isAlertDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowAlert() {
+ return !this.isAlertDismissed;
+ },
+ titleLabel() {
+ return sprintf(this.$options.i18n.titleLabel, { featureName: this.featureName });
+ },
+ },
+ methods: {
+ dismissAlert() {
+ this.isAlertDismissed = true;
+ },
+ },
+ i18n: {
+ titleLabel: s__('Incubation|%{featureName} is in incubating phase'),
+ contentLabel: s__(
+ 'Incubation|GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited.',
+ ),
+ learnMoreLabel: s__('Incubation|Learn more about incubating features'),
+ feedbackLabel: s__('Incubation|Give feedback on this feature'),
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ v-if="shouldShowAlert"
+ :title="titleLabel"
+ variant="warning"
+ :primary-button-text="$options.i18n.feedbackLabel"
+ :primary-button-link="linkToFeedbackIssue"
+ @dismiss="dismissAlert"
+ >
+ {{ $options.i18n.contentLabel }}
+ <gl-link href="https://about.gitlab.com/handbook/engineering/incubation/" target="_blank">{{
+ $options.i18n.learnMoreLabel
+ }}</gl-link>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue
new file mode 100644
index 00000000000..b5afe92316a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue
@@ -0,0 +1,62 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
+export default {
+ name: 'KeysetPagination',
+ components: {
+ GlKeysetPagination,
+ },
+ props: {
+ startCursor: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ endCursor: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasNextPage: {
+ type: Boolean,
+ required: true,
+ },
+ hasPreviousPage: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ previousPageLink() {
+ return setUrlParams({ cursor: this.startCursor });
+ },
+ nextPageLink() {
+ return setUrlParams({ cursor: this.endCursor });
+ },
+ isPaginationVisible() {
+ return this.hasPreviousPage || this.hasNextPage;
+ },
+ },
+ i18n: {
+ previousPageButtonLabel: __('Prev'),
+ nextPageButtonLabel: __('Next'),
+ },
+};
+</script>
+
+<template>
+ <div v-if="isPaginationVisible" class="gl--flex-center">
+ <gl-keyset-pagination
+ :start-cursor="startCursor"
+ :end-cursor="endCursor"
+ :has-previous-page="hasPreviousPage"
+ :has-next-page="hasNextPage"
+ :prev-text="$options.i18n.previousPageButtonLabel"
+ :next-text="$options.i18n.nextPageButtonLabel"
+ :prev-button-link="previousPageLink"
+ :next-button-link="nextPageLink"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
index d80c1ff8b0c..9a88ab44f3d 100644
--- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
+++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/constants.js
@@ -1,9 +1,10 @@
import { issuableTypes } from '~/boards/constants';
+import { TYPE_ISSUE } from '~/issues/constants';
import blockingIssuesQuery from './graphql/blocking_issues.query.graphql';
import blockingEpicsQuery from './graphql/blocking_epics.query.graphql';
export const blockingIssuablesQueries = {
- [issuableTypes.issue]: {
+ [TYPE_ISSUE]: {
query: blockingIssuesQuery,
},
[issuableTypes.epic]: {
diff --git a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
index 253aca8837d..f5b4870d59f 100644
--- a/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/issuable_blocked_icon/issuable_blocked_icon.vue
@@ -1,8 +1,9 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants';
+import { TYPENAME_ISSUE, TYPENAME_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/issues/constants';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
import { blockingIssuablesQueries } from './constants';
@@ -10,16 +11,16 @@ import { blockingIssuablesQueries } from './constants';
export default {
i18n: {
issuableType: {
- [issuableTypes.issue]: __('issue'),
+ [TYPE_ISSUE]: __('issue'),
[issuableTypes.epic]: __('epic'),
},
},
graphQLIdType: {
- [issuableTypes.issue]: TYPE_ISSUE,
- [issuableTypes.epic]: TYPE_EPIC,
+ [TYPE_ISSUE]: TYPENAME_ISSUE,
+ [issuableTypes.epic]: TYPENAME_EPIC,
},
referenceFormatter: {
- [issuableTypes.issue]: (r) => r.split('/')[1],
+ [TYPE_ISSUE]: (r) => r.split('/')[1],
},
defaultDisplayLimit: 3,
textTruncateWidth: 80,
@@ -42,7 +43,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [issuableTypes.issue, issuableTypes.epic].includes(value);
+ return [TYPE_ISSUE, issuableTypes.epic].includes(value);
},
},
},
@@ -119,7 +120,7 @@ export default {
);
},
blockIcon() {
- return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked';
+ return this.issuableType === TYPE_ISSUE ? 'issue-block' : 'entity-blocked';
},
glIconId() {
return `blocked-icon-${this.uniqueId}`;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 7b76fc3fc6d..6f4cddbdfa2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -82,6 +82,11 @@ export default {
required: false,
default: true,
},
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
line: {
type: Object,
required: false,
@@ -257,6 +262,7 @@ export default {
contacts: this.enableAutocomplete,
},
true,
+ this.autocompleteDataSources,
);
},
beforeDestroy() {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index c53118b9f62..7e6b0e4a63b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -41,33 +41,25 @@ export default {
required: false,
default: true,
},
- formFieldId: {
- type: String,
- required: true,
- },
- formFieldName: {
- type: String,
- required: true,
- },
enablePreview: {
type: Boolean,
required: false,
default: true,
},
+ autocompleteDataSources: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
enableAutocomplete: {
type: Boolean,
required: false,
default: true,
},
- formFieldPlaceholder: {
- type: String,
- required: false,
- default: '',
- },
- formFieldAriaLabel: {
- type: String,
- required: false,
- default: '',
+ formFieldProps: {
+ type: Object,
+ required: true,
+ validator: (prop) => prop.id && prop.name,
},
autofocus: {
type: Boolean,
@@ -152,6 +144,7 @@ export default {
:textarea-value="value"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
+ :autocomplete-data-sources="autocompleteDataSources"
:uploads-path="uploadsPath"
:enable-preview="enablePreview"
show-content-editor-switcher
@@ -160,16 +153,13 @@ export default {
>
<template #textarea>
<textarea
- :id="formFieldId"
+ v-bind="formFieldProps"
ref="textarea"
:value="value"
- :name="formFieldName"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
:data-supports-quick-actions="supportsQuickActions"
data-qa-selector="markdown_editor_form_field"
- :aria-label="formFieldAriaLabel"
- :placeholder="formFieldPlaceholder"
@input="updateMarkdownFromMarkdownField"
@keydown="$emit('keydown', $event)"
>
@@ -189,9 +179,8 @@ export default {
@enableMarkdownEditor="onEditingModeChange('markdownField')"
/>
<input
- :id="formFieldId"
+ v-bind="formFieldProps"
:value="value"
- :name="formFieldName"
data-qa-selector="markdown_editor_form_field"
type="hidden"
/>
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js
new file mode 100644
index 00000000000..e5dca170965
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/constants.js
@@ -0,0 +1,26 @@
+import { __ } from '~/locale';
+
+export const RESOURCE_TYPE_ISSUE = 'issue';
+export const RESOURCE_TYPE_MERGE_REQUEST = 'merge-request';
+export const RESOURCE_TYPE_MILESTONE = 'milestone';
+
+export const RESOURCE_TYPES = [
+ RESOURCE_TYPE_ISSUE,
+ RESOURCE_TYPE_MERGE_REQUEST,
+ RESOURCE_TYPE_MILESTONE,
+];
+
+export const RESOURCE_OPTIONS = {
+ [RESOURCE_TYPE_ISSUE]: {
+ path: 'issues/new',
+ label: __('issue'),
+ },
+ [RESOURCE_TYPE_MERGE_REQUEST]: {
+ path: 'merge_requests/new',
+ label: __('merge request'),
+ },
+ [RESOURCE_TYPE_MILESTONE]: {
+ path: 'milestones/new',
+ label: __('milestone'),
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql
new file mode 100644
index 00000000000..578914dbbaf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_group_projects_with_merge_requests_enabled.query.graphql
@@ -0,0 +1,18 @@
+query searchUserGroupProjectsWithMergeRequestsEnabled($fullPath: ID!, $search: String) {
+ group(fullPath: $fullPath) {
+ id
+ projects(
+ search: $search
+ withMergeRequestsEnabled: true
+ includeSubgroups: true
+ sort: ACTIVITY_DESC
+ ) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql
new file mode 100644
index 00000000000..8fe92cf7c6c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_groups_and_projects.query.graphql
@@ -0,0 +1,21 @@
+query searchUserGroupsAndProjects($username: String!, $search: String) {
+ projects(sort: "latest_activity_desc", membership: true) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ webUrl
+ }
+ }
+
+ user(username: $username) {
+ id
+ groups(search: $search) {
+ nodes {
+ id
+ name
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql
new file mode 100644
index 00000000000..a630c885d28
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_issues_enabled.query.graphql
@@ -0,0 +1,15 @@
+query searchUserProjectsWithIssuesEnabled($search: String) {
+ projects(
+ search: $search
+ membership: true
+ withIssuesEnabled: true
+ sort: "latest_activity_desc"
+ ) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql
new file mode 100644
index 00000000000..44ebf755728
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/graphql/search_user_projects_with_merge_requests_enabled.query.graphql
@@ -0,0 +1,15 @@
+query searchUserProjectsWithMergeRequestsEnabled($search: String) {
+ projects(
+ search: $search
+ membership: true
+ withMergeRequestsEnabled: true
+ sort: "latest_activity_desc"
+ ) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js
new file mode 100644
index 00000000000..f3905dabedd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/init_new_resource_dropdown.js
@@ -0,0 +1,46 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import NewResourceDropdown from './new_resource_dropdown.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initNewResourceDropdown = (props = {}) => {
+ const el = document.querySelector('.js-new-resource-dropdown');
+
+ if (!el) {
+ return false;
+ }
+
+ const { groupId, fullPath, username } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createElement) {
+ return createElement(NewResourceDropdown, {
+ props: {
+ withLocalStorage: true,
+ groupId,
+ queryVariables: {
+ ...(fullPath
+ ? {
+ fullPath,
+ }
+ : {}),
+ ...(username
+ ? {
+ username,
+ }
+ : {}),
+ },
+ ...props,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
new file mode 100644
index 00000000000..b079181bd10
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/new_resource_dropdown/new_resource_dropdown.vue
@@ -0,0 +1,208 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import AccessorUtilities from '~/lib/utils/accessor';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import searchUserProjectsWithIssuesEnabled from './graphql/search_user_projects_with_issues_enabled.query.graphql';
+import { RESOURCE_TYPE_ISSUE, RESOURCE_TYPES, RESOURCE_OPTIONS } from './constants';
+
+export default {
+ i18n: {
+ noMatchesFound: __('No matches found'),
+ toggleButtonLabel: __('Toggle project select'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ LocalStorageSync,
+ },
+ props: {
+ resourceType: {
+ type: String,
+ required: false,
+ default: RESOURCE_TYPE_ISSUE,
+ validator: (value) => RESOURCE_TYPES.includes(value),
+ },
+ query: {
+ type: Object,
+ required: false,
+ default: () => searchUserProjectsWithIssuesEnabled,
+ },
+ groupId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ queryVariables: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ extractProjects: {
+ type: Function,
+ required: false,
+ default: (data) => data?.projects?.nodes,
+ },
+ withLocalStorage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ projects: [],
+ search: '',
+ selectedProject: {},
+ shouldSkipQuery: true,
+ };
+ },
+ apollo: {
+ projects: {
+ query() {
+ return this.query;
+ },
+ variables() {
+ return {
+ search: this.search,
+ ...this.queryVariables,
+ };
+ },
+ update(data) {
+ return this.extractProjects(data) || [];
+ },
+ error(error) {
+ createAlert({
+ message: __('An error occurred while loading projects.'),
+ captureError: true,
+ error,
+ });
+ },
+ skip() {
+ return this.shouldSkipQuery;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ computed: {
+ localStorageKey() {
+ return `group-${this.groupId}-new-${this.resourceType}-recent-project`;
+ },
+ resourceOptions() {
+ return RESOURCE_OPTIONS[this.resourceType];
+ },
+ defaultDropdownText() {
+ return sprintf(__('Select project to create %{type}'), { type: this.resourceOptions.label });
+ },
+ dropdownHref() {
+ return this.hasSelectedProject
+ ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, this.resourceOptions.path)
+ : undefined;
+ },
+ dropdownText() {
+ return this.hasSelectedProject
+ ? sprintf(__('New %{type} in %{project}'), {
+ type: this.resourceOptions.label,
+ project: this.selectedProject.name,
+ })
+ : this.defaultDropdownText;
+ },
+ hasSelectedProject() {
+ return this.selectedProject.webUrl;
+ },
+ showNoSearchResultsText() {
+ return !this.projects.length && this.search;
+ },
+ canUseLocalStorage() {
+ return this.withLocalStorage && AccessorUtilities.canUseLocalStorage();
+ },
+ selectedProjectForLocalStorage() {
+ const { webUrl, name } = this.selectedProject;
+
+ return { webUrl, name };
+ },
+ },
+ methods: {
+ handleDropdownClick() {
+ if (!this.dropdownHref) {
+ this.$refs.dropdown.show();
+ }
+ },
+ handleDropdownShown() {
+ if (this.shouldSkipQuery) {
+ this.shouldSkipQuery = false;
+ }
+ this.$refs.search.focusInput();
+ },
+ selectProject(project) {
+ this.selectedProject = project;
+ },
+ initFromLocalStorage(storedProject) {
+ // Historically, the selected project was saved with the URL as the `url` property, so we are
+ // falling back to that legacy property if `webUrl` is empty. This ensures that we support
+ // localStorage data that was persisted prior to this change.
+ let webUrl = storedProject.webUrl || storedProject.url;
+
+ // The select2 implementation used to include the resource path in the local storage. We
+ // need to clean this up so that we can then re-build a fresh URL in the computed prop.
+ webUrl = webUrl.endsWith(this.resourceOptions.path)
+ ? webUrl.slice(0, webUrl.length - this.resourceOptions.path.length)
+ : webUrl;
+ const dashSuffix = `${DASH_SCOPE}/`;
+ webUrl = webUrl.endsWith(dashSuffix)
+ ? webUrl.slice(0, webUrl.length - dashSuffix.length)
+ : webUrl;
+
+ this.selectedProject = { webUrl, name: storedProject.name };
+ },
+ },
+};
+</script>
+
+<template>
+ <local-storage-sync
+ :storage-key="localStorageKey"
+ :value="selectedProjectForLocalStorage"
+ @input="initFromLocalStorage"
+ >
+ <gl-dropdown
+ ref="dropdown"
+ right
+ split
+ :split-href="dropdownHref"
+ :text="dropdownText"
+ :toggle-text="$options.i18n.toggleButtonLabel"
+ variant="confirm"
+ data-testid="new-resource-dropdown"
+ @click="handleDropdownClick"
+ @shown="handleDropdownShown"
+ >
+ <gl-search-box-by-type ref="search" v-model.trim="search" />
+ <gl-loading-icon v-if="$apollo.queries.projects.loading" />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="project of projects"
+ :key="project.id"
+ @click="selectProject(project)"
+ >
+ {{ project.nameWithNamespace || project.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="showNoSearchResultsText">
+ {{ $options.i18n.noMatchesFound }}
+ </gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+ </local-storage-sync>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 5516c9943b8..5d0ee6adffe 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -33,6 +33,7 @@ export default {
'gl-border-t-transparent': !this.first && !this.selected,
'gl-border-t-gray-100': this.first && !this.selected,
'gl-border-b-gray-100': !this.selected,
+ 'gl-border-t-transparent!': this.selected && !this.first,
'gl-bg-blue-50 gl-border-blue-200': this.selected,
};
},
@@ -126,10 +127,9 @@ export default {
<slot name="right-action"></slot>
</div>
</div>
- <div class="gl-display-flex">
+ <div v-if="isDetailsShown" class="gl-display-flex">
<div class="gl-w-7"></div>
<div
- v-if="isDetailsShown"
class="gl-display-flex gl-flex-direction-column gl-flex-grow-1 gl-bg-gray-10 gl-rounded-base gl-inset-border-1-gray-100 gl-mb-3"
>
<div
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue
deleted file mode 100644
index e3e3b9abc3c..00000000000
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import RunnerAwsDeploymentsModal from './runner_aws_deployments_modal.vue';
-
-export default {
- components: {
- GlButton,
- RunnerAwsDeploymentsModal,
- },
- directives: {
- GlModalDirective,
- },
- modalId: 'runner-aws-deployments-modal',
- i18n: {
- buttonText: s__('Runners|Deploy GitLab Runner in AWS'),
- },
- data() {
- return {
- opened: false,
- };
- },
- methods: {
- onClick() {
- this.opened = true;
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-button
- v-gl-modal-directive="$options.modalId"
- class="gl-mt-4"
- data-testid="show-modal-button"
- variant="confirm"
- @click="onClick"
- >
- {{ $options.i18n.buttonText }}
- </gl-button>
- <runner-aws-deployments-modal v-if="opened" :modal-id="$options.modalId" />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
deleted file mode 100644
index 08acde1aefc..00000000000
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<script>
-import { GlModal } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import RunnerAwsInstructions from '~/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue';
-
-export default {
- components: {
- GlModal,
- RunnerAwsInstructions,
- },
- props: {
- modalId: {
- type: String,
- required: true,
- },
- },
- methods: {
- onClose() {
- this.$refs.modal.close();
- },
- },
- i18n_title: s__('Runners|Deploy GitLab Runner in AWS'),
-};
-</script>
-<template>
- <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n_title" hide-footer size="sm">
- <runner-aws-instructions @close="onClose" />
- </gl-modal>
-</template>
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 3dbc5246c3d..b66c89d1372 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -4,6 +4,7 @@ export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
export const PLATFORM_DOCKER = 'docker';
export const PLATFORM_KUBERNETES = 'kubernetes';
+export const PLATFORM_AWS = 'aws';
export const AWS_README_URL =
'https://gitlab.com/guided-explorations/aws/gitlab-runner-autoscaling-aws-asg/-/blob/main/easybuttons.md';
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue
index cafebdfe5f4..8a234889e6f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/instructions/runner_aws_instructions.vue
@@ -2,6 +2,7 @@
import {
GlButton,
GlSprintf,
+ GlIcon,
GlLink,
GlFormRadioGroup,
GlFormRadio,
@@ -11,6 +12,7 @@ import {
import Tracking from '~/tracking';
import { getBaseURL, objectToQuery, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import {
AWS_README_URL,
AWS_CF_BASE_URL,
@@ -22,13 +24,22 @@ export default {
components: {
GlButton,
GlSprintf,
+ GlIcon,
GlLink,
GlFormRadioGroup,
GlFormRadio,
GlAccordion,
GlAccordionItem,
+ ModalCopyButton,
},
mixins: [Tracking.mixin()],
+ props: {
+ registrationToken: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
data() {
return {
selectedIndex: 0,
@@ -65,16 +76,20 @@ export default {
},
},
i18n: {
- title: s__('Runners|Deploy GitLab Runner in AWS'),
instructions: s__(
- 'Runners|Select your preferred option here. In the next step, you can choose the capacity for your runner in the AWS CloudFormation console.',
+ 'Runners|Select your preferred runner, then choose the capacity for the runner in the AWS CloudFormation console.',
),
chooseRunner: s__('Runners|Choose your preferred GitLab Runner'),
dontSeeWhatYouAreLookingFor: s__(
"Runners|Don't see what you are looking for? See the full list of options, including a fully customizable option %{linkStart}here%{linkEnd}.",
),
+ runnerRegistrationToken: s__('Runners|Runner Registration token'),
+ copyInstructions: s__('Runners|Copy registration token'),
moreDetails: __('More Details'),
lessDetails: __('Less Details'),
+ close: __('Close'),
+ deployRunnerInAws: s__('Runners|Deploy GitLab Runner in AWS'),
+ externalLink: __('(external link)'),
},
readmeUrl: AWS_README_URL,
easyButtons: AWS_EASY_BUTTONS,
@@ -83,6 +98,7 @@ export default {
<template>
<div>
<p>{{ $options.i18n.instructions }}</p>
+
<gl-form-radio-group v-model="selectedIndex" :label="$options.i18n.chooseRunner" label-sr-only>
<gl-form-radio
v-for="(easyButton, idx) in $options.easyButtons"
@@ -113,10 +129,23 @@ export default {
</template>
</gl-sprintf>
</p>
+ <template v-if="registrationToken">
+ <h5 class="gl-mb-3">{{ $options.i18n.runnerRegistrationToken }}</h5>
+ <div class="gl-display-flex">
+ <pre class="gl-bg-gray gl-flex-grow-1 gl-white-space-pre-line">{{ registrationToken }}</pre>
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="registrationToken"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+ </template>
<footer class="gl-display-flex gl-justify-content-end gl-pt-3 gl-gap-3">
- <gl-button @click="onClose()">{{ __('Close') }}</gl-button>
+ <gl-button @click="onClose()">{{ $options.i18n.close }}</gl-button>
<gl-button variant="confirm" @click="onOk()">
- {{ s__('Runners|Deploy GitLab Runner in AWS') }}
+ {{ $options.i18n.deployRunnerInAws }}
+ <gl-icon name="external-link" :aria-label="$options.i18n.externalLink" />
</gl-button>
</footer>
</div>
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 729fe9c462c..22d9b88fa41 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
@@ -14,11 +14,12 @@ import {
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { __, s__ } from '~/locale';
import getRunnerPlatformsQuery from './graphql/get_runner_platforms.query.graphql';
-import { PLATFORM_DOCKER, PLATFORM_KUBERNETES } from './constants';
+import { PLATFORM_DOCKER, PLATFORM_KUBERNETES, PLATFORM_AWS } from './constants';
import RunnerCliInstructions from './instructions/runner_cli_instructions.vue';
import RunnerDockerInstructions from './instructions/runner_docker_instructions.vue';
import RunnerKubernetesInstructions from './instructions/runner_kubernetes_instructions.vue';
+import RunnerAwsInstructions from './instructions/runner_aws_instructions.vue';
export default {
components: {
@@ -104,6 +105,8 @@ export default {
return RunnerDockerInstructions;
case PLATFORM_KUBERNETES:
return RunnerKubernetesInstructions;
+ case PLATFORM_AWS:
+ return RunnerAwsInstructions;
default:
return null;
}
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index 28a16cd846a..092e8ba6c15 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -1,64 +1,55 @@
<script>
import { GlIntersectionObserver } from '@gitlab/ui';
-import LineHighlighter from '~/blob/line_highlighter';
-import ChunkLine from './chunk_line.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import { getPageParamValue, getPageSearchString } from '~/blob/utils';
/*
* We only highlight the chunk that is currently visible to the user.
* By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
*
- * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
- * so by making text transparent and rendering raw (non-highlighted) text,
- * the browser spends less resources on painting content that is not immediately relevant.
- *
- * Why use transparent text as opposed to hiding content entirely?
- * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
- * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
+ * Content that is not visible to the user (i.e. not highlighted) does not need to look nice,
+ * so by rendering raw (non-highlighted) text, the browser spends less resources on painting
+ * content that is not immediately relevant.
+ * Why use plaintext as opposed to hiding content entirely?
+ * If content is hidden entirely, native find text (⌘ + F) won't work.
*/
export default {
components: {
- ChunkLine,
GlIntersectionObserver,
},
+ directives: {
+ SafeHtml,
+ },
+ mixins: [glFeatureFlagMixin()],
props: {
- isFirstChunk: {
+ isHighlighted: {
type: Boolean,
- required: false,
- default: false,
+ required: true,
},
chunkIndex: {
type: Number,
required: false,
default: 0,
},
- isHighlighted: {
- type: Boolean,
+ rawContent: {
+ type: String,
required: true,
},
- content: {
+ highlightedContent: {
type: String,
required: true,
},
- startingFrom: {
- type: Number,
- required: false,
- default: 0,
- },
totalLines: {
type: Number,
required: false,
default: 0,
},
- totalChunks: {
+ startingFrom: {
type: Number,
required: false,
default: 0,
},
- language: {
- type: String,
- required: false,
- default: null,
- },
blamePath: {
type: String,
required: true,
@@ -66,37 +57,37 @@ export default {
},
data() {
return {
+ hasAppeared: false,
isLoading: true,
};
},
computed: {
+ shouldHighlight() {
+ return Boolean(this.highlightedContent) && (this.hasAppeared || this.isHighlighted);
+ },
lines() {
return this.content.split('\n');
},
+ pageSearchString() {
+ if (!this.glFeatures.fileLineBlame) return '';
+ const page = getPageParamValue(this.number);
+ return getPageSearchString(this.blamePath, page);
+ },
},
-
created() {
- if (this.isFirstChunk) {
+ if (this.chunkIndex === 0) {
+ // Display first chunk ASAP in order to improve perceived performance
this.isLoading = false;
return;
}
- window.requestIdleCallback(async () => {
+ window.requestIdleCallback(() => {
this.isLoading = false;
- const { hash } = this.$route;
- if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) {
- // when the last chunk is loaded scroll to the hash
- await this.$nextTick();
- const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
- lineHighlighter.highlightHash(hash);
- }
});
},
methods: {
handleChunkAppear() {
- if (!this.isHighlighted) {
- this.$emit('appear', this.chunkIndex);
- }
+ this.hasAppeared = true;
},
calculateLineNumber(index) {
return this.startingFrom + index + 1;
@@ -106,28 +97,37 @@ export default {
</script>
<template>
<gl-intersection-observer @appear="handleChunkAppear">
- <div v-if="isHighlighted">
- <chunk-line
- v-for="(line, index) in lines"
- :key="index"
- :number="calculateLineNumber(index)"
- :content="line"
- :language="language"
- :blame-path="blamePath"
- />
- </div>
- <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent">
- <div class="gl-display-flex gl-flex-direction-column content-visibility-auto">
- <span
+ <div class="gl-display-flex">
+ <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column">
+ <div
v-for="(n, index) in totalLines"
- v-once
- :id="`L${calculateLineNumber(index)}`"
:key="index"
- data-testid="line-number"
- v-text="calculateLineNumber(index)"
- ></span>
+ data-testid="line-numbers"
+ class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
+ >
+ <a
+ v-if="glFeatures.fileLineBlame"
+ class="gl-user-select-none gl-shadow-none! file-line-blame"
+ :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`"
+ ></a>
+ <a
+ :id="`L${calculateLineNumber(index)}`"
+ class="gl-user-select-none gl-shadow-none! file-line-num"
+ :href="`#L${calculateLineNumber(index)}`"
+ :data-line-number="calculateLineNumber(index)"
+ >
+ {{ calculateLineNumber(index) }}
+ </a>
+ </div>
</div>
- <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div>
+
+ <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent">
+ <!-- Placeholder for line numbers while content is not highlighted -->
+ </div>
+
+ <pre
+ class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0"
+ ><code v-if="shouldHighlight" v-once v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre>
</div>
</gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue
new file mode 100644
index 00000000000..28a16cd846a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_deprecated.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlIntersectionObserver } from '@gitlab/ui';
+import LineHighlighter from '~/blob/line_highlighter';
+import ChunkLine from './chunk_line.vue';
+
+/*
+ * We only highlight the chunk that is currently visible to the user.
+ * By making use of the Intersection Observer API we can determine when a chunk becomes visible and highlight it accordingly.
+ *
+ * Content that is not visible to the user (i.e. not highlighted) do not need to look nice,
+ * so by making text transparent and rendering raw (non-highlighted) text,
+ * the browser spends less resources on painting content that is not immediately relevant.
+ *
+ * Why use transparent text as opposed to hiding content entirely?
+ * 1. If content is hidden entirely, native find text (⌘ + F) won't work.
+ * 2. When URL contains line numbers, the browser needs to be able to jump to the correct line.
+ */
+export default {
+ components: {
+ ChunkLine,
+ GlIntersectionObserver,
+ },
+ props: {
+ isFirstChunk: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ chunkIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ isHighlighted: {
+ type: Boolean,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ startingFrom: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ totalLines: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ totalChunks: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ language: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ blamePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: true,
+ };
+ },
+ computed: {
+ lines() {
+ return this.content.split('\n');
+ },
+ },
+
+ created() {
+ if (this.isFirstChunk) {
+ this.isLoading = false;
+ return;
+ }
+
+ window.requestIdleCallback(async () => {
+ this.isLoading = false;
+ const { hash } = this.$route;
+ if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) {
+ // when the last chunk is loaded scroll to the hash
+ await this.$nextTick();
+ const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ lineHighlighter.highlightHash(hash);
+ }
+ });
+ },
+ methods: {
+ handleChunkAppear() {
+ if (!this.isHighlighted) {
+ this.$emit('appear', this.chunkIndex);
+ }
+ },
+ calculateLineNumber(index) {
+ return this.startingFrom + index + 1;
+ },
+ },
+};
+</script>
+<template>
+ <gl-intersection-observer @appear="handleChunkAppear">
+ <div v-if="isHighlighted">
+ <chunk-line
+ v-for="(line, index) in lines"
+ :key="index"
+ :number="calculateLineNumber(index)"
+ :content="line"
+ :language="language"
+ :blame-path="blamePath"
+ />
+ </div>
+ <div v-else-if="!isLoading" class="gl-display-flex gl-text-transparent">
+ <div class="gl-display-flex gl-flex-direction-column content-visibility-auto">
+ <span
+ v-for="(n, index) in totalLines"
+ v-once
+ :id="`L${calculateLineNumber(index)}`"
+ :key="index"
+ data-testid="line-number"
+ v-text="calculateLineNumber(index)"
+ ></span>
+ </div>
+ <div v-once class="gl-white-space-pre-wrap!" data-testid="content">{{ content }}</div>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index f382ded90d7..15335ea6edc 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -120,6 +120,8 @@ export const EVENT_LABEL_FALLBACK = 'legacy_fallback';
export const LINES_PER_CHUNK = 70;
+export const NEWLINE = '\n';
+
export const BIDI_CHARS = [
'\u202A', // Left-to-Right Embedding (Try treating following text as left-to-right)
'\u202B', // Right-to-Left Embedding (Try treating following text as right-to-left)
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index efafa67a733..11708b6f1f6 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -1,192 +1,40 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import LineHighlighter from '~/blob/line_highlighter';
-import eventHub from '~/notes/event_hub';
-import languageLoader from '~/content_editor/services/highlight_js_language_loader';
-import addBlobLinksTracking from '~/blob/blob_links_tracking';
+import SafeHtml from '~/vue_shared/directives/safe_html';
import Tracking from '~/tracking';
-import {
- EVENT_ACTION,
- EVENT_LABEL_VIEWER,
- EVENT_LABEL_FALLBACK,
- ROUGE_TO_HLJS_LANGUAGE_MAP,
- LINES_PER_CHUNK,
- LEGACY_FALLBACKS,
-} from './constants';
+import addBlobLinksTracking from '~/blob/blob_links_tracking';
+import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants';
import Chunk from './components/chunk.vue';
-import { registerPlugins } from './plugins/index';
-/*
- * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
- * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
- *
- * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
- * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
- * it does not trigger a repaint on a parent element that wraps all 1000 lines.
- */
export default {
components: {
- GlLoadingIcon,
Chunk,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [Tracking.mixin()],
+ inject: {
+ highlightWorker: { default: null },
+ },
props: {
blob: {
type: Object,
required: true,
},
- },
- data() {
- return {
- languageDefinition: null,
- content: this.blob.rawTextBlob,
- language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()],
- hljs: null,
- firstChunk: null,
- chunks: {},
- isLoading: true,
- isLineSelected: false,
- lineHighlighter: null,
- };
- },
- computed: {
- splitContent() {
- return this.content.split(/\r?\n/);
- },
- lineNumbers() {
- return this.splitContent.length;
- },
- unsupportedLanguage() {
- const supportedLanguages = Object.keys(languageLoader);
- const unsupportedLanguage =
- !supportedLanguages.includes(this.language) &&
- !supportedLanguages.includes(this.blob.language?.toLowerCase());
-
- return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage;
- },
- totalChunks() {
- return Object.keys(this.chunks).length;
+ chunks: {
+ type: Array,
+ required: false,
+ default: () => [],
},
},
- async created() {
+ created() {
+ this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language });
addBlobLinksTracking();
- this.trackEvent(EVENT_LABEL_VIEWER);
-
- if (this.unsupportedLanguage) {
- this.handleUnsupportedLanguage();
- return;
- }
-
- this.generateFirstChunk();
- this.hljs = await this.loadHighlightJS();
-
- if (this.language) {
- this.languageDefinition = await this.loadLanguage();
- }
-
- // Highlight the first chunk as soon as highlight.js is available
- this.highlightChunk(null, true);
-
- window.requestIdleCallback(async () => {
- // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
- this.generateRemainingChunks();
- this.isLoading = false;
- await this.$nextTick();
- this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
- });
- },
- methods: {
- trackEvent(label) {
- this.track(EVENT_ACTION, { label, property: this.blob.language });
- },
- handleUnsupportedLanguage() {
- this.trackEvent(EVENT_LABEL_FALLBACK);
- this.$emit('error');
- },
- generateFirstChunk() {
- const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
- this.firstChunk = this.createChunk(lines);
- },
- generateRemainingChunks() {
- const result = {};
- for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
- const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
- const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
- result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
- }
-
- this.chunks = result;
- },
- createChunk(lines, startingFrom = 0) {
- return {
- content: lines.join('\n'),
- startingFrom,
- totalLines: lines.length,
- language: this.language,
- isHighlighted: false,
- };
- },
- highlightChunk(index, isFirstChunk) {
- const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
-
- if (chunk.isHighlighted) {
- return;
- }
-
- const { highlightedContent, language } = this.highlight(chunk.content, this.language);
-
- Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
-
- this.selectLine();
-
- this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
- },
- highlight(content, language) {
- let detectedLanguage = language;
- let highlightedContent;
- if (this.hljs) {
- registerPlugins(this.hljs, this.blob.fileType, this.content);
- if (!detectedLanguage) {
- const hljsHighlightAuto = this.hljs.highlightAuto(content);
- highlightedContent = hljsHighlightAuto.value;
- detectedLanguage = hljsHighlightAuto.language;
- } else if (this.languageDefinition) {
- highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
- }
- }
-
- return { highlightedContent, language: detectedLanguage };
- },
- loadHighlightJS() {
- // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
- return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
- },
- async loadLanguage() {
- let languageDefinition;
-
- try {
- languageDefinition = await languageLoader[this.language]();
- this.hljs.registerLanguage(this.language, languageDefinition.default);
- } catch (message) {
- this.$emit('error', message);
- }
-
- return languageDefinition;
- },
- async selectLine() {
- if (this.isLineSelected || !this.lineHighlighter) {
- return;
- }
-
- this.isLineSelected = true;
- await this.$nextTick();
- this.lineHighlighter.highlightHash(this.$route.hash);
- },
},
userColorScheme: window.gon.user_color_scheme,
- currentlySelectedLine: null,
};
</script>
+
<template>
<div
class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
@@ -196,32 +44,15 @@ export default {
data-qa-selector="blob_viewer_file_content"
>
<chunk
- v-if="firstChunk"
- :lines="firstChunk.lines"
- :total-lines="firstChunk.totalLines"
- :content="firstChunk.content"
- :starting-from="firstChunk.startingFrom"
- :is-highlighted="firstChunk.isHighlighted"
- is-first-chunk
- :language="firstChunk.language"
- :blame-path="blob.blamePath"
- />
-
- <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
- <chunk
- v-for="(chunk, key, index) in chunks"
- v-else
- :key="key"
- :lines="chunk.lines"
- :content="chunk.content"
+ v-for="(chunk, _, index) in chunks"
+ :key="index"
+ :chunk-index="index"
+ :is-highlighted="Boolean(chunk.isHighlighted)"
+ :raw-content="chunk.rawContent"
+ :highlighted-content="chunk.highlightedContent"
:total-lines="chunk.totalLines"
:starting-from="chunk.startingFrom"
- :is-highlighted="chunk.isHighlighted"
- :chunk-index="index"
- :language="chunk.language"
:blame-path="blob.blamePath"
- :total-chunks="totalChunks"
- @appear="highlightChunk"
/>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue
new file mode 100644
index 00000000000..26cf45c7570
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_deprecated.vue
@@ -0,0 +1,227 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import LineHighlighter from '~/blob/line_highlighter';
+import eventHub from '~/notes/event_hub';
+import languageLoader from '~/content_editor/services/highlight_js_language_loader';
+import addBlobLinksTracking from '~/blob/blob_links_tracking';
+import Tracking from '~/tracking';
+import {
+ EVENT_ACTION,
+ EVENT_LABEL_VIEWER,
+ EVENT_LABEL_FALLBACK,
+ ROUGE_TO_HLJS_LANGUAGE_MAP,
+ LINES_PER_CHUNK,
+ LEGACY_FALLBACKS,
+} from './constants';
+import Chunk from './components/chunk_deprecated.vue';
+import { registerPlugins } from './plugins/index';
+
+/*
+ * This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
+ * we highlight and display the 1st chunk (L1-70) to the user as quickly as possible.
+ *
+ * The rest of the lines (L71+) is rendered once the browser goes into an idle state (requestIdleCallback).
+ * Each chunk is self-contained, this ensures when for example the width of a container on line 1000 changes,
+ * it does not trigger a repaint on a parent element that wraps all 1000 lines.
+ */
+export default {
+ components: {
+ GlLoadingIcon,
+ Chunk,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ languageDefinition: null,
+ content: this.blob.rawTextBlob,
+ language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()],
+ hljs: null,
+ firstChunk: null,
+ chunks: {},
+ isLoading: true,
+ isLineSelected: false,
+ lineHighlighter: null,
+ };
+ },
+ computed: {
+ splitContent() {
+ return this.content.split(/\r?\n/);
+ },
+ lineNumbers() {
+ return this.splitContent.length;
+ },
+ unsupportedLanguage() {
+ const supportedLanguages = Object.keys(languageLoader);
+ const unsupportedLanguage =
+ !supportedLanguages.includes(this.language) &&
+ !supportedLanguages.includes(this.blob.language?.toLowerCase());
+
+ return LEGACY_FALLBACKS.includes(this.language) || unsupportedLanguage;
+ },
+ totalChunks() {
+ return Object.keys(this.chunks).length;
+ },
+ },
+ async created() {
+ addBlobLinksTracking();
+ this.trackEvent(EVENT_LABEL_VIEWER);
+
+ if (this.unsupportedLanguage) {
+ this.handleUnsupportedLanguage();
+ return;
+ }
+
+ this.generateFirstChunk();
+ this.hljs = await this.loadHighlightJS();
+
+ if (this.language) {
+ this.languageDefinition = await this.loadLanguage();
+ }
+
+ // Highlight the first chunk as soon as highlight.js is available
+ this.highlightChunk(null, true);
+
+ window.requestIdleCallback(async () => {
+ // Generate the remaining chunks once the browser idles to ensure the browser resources are spent on the most important things first
+ this.generateRemainingChunks();
+ this.isLoading = false;
+ await this.$nextTick();
+ this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' });
+ });
+ },
+ methods: {
+ trackEvent(label) {
+ this.track(EVENT_ACTION, { label, property: this.blob.language });
+ },
+ handleUnsupportedLanguage() {
+ this.trackEvent(EVENT_LABEL_FALLBACK);
+ this.$emit('error');
+ },
+ generateFirstChunk() {
+ const lines = this.splitContent.splice(0, LINES_PER_CHUNK);
+ this.firstChunk = this.createChunk(lines);
+ },
+ generateRemainingChunks() {
+ const result = {};
+ for (let i = 0; i < this.splitContent.length; i += LINES_PER_CHUNK) {
+ const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
+ const lines = this.splitContent.slice(i, i + LINES_PER_CHUNK);
+ result[chunkIndex] = this.createChunk(lines, i + LINES_PER_CHUNK);
+ }
+
+ this.chunks = result;
+ },
+ createChunk(lines, startingFrom = 0) {
+ return {
+ content: lines.join('\n'),
+ startingFrom,
+ totalLines: lines.length,
+ language: this.language,
+ isHighlighted: false,
+ };
+ },
+ highlightChunk(index, isFirstChunk) {
+ const chunk = isFirstChunk ? this.firstChunk : this.chunks[index];
+
+ if (chunk.isHighlighted) {
+ return;
+ }
+
+ const { highlightedContent, language } = this.highlight(chunk.content, this.language);
+
+ Object.assign(chunk, { language, content: highlightedContent, isHighlighted: true });
+
+ this.selectLine();
+
+ this.$nextTick(() => eventHub.$emit('showBlobInteractionZones', this.blob.path));
+ },
+ highlight(content, language) {
+ let detectedLanguage = language;
+ let highlightedContent;
+ if (this.hljs) {
+ registerPlugins(this.hljs, this.blob.fileType, this.content);
+ if (!detectedLanguage) {
+ const hljsHighlightAuto = this.hljs.highlightAuto(content);
+ highlightedContent = hljsHighlightAuto.value;
+ detectedLanguage = hljsHighlightAuto.language;
+ } else if (this.languageDefinition) {
+ highlightedContent = this.hljs.highlight(content, { language: this.language }).value;
+ }
+ }
+
+ return { highlightedContent, language: detectedLanguage };
+ },
+ loadHighlightJS() {
+ // If no language can be mapped to highlight.js we load all common languages else we load only the core (smallest footprint)
+ return !this.language ? import('highlight.js/lib/common') : import('highlight.js/lib/core');
+ },
+ async loadLanguage() {
+ let languageDefinition;
+
+ try {
+ languageDefinition = await languageLoader[this.language]();
+ this.hljs.registerLanguage(this.language, languageDefinition.default);
+ } catch (message) {
+ this.$emit('error', message);
+ }
+
+ return languageDefinition;
+ },
+ async selectLine() {
+ if (this.isLineSelected || !this.lineHighlighter) {
+ return;
+ }
+
+ this.isLineSelected = true;
+ await this.$nextTick();
+ this.lineHighlighter.highlightHash(this.$route.hash);
+ },
+ },
+ userColorScheme: window.gon.user_color_scheme,
+ currentlySelectedLine: null,
+};
+</script>
+<template>
+ <div
+ class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto"
+ :class="$options.userColorScheme"
+ data-type="simple"
+ :data-path="blob.path"
+ data-qa-selector="blob_viewer_file_content"
+ >
+ <chunk
+ v-if="firstChunk"
+ :lines="firstChunk.lines"
+ :total-lines="firstChunk.totalLines"
+ :content="firstChunk.content"
+ :starting-from="firstChunk.startingFrom"
+ :is-highlighted="firstChunk.isHighlighted"
+ is-first-chunk
+ :language="firstChunk.language"
+ :blame-path="blob.blamePath"
+ />
+
+ <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
+ <chunk
+ v-for="(chunk, key, index) in chunks"
+ v-else
+ :key="key"
+ :lines="chunk.lines"
+ :content="chunk.content"
+ :total-lines="chunk.totalLines"
+ :starting-from="chunk.startingFrom"
+ :is-highlighted="chunk.isHighlighted"
+ :chunk-index="index"
+ :language="chunk.language"
+ :blame-path="blob.blamePath"
+ :total-chunks="totalChunks"
+ @appear="highlightChunk"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js
index 0da57f9e6fa..142c135e9c1 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/workers/highlight_utils.js
@@ -1,15 +1,47 @@
-import hljs from 'highlight.js/lib/core';
-import languageLoader from '~/content_editor/services/highlight_js_language_loader';
+import hljs from 'highlight.js';
import { registerPlugins } from '../plugins/index';
+import { LINES_PER_CHUNK, NEWLINE, ROUGE_TO_HLJS_LANGUAGE_MAP } from '../constants';
-const initHighlightJs = async (fileType, content, language) => {
- const languageDefinition = await languageLoader[language]();
-
+const initHighlightJs = (fileType, content) => {
registerPlugins(hljs, fileType, content);
- hljs.registerLanguage(language, languageDefinition.default);
};
-export const highlight = (fileType, content, language) => {
- initHighlightJs(fileType, content, language);
- return hljs.highlight(content, { language }).value;
+const splitByLineBreaks = (content = '') => content.split(/\r?\n/);
+
+const createChunk = (language, rawChunkLines, highlightedChunkLines = [], startingFrom = 0) => ({
+ highlightedContent: highlightedChunkLines.join(NEWLINE),
+ rawContent: rawChunkLines.join(NEWLINE),
+ totalLines: rawChunkLines.length,
+ startingFrom,
+ language,
+});
+
+const splitIntoChunks = (language, rawContent, highlightedContent) => {
+ const result = [];
+ const splitRawContent = splitByLineBreaks(rawContent);
+ const splitHighlightedContent = splitByLineBreaks(highlightedContent);
+
+ for (let i = 0; i < splitRawContent.length; i += LINES_PER_CHUNK) {
+ const chunkIndex = Math.floor(i / LINES_PER_CHUNK);
+ const highlightedChunk = splitHighlightedContent.slice(i, i + LINES_PER_CHUNK);
+ const rawChunk = splitRawContent.slice(i, i + LINES_PER_CHUNK);
+ result[chunkIndex] = createChunk(language, rawChunk, highlightedChunk, i);
+ }
+
+ return result;
+};
+
+const highlight = (fileType, rawContent, lang) => {
+ const language = ROUGE_TO_HLJS_LANGUAGE_MAP[lang.toLowerCase()];
+ let result;
+
+ if (language) {
+ initHighlightJs(fileType, rawContent, language);
+ const highlightedContent = hljs.highlight(rawContent, { language }).value;
+ result = splitIntoChunks(language, rawContent, highlightedContent);
+ }
+
+ return result;
};
+
+export { highlight, splitIntoChunks };
diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue
index bd5b7b77017..ad81c14d9e5 100644
--- a/app/assets/javascripts/vue_shared/components/url_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/url_sync.vue
@@ -1,7 +1,9 @@
<script>
-import { historyPushState } from '~/lib/utils/common_utils';
+import { historyPushState, historyReplaceState } from '~/lib/utils/common_utils';
import { mergeUrlParams, setUrlParams } from '~/lib/utils/url_utility';
+export const HISTORY_PUSH_UPDATE_METHOD = 'push';
+export const HISTORY_REPLACE_UPDATE_METHOD = 'replace';
export const URL_SET_PARAMS_STRATEGY = 'set';
export const URL_MERGE_PARAMS_STRATEGY = 'merge';
@@ -24,6 +26,13 @@ export default {
default: URL_MERGE_PARAMS_STRATEGY,
validator: (value) => [URL_MERGE_PARAMS_STRATEGY, URL_SET_PARAMS_STRATEGY].includes(value),
},
+ historyUpdateMethod: {
+ type: String,
+ required: false,
+ default: HISTORY_PUSH_UPDATE_METHOD,
+ validator: (value) =>
+ [HISTORY_PUSH_UPDATE_METHOD, HISTORY_REPLACE_UPDATE_METHOD].includes(value),
+ },
},
watch: {
query: {
@@ -40,9 +49,14 @@ export default {
updateQuery(newQuery) {
const url =
this.urlParamsUpdateStrategy === URL_SET_PARAMS_STRATEGY
- ? setUrlParams(this.query, window.location.href, true)
+ ? setUrlParams(this.query, window.location.href, true, true, true)
: mergeUrlParams(newQuery, window.location.href, { spreadArrays: true });
- historyPushState(url);
+
+ if (this.historyUpdateMethod === HISTORY_PUSH_UPDATE_METHOD) {
+ historyPushState(url);
+ } else {
+ historyReplaceState(url);
+ }
},
},
render() {
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index 231f5ff3d1f..167db3ce1f2 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -74,8 +74,8 @@ export default {
<user-avatar-link
v-for="item in visibleItems"
:key="item.id"
- :link-href="item.web_url"
- :img-src="item.avatar_url"
+ :link-href="item.web_url || item.webUrl"
+ :img-src="item.avatar_url || item.avatarUrl"
:img-alt="item.name"
:tooltip-text="item.name"
:img-size="imgSize"
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index 86a99b8f0ed..edcfabe7da3 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -2,18 +2,19 @@
import { debounce } from 'lodash';
import {
GlDropdown,
- GlDropdownForm,
GlDropdownDivider,
+ GlDropdownForm,
GlDropdownItem,
- GlSearchBoxByType,
GlLoadingIcon,
+ GlSearchBoxByType,
GlTooltipDirective,
} from '@gitlab/ui';
import { __ } from '~/locale';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
-import { IssuableType } from '~/issues/constants';
+import { IssuableType, TYPE_ISSUE } from '~/issues/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { participantsQueries, userSearchQueries } from '~/sidebar/constants';
+import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
export default {
@@ -47,7 +48,8 @@ export default {
},
iid: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
value: {
type: Array,
@@ -65,7 +67,7 @@ export default {
issuableType: {
type: String,
required: false,
- default: IssuableType.Issue,
+ default: TYPE_ISSUE,
},
isEditing: {
type: Boolean,
@@ -160,20 +162,17 @@ export default {
}
return {
...variables,
- mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId),
+ mergeRequestId: convertToGraphQLId(TYPENAME_MERGE_REQUEST, this.issuableId),
};
},
isLoading() {
return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
},
users() {
- if (!this.participants) {
- return [];
- }
-
- const filteredParticipants = this.participants.filter(
- (user) => user.name.includes(this.search) || user.username.includes(this.search),
- );
+ const filteredParticipants =
+ this.participants?.filter(
+ (user) => user.name.includes(this.search) || user.username.includes(this.search),
+ ) || [];
// TODO this de-duplication is temporary (BE fix required)
// https://gitlab.com/gitlab-org/gitlab/-/issues/327822
@@ -254,6 +253,10 @@ export default {
this.$emit('input', selected);
}
},
+ unassign() {
+ this.$emit('input', []);
+ this.$refs.dropdown.hide();
+ },
unselect(name) {
const selected = this.value.filter((user) => user.username !== name);
this.$emit('input', selected);
@@ -323,7 +326,7 @@ export default {
:is-checked="selectedIsEmpty"
is-check-centered
data-testid="unassign"
- @click.native.capture.stop="$emit('input', [])"
+ @click.native.capture.stop="unassign"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 98630512308..28bec63b244 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -423,6 +423,7 @@ export default {
target="_blank"
:href="webIdeUrl"
block
+ @click="dismissCalloutOnActionClicked(dismiss)"
>
{{ __('Try it out now') }}
</gl-link>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index c93dd95a886..fd151751372 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -1,5 +1,5 @@
import { __, n__, sprintf } from '~/locale';
-import { IssuableType, WorkspaceType } from '~/issues/constants';
+import { TYPE_ISSUE, WorkspaceType } from '~/issues/constants';
const INTERVALS = {
minute: 'minute',
@@ -88,9 +88,9 @@ export const confidentialityInfoText = (workspaceType, issuableType) =>
),
{
workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'),
- issuableType: issuableType === IssuableType.Issue ? __('issue') : __('epic'),
+ issuableType: issuableType === TYPE_ISSUE ? __('issue') : __('epic'),
permissions:
- issuableType === IssuableType.Issue
+ issuableType === TYPE_ISSUE
? __('at least the Reporter role, the author, and assignees')
: __('at least the Reporter role'),
},
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
index 5eb3da3c62e..d78530239a5 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -173,6 +173,7 @@ export default {
:can-edit="enableEdit"
:task-list-update-path="taskListUpdatePath"
/>
+ <slot name="secondary-content"></slot>
<small v-if="isUpdated" class="edited-text gl-font-sm!">
{{ __('Edited') }}
<time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
index 9b1cbfe218b..6fe98764fcd 100644
--- a/app/assets/javascripts/vue_shared/security_reports/components/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
@@ -1,8 +1,8 @@
export const SEVERITY_CLASS_NAME_MAP = {
- critical: 'text-danger-800',
- high: 'text-danger-600',
- medium: 'text-warning-400',
- low: 'text-warning-200',
- info: 'text-primary-400',
- unknown: 'text-secondary-400',
+ critical: 'gl-text-red-800',
+ high: 'gl-text-red-600',
+ medium: 'gl-text-orange-400',
+ low: 'gl-text-orange-200',
+ info: 'gl-text-blue-400',
+ unknown: 'gl-text-gray-400',
};
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
index f3cb5fc16f0..f620bad8dba 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
@@ -24,42 +24,37 @@ export const fetchDiffData = (state, endpoint, category) => {
/**
* Returns given vulnerability enriched with the corresponding
* feedback (`dismissal` or `issue` type)
- * @param {Object} vulnerability
- * @param {Array} feedback
+ * @param {Object} vulnerabilityObject
+ * @param {Array} feedbackList
*/
-export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) =>
- feedback
+export const enrichVulnerabilityWithFeedback = (vulnerabilityObject, feedbackList = []) => {
+ const vulnerability = { ...vulnerabilityObject };
+ // Some records may have a null `uuid`, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback.
+ // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791
+ feedbackList
.filter((fb) =>
- // Some records still have a `finding_uuid` with null, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback.
- // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791
- fb.finding_uuid !== null
- ? fb.finding_uuid === vulnerability.finding_uuid
+ fb.finding_uuid
+ ? fb.finding_uuid === vulnerability.uuid
: fb.project_fingerprint === vulnerability.project_fingerprint,
)
- .reduce((vuln, fb) => {
- if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) {
- return {
- ...vuln,
- isDismissed: true,
- dismissalFeedback: fb,
- };
+ .forEach((feedback) => {
+ if (feedback.feedback_type === FEEDBACK_TYPE_DISMISSAL) {
+ vulnerability.isDismissed = true;
+ vulnerability.dismissalFeedback = feedback;
+ } else if (feedback.feedback_type === FEEDBACK_TYPE_ISSUE && feedback.issue_iid) {
+ vulnerability.hasIssue = true;
+ vulnerability.issue_feedback = feedback;
+ } else if (
+ feedback.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST &&
+ feedback.merge_request_iid
+ ) {
+ vulnerability.hasMergeRequest = true;
+ vulnerability.merge_request_feedback = feedback;
}
- if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) {
- return {
- ...vuln,
- hasIssue: true,
- issue_feedback: fb,
- };
- }
- if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) {
- return {
- ...vuln,
- hasMergeRequest: true,
- merge_request_feedback: fb,
- };
- }
- return vuln;
- }, vulnerability);
+ });
+
+ return vulnerability;
+};
/**
* Generates the added, fixed, and existing vulnerabilities from the API report.
diff --git a/app/assets/javascripts/webhooks/components/test_dropdown.vue b/app/assets/javascripts/webhooks/components/test_dropdown.vue
new file mode 100644
index 00000000000..78e5dff6f59
--- /dev/null
+++ b/app/assets/javascripts/webhooks/components/test_dropdown.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlDisclosureDropdown } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'HookTestDropdown',
+ components: {
+ GlDisclosureDropdown,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ size: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ itemsWithAction() {
+ return this.items.map((item) => ({
+ text: item.text,
+ action: () => this.testHook(item.href),
+ }));
+ },
+ },
+ methods: {
+ testHook(href) {
+ // HACK: Trigger @rails/ujs's data-method handling.
+ //
+ // The more obvious approaches of (1) declaratively rendering the
+ // links using GlDisclosureDropdown's list-item slot and (2) using
+ // item.extraAttrs to set the data-method attributes on the links
+ // do not work for reasons laid out in
+ // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2134.
+ //
+ // Sending the POST with axios also doesn't work, since the
+ // endpoints return 302 redirects. Since axios uses XMLHTTPRequest,
+ // it transparently follows redirects, meaning the Location header
+ // of the first response cannot be inspected/acted upon by JS. We
+ // could manually trigger a reload afterwards, but that would mean
+ // a duplicate fetch of the current page: one by the XHR, and one
+ // by the explicit reload. It would also mean losing the flash
+ // alert set by the backend, making the feature useless for the
+ // user.
+ //
+ // The ideal fix here would be to refactor the test endpoint to
+ // return a JSON response, removing the need for a redirect/page
+ // reload to show the result.
+ const a = document.createElement('a');
+ a.setAttribute('hidden', '');
+ a.href = href;
+ a.dataset.method = 'post';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ },
+ },
+ i18n: {
+ test: __('Test'),
+ },
+};
+</script>
+
+<template>
+ <gl-disclosure-dropdown :toggle-text="$options.i18n.test" :items="itemsWithAction" :size="size" />
+</template>
diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js
index 7d04978280b..6eb7cbea72c 100644
--- a/app/assets/javascripts/webhooks/index.js
+++ b/app/assets/javascripts/webhooks/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import FormUrlApp from './components/form_url_app.vue';
+import TestDropdown from './components/test_dropdown.vue';
export default () => {
const el = document.querySelector('.js-vue-webhook-form');
@@ -23,3 +24,22 @@ export default () => {
},
});
};
+
+const initHookTestDropdown = (el) => {
+ const { items, size } = el.dataset;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(TestDropdown, {
+ props: {
+ items: JSON.parse(items),
+ size,
+ },
+ });
+ },
+ });
+};
+
+export const initHookTestDropdowns = (selector = '.js-webhook-test-dropdown') =>
+ document.querySelectorAll(selector).forEach(initHookTestDropdown);
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 9e5361e8302..472bc1dfacc 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -35,7 +35,11 @@ export default {
const body = document.querySelector('body');
const { namespaceId } = body.dataset;
- this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
+ this.track('click_whats_new_drawer', {
+ label: 'namespace_id',
+ value: namespaceId,
+ property: 'navigation_top',
+ });
},
methods: {
...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']),
diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js
index 41aff202f48..f9b725ed429 100644
--- a/app/assets/javascripts/whats_new/utils/notification.js
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -5,6 +5,8 @@ export const getVersionDigest = (appEl) => appEl.dataset.versionDigest;
export const setNotification = (appEl) => {
const versionDigest = getVersionDigest(appEl);
const notificationEl = document.querySelector('.header-help');
+ if (!notificationEl) return;
+
let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
if (localStorage.getItem(STORAGE_KEY) === versionDigest) {
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 2a0913e380a..8ec8482657d 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -62,6 +62,7 @@ export default {
:value="state"
:options="$options.states"
:disabled="disabled"
+ data-testid="work-item-state-select"
class="gl-w-auto hide-select-decoration gl-pl-3"
:class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
@change="setState"
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index b2c8b7ae1db..6aa3c54705c 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -35,7 +35,7 @@ export default {
<template>
<h2
- class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full"
+ class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-1 gl-mt-0 gl-w-full"
:class="{ 'gl-cursor-text': disabled }"
aria-labelledby="item-title"
>
diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue
index 92a2fcaf1df..bca061f5e01 100644
--- a/app/assets/javascripts/work_items/components/notes/system_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/system_note.vue
@@ -22,6 +22,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
import axios from '~/lib/utils/axios_utils';
import { getLocationHash } from '~/lib/utils/url_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import NoteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -87,6 +88,9 @@ export default {
descriptionVersion() {
return this.descriptionVersions[this.note.description_version_id];
},
+ noteId() {
+ return getIdFromGraphQLId(this.note.id);
+ },
},
mounted() {
renderGFM(this.$refs['gfm-content']);
@@ -129,7 +133,7 @@ export default {
<note-header
:author="note.author"
:created-at="note.createdAt"
- :note-id="note.id"
+ :note-id="noteId"
:is-system-note="true"
>
<span ref="gfm-content" v-safe-html="actionTextHtml"></span>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
new file mode 100644
index 00000000000..b3f17aff2ae
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue
@@ -0,0 +1,211 @@
+<script>
+import { GlAvatar, GlButton } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { clearDraft } from '~/lib/utils/autosave';
+import Tracking from '~/tracking';
+import { ASC } from '~/notes/constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { updateCommentState } from '~/work_items/graphql/cache_utils';
+import { getWorkItemQuery } from '../../utils';
+import createNoteMutation from '../../graphql/notes/create_work_item_note.mutation.graphql';
+import { TRACKING_CATEGORY_SHOW, i18n } from '../../constants';
+import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
+import WorkItemCommentLocked from './work_item_comment_locked.vue';
+import WorkItemCommentForm from './work_item_comment_form.vue';
+
+export default {
+ constantOptions: {
+ avatarUrl: window.gon.current_user_avatar_url,
+ },
+ components: {
+ GlAvatar,
+ GlButton,
+ WorkItemNoteSignedOut,
+ WorkItemCommentLocked,
+ WorkItemCommentForm,
+ },
+ mixins: [glFeatureFlagMixin(), Tracking.mixin()],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ discussionId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ addPadding: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ sortOrder: {
+ type: String,
+ required: false,
+ default: ASC,
+ },
+ },
+ data() {
+ return {
+ workItem: {},
+ isEditing: false,
+ isSubmitting: false,
+ isSubmittingWithKeydown: false,
+ };
+ },
+ apollo: {
+ workItem: {
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ },
+ skip() {
+ return !this.queryVariables.id && !this.queryVariables.iid;
+ },
+ error() {
+ this.$emit('error', i18n.fetchError);
+ },
+ },
+ },
+ computed: {
+ signedIn() {
+ return Boolean(window.gon.current_user_id);
+ },
+ autosaveKey() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return this.discussionId ? `${this.discussionId}-comment` : `${this.workItemId}-comment`;
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_comment',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ markdownPreviewPath() {
+ return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
+ this.workItemType
+ }`;
+ },
+ timelineEntryClass() {
+ return {
+ 'timeline-entry gl-mb-3': true,
+ 'gl-p-4': this.addPadding,
+ };
+ },
+ isProjectArchived() {
+ return this.workItem?.project?.archived;
+ },
+ canUpdate() {
+ return this.workItem?.userPermissions?.updateWorkItem;
+ },
+ },
+ watch: {
+ autofocus: {
+ immediate: true,
+ handler(focus) {
+ if (focus) {
+ this.isEditing = true;
+ }
+ },
+ },
+ },
+ methods: {
+ async updateWorkItem(commentText) {
+ this.isSubmitting = true;
+ this.$emit('replying', commentText);
+ const { queryVariables, fetchByIid } = this;
+
+ try {
+ this.track('add_work_item_comment');
+
+ await this.$apollo.mutate({
+ mutation: createNoteMutation,
+ variables: {
+ input: {
+ noteableId: this.workItemId,
+ body: commentText,
+ discussionId: this.discussionId || null,
+ },
+ },
+ update(store, createNoteData) {
+ if (createNoteData.data?.createNote?.errors?.length) {
+ throw new Error(createNoteData.data?.createNote?.errors[0]);
+ }
+ updateCommentState(store, createNoteData, fetchByIid, queryVariables);
+ },
+ });
+ clearDraft(this.autosaveKey);
+ this.$emit('replied');
+ this.cancelEditing();
+ } catch (error) {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ }
+
+ this.isSubmitting = false;
+ },
+ cancelEditing() {
+ this.isEditing = false;
+ this.$emit('cancelEditing');
+ },
+ },
+};
+</script>
+
+<template>
+ <li :class="timelineEntryClass">
+ <work-item-note-signed-out v-if="!signedIn" />
+ <work-item-comment-locked
+ v-else-if="!canUpdate"
+ :work-item-type="workItemType"
+ :is-project-archived="isProjectArchived"
+ />
+ <div v-else class="gl-relative gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ <work-item-comment-form
+ v-if="isEditing"
+ :work-item-type="workItemType"
+ :aria-label="__('Add a comment')"
+ :is-submitting="isSubmitting"
+ :autosave-key="autosaveKey"
+ @submitForm="updateWorkItem"
+ @cancelEditing="cancelEditing"
+ />
+ <gl-button
+ v-else
+ class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
+ @click="isEditing = true"
+ >{{ __('Add a comment') }}</gl-button
+ >
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
new file mode 100644
index 00000000000..fd407fd9d9f
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__, __ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
+
+export default {
+ constantOptions: {
+ markdownDocsPath: helpPagePath('user/markdown'),
+ },
+ components: {
+ GlButton,
+ MarkdownEditor,
+ },
+ inject: ['fullPath'],
+ props: {
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ ariaLabel: {
+ type: String,
+ required: true,
+ },
+ autosaveKey: {
+ type: String,
+ required: true,
+ },
+ isSubmitting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ initialValue: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ commentButtonText: {
+ type: String,
+ required: false,
+ default: __('Comment'),
+ },
+ },
+ data() {
+ return {
+ commentText: getDraft(this.autosaveKey) || this.initialValue || '',
+ };
+ },
+ computed: {
+ markdownPreviewPath() {
+ return joinPaths(
+ '/',
+ gon.relative_url_root || '',
+ this.fullPath,
+ `/preview_markdown?target_type=${this.workItemType}`,
+ );
+ },
+ formFieldProps() {
+ return {
+ 'aria-label': this.ariaLabel,
+ placeholder: __('Write a comment or drag your files here…'),
+ id: 'work-item-add-or-edit-comment',
+ name: 'work-item-add-or-edit-comment',
+ };
+ },
+ },
+ methods: {
+ setCommentText(newText) {
+ this.commentText = newText;
+ updateDraft(this.autosaveKey, this.commentText);
+ },
+ async cancelEditing() {
+ if (this.commentText && this.commentText !== this.initialValue) {
+ const msg = s__('WorkItem|Are you sure you want to cancel editing?');
+
+ const confirmed = await confirmAction(msg, {
+ primaryBtnText: __('Discard changes'),
+ cancelBtnText: __('Continue editing'),
+ primaryBtnVariant: 'danger',
+ });
+
+ if (!confirmed) {
+ return;
+ }
+ }
+
+ this.$emit('cancelEditing');
+ clearDraft(this.autosaveKey);
+ },
+ },
+};
+</script>
+
+<template>
+ <form class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
+ <markdown-editor
+ :value="commentText"
+ :render-markdown-path="markdownPreviewPath"
+ :markdown-docs-path="$options.constantOptions.markdownDocsPath"
+ :form-field-props="formFieldProps"
+ data-testid="work-item-add-comment"
+ class="gl-mb-3"
+ autofocus
+ use-bottom-toolbar
+ @input="setCommentText"
+ @keydown.meta.enter="$emit('submitForm', commentText)"
+ @keydown.ctrl.enter="$emit('submitForm', commentText)"
+ @keydown.esc.stop="cancelEditing"
+ />
+ <gl-button
+ category="primary"
+ variant="confirm"
+ data-testid="confirm-button"
+ :loading="isSubmitting"
+ @click="$emit('submitForm', commentText)"
+ >{{ commentButtonText }}
+ </gl-button>
+ <gl-button data-testid="cancel-button" category="primary" class="gl-ml-3" @click="cancelEditing"
+ >{{ __('Cancel') }}
+ </gl-button>
+ </form>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
index f837d025b7f..f837d025b7f 100644
--- a/app/assets/javascripts/work_items/components/work_item_comment_locked.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_locked.vue
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
new file mode 100644
index 00000000000..bda00f978b9
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_discussion.vue
@@ -0,0 +1,191 @@
+<script>
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+import { ASC } from '~/notes/constants';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue';
+import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
+import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
+import WorkItemNoteReplying from '~/work_items/components/notes/work_item_note_replying.vue';
+import WorkItemAddNote from './work_item_add_note.vue';
+
+export default {
+ components: {
+ TimelineEntryItem,
+ GlAvatarLink,
+ GlAvatar,
+ WorkItemNote,
+ WorkItemAddNote,
+ ToggleRepliesWidget,
+ DiscussionNotesRepliesWrapper,
+ WorkItemNoteReplying,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ fetchByIid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ discussion: {
+ type: Array,
+ required: true,
+ },
+ sortOrder: {
+ type: String,
+ default: ASC,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ isExpanded: false,
+ autofocus: false,
+ isReplying: false,
+ replyingText: '',
+ };
+ },
+ computed: {
+ note() {
+ return this.discussion[0];
+ },
+ author() {
+ return this.note.author;
+ },
+ noteAnchorId() {
+ return `note_${this.note.id}`;
+ },
+ hasReplies() {
+ return this.replies?.length;
+ },
+ replies() {
+ if (this.discussion?.length > 1) {
+ return this.discussion.slice(1);
+ }
+ return null;
+ },
+ discussionId() {
+ return this.discussion[0]?.discussion?.id || '';
+ },
+ },
+ methods: {
+ showReplyForm() {
+ this.isExpanded = true;
+ this.autofocus = true;
+ },
+ hideReplyForm() {
+ this.isExpanded = this.hasReplies;
+ this.autofocus = false;
+ },
+ toggleDiscussion() {
+ this.isExpanded = !this.isExpanded;
+ this.autofocus = this.isExpanded;
+ },
+ threadKey(note) {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ return `${note.id}-thread`;
+ },
+ onReplied() {
+ this.isExpanded = true;
+ this.isReplying = false;
+ this.replyingText = '';
+ },
+ onReplying(commentText) {
+ this.isReplying = true;
+ this.replyingText = commentText;
+ },
+ },
+};
+</script>
+
+<template>
+ <timeline-entry-item
+ :id="noteAnchorId"
+ :class="{ 'internal-note': note.internal }"
+ :data-note-id="note.id"
+ class="note note-wrapper note-comment gl-px-0"
+ >
+ <div class="timeline-avatar gl-float-left">
+ <gl-avatar-link :href="author.webUrl">
+ <gl-avatar
+ :src="author.avatarUrl"
+ :entity-name="author.username"
+ :alt="author.name"
+ :size="32"
+ />
+ </gl-avatar-link>
+ </div>
+
+ <div class="timeline-content">
+ <div class="discussion-body">
+ <div class="discussion-wrapper">
+ <div class="discussion-notes">
+ <ul class="notes">
+ <work-item-note
+ :is-first-note="true"
+ :note="note"
+ :discussion-id="discussionId"
+ :work-item-type="workItemType"
+ :class="{ 'gl-mb-5': hasReplies }"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', note)"
+ @error="$emit('error', $event)"
+ />
+ <discussion-notes-replies-wrapper>
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ @toggle="toggleDiscussion({ discussionId })"
+ />
+ <template v-if="isExpanded">
+ <template v-for="reply in replies">
+ <work-item-note
+ :key="threadKey(reply)"
+ :discussion-id="discussionId"
+ :note="reply"
+ :work-item-type="workItemType"
+ @startReplying="showReplyForm"
+ @deleteNote="$emit('deleteNote', reply)"
+ @error="$emit('error', $event)"
+ />
+ </template>
+ <work-item-note-replying v-if="isReplying" :body="replyingText" />
+ <work-item-add-note
+ :autofocus="autofocus"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :work-item-id="workItemId"
+ :fetch-by-iid="fetchByIid"
+ :discussion-id="discussionId"
+ :work-item-type="workItemType"
+ :sort-order="sortOrder"
+ :add-padding="true"
+ @cancelEditing="hideReplyForm"
+ @replied="onReplied"
+ @replying="onReplying"
+ @error="$emit('error', $event)"
+ />
+ </template>
+ </discussion-notes-replies-wrapper>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </timeline-entry-item>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
index 5efa9c94f2b..5dd21a5f76f 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue
@@ -1,42 +1,126 @@
<script>
-import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
+import { GlAvatarLink, GlAvatar, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { __ } from '~/locale';
+import { updateDraft, clearDraft } from '~/lib/utils/autosave';
+import { renderMarkdown } from '~/notes/utils';
+import EditedAt from '~/issues/show/components/edited.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import NoteBody from '~/work_items/components/notes/work_item_note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
+import NoteActions from '~/work_items/components/notes/work_item_note_actions.vue';
+import updateWorkItemNoteMutation from '../../graphql/notes/update_work_item_note.mutation.graphql';
+import WorkItemCommentForm from './work_item_comment_form.vue';
export default {
+ name: 'WorkItemNoteThread',
+ i18n: {
+ moreActionsText: __('More actions'),
+ deleteNoteText: __('Delete comment'),
+ },
components: {
- NoteHeader,
- NoteBody,
TimelineEntryItem,
- GlAvatarLink,
+ NoteBody,
+ NoteHeader,
+ NoteActions,
GlAvatar,
+ GlAvatarLink,
+ GlDropdown,
+ GlDropdownItem,
+ WorkItemCommentForm,
+ EditedAt,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
note: {
type: Object,
required: true,
},
+ isFirstNote: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ };
},
computed: {
author() {
return this.note.author;
},
- noteAnchorId() {
- return `note_${this.note.id}`;
+ entryClass() {
+ return {
+ 'note note-wrapper note-comment': true,
+ 'gl-p-4': !this.isFirstNote,
+ };
+ },
+ showReply() {
+ return this.note.userPermissions.createNote && this.isFirstNote;
+ },
+ autosaveKey() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `${this.note.id}-comment`;
+ },
+ lastEditedBy() {
+ return this.note.lastEditedBy;
+ },
+ hasAdminPermission() {
+ return this.note.userPermissions.adminNote;
+ },
+ },
+ methods: {
+ showReplyForm() {
+ this.$emit('startReplying');
+ },
+ startEditing() {
+ this.isEditing = true;
+ updateDraft(this.autosaveKey, this.note.body);
+ },
+ async updateNote(newText) {
+ this.isEditing = false;
+ try {
+ await this.$apollo.mutate({
+ mutation: updateWorkItemNoteMutation,
+ variables: {
+ input: {
+ id: this.note.id,
+ body: newText,
+ },
+ },
+ optimisticResponse: {
+ updateNote: {
+ errors: [],
+ note: {
+ ...this.note,
+ bodyHtml: renderMarkdown(newText),
+ },
+ },
+ },
+ });
+ clearDraft(this.autosaveKey);
+ } catch (error) {
+ updateDraft(this.autosaveKey, newText);
+ this.isEditing = true;
+ this.$emit('error', __('Something went wrong when updating a comment. Please try again'));
+ Sentry.captureException(error);
+ }
},
},
};
</script>
<template>
- <timeline-entry-item
- :id="noteAnchorId"
- :class="{ 'internal-note': note.internal }"
- :data-note-id="note.id"
- class="note note-wrapper note-comment"
- >
- <div class="timeline-avatar gl-float-left">
+ <timeline-entry-item :class="entryClass">
+ <div v-if="!isFirstNote" :key="note.id" class="timeline-avatar gl-float-left">
<gl-avatar-link :href="author.webUrl">
<gl-avatar
:src="author.avatarUrl"
@@ -46,14 +130,57 @@ export default {
/>
</gl-avatar-link>
</div>
-
- <div class="timeline-content">
+ <work-item-comment-form
+ v-if="isEditing"
+ :work-item-type="workItemType"
+ :aria-label="__('Edit comment')"
+ :autosave-key="autosaveKey"
+ :initial-value="note.body"
+ :comment-button-text="__('Save comment')"
+ :class="{ 'gl-pl-8': !isFirstNote }"
+ @cancelEditing="isEditing = false"
+ @submitForm="updateNote"
+ />
+ <div v-else class="timeline-content-inner" data-testid="note-wrapper">
<div class="note-header">
<note-header :author="author" :created-at="note.createdAt" :note-id="note.id" />
+ <note-actions
+ :show-reply="showReply"
+ :show-edit="hasAdminPermission"
+ @startReplying="showReplyForm"
+ @startEditing="startEditing"
+ />
+ <!-- v-if condition should be moved to "delete" dropdown item as soon as we implement copying the link -->
+ <gl-dropdown
+ v-if="hasAdminPermission"
+ v-gl-tooltip
+ icon="ellipsis_v"
+ text-sr-only
+ right
+ :text="$options.i18n.moreActionsText"
+ :title="$options.i18n.moreActionsText"
+ category="tertiary"
+ no-caret
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-testid="delete-note-action"
+ @click="$emit('deleteNote')"
+ >
+ {{ $options.i18n.deleteNoteText }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
<div class="timeline-discussion-body">
- <note-body :note="note" />
+ <note-body ref="noteBody" :note="note" />
</div>
+ <edited-at
+ v-if="note.lastEditedBy"
+ :updated-at="note.lastEditedAt"
+ :updated-by-name="lastEditedBy.name"
+ :updated-by-path="lastEditedBy.webPath"
+ :class="isFirstNote ? 'gl-pl-3' : 'gl-pl-8'"
+ />
</div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
new file mode 100644
index 00000000000..c17e855e527
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
+
+export default {
+ name: 'WorkItemNoteActions',
+ i18n: {
+ editButtonText: __('Edit comment'),
+ },
+ components: {
+ GlButton,
+ ReplyButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ showReply: {
+ type: Boolean,
+ required: true,
+ },
+ showEdit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="note-actions">
+ <reply-button v-if="showReply" ref="replyButton" @startReplying="$emit('startReplying')" />
+ <gl-button
+ v-if="showEdit"
+ v-gl-tooltip
+ data-testid="edit-work-item-note"
+ data-track-action="click_button"
+ data-track-label="edit_button"
+ category="tertiary"
+ icon="pencil"
+ :title="$options.i18n.editButtonText"
+ :aria-label="$options.i18n.editButtonText"
+ @click="$emit('startEditing')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
index dcee8750f81..95397b58925 100644
--- a/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_body.vue
@@ -3,6 +3,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
export default {
+ name: 'WorkItemNoteBody',
directives: {
SafeHtml,
},
@@ -12,12 +13,22 @@ export default {
required: true,
},
},
- mounted() {
- this.renderGFM();
+ watch: {
+ 'note.bodyHtml': {
+ immediate: true,
+ async handler(newVal, oldVal) {
+ if (newVal === oldVal) {
+ return;
+ }
+ await this.$nextTick();
+ this.renderGFM();
+ },
+ },
},
methods: {
renderGFM() {
renderGFM(this.$refs['note-body']);
+ gl?.lazyLoader?.searchLazyImages();
},
},
safeHtmlConfig: {
diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
new file mode 100644
index 00000000000..46f61ccd204
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_replying.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlAvatar } from '@gitlab/ui';
+import SafeHtml from '~/vue_shared/directives/safe_html';
+import NoteHeader from '~/notes/components/note_header.vue';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+
+export default {
+ name: 'WorkItemNoteReplying',
+ components: {
+ TimelineEntryItem,
+ GlAvatar,
+ NoteHeader,
+ },
+ directives: {
+ SafeHtml,
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
+ },
+ constantOptions: {
+ avatarUrl: window.gon.current_user_avatar_url,
+ },
+ props: {
+ body: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ author() {
+ return {
+ avatarUrl: window.gon.current_user_avatar_url,
+ id: window.gon.current_user_id,
+ name: window.gon.current_user_fullname,
+ username: window.gon.current_username,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <timeline-entry-item class="note note-wrapper note-comment gl-p-4 being-posted">
+ <div class="timeline-avatar gl-float-left">
+ <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
+ </div>
+ <div class="note-header">
+ <note-header :author="author" />
+ </div>
+ <div ref="note-body" v-safe-html:[$options.safeHtmlConfig]="body" class="note-body"></div>
+ </timeline-entry-item>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
index 3ef4a16bc57..3ef4a16bc57 100644
--- a/app/assets/javascripts/work_items/components/work_item_note_signed_out.vue
+++ b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue
diff --git a/app/assets/javascripts/work_items/components/widget_wrapper.vue b/app/assets/javascripts/work_items/components/widget_wrapper.vue
new file mode 100644
index 00000000000..355f17e970b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/widget_wrapper.vue
@@ -0,0 +1,80 @@
+<script>
+import { GlAlert, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ },
+ props: {
+ error: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
+ computed: {
+ toggleIcon() {
+ return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
+ },
+ toggleLabel() {
+ return this.isOpen ? __('Collapse') : __('Expand');
+ },
+ },
+ methods: {
+ hide() {
+ this.isOpen = false;
+ },
+ show() {
+ this.isOpen = true;
+ },
+ toggle() {
+ this.isOpen = !this.isOpen;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4">
+ <div
+ class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
+ :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
+ >
+ <div class="gl-display-flex gl-flex-grow-1">
+ <h5 class="gl-m-0 gl-line-height-24">
+ <slot name="header"></slot>
+ </h5>
+ <slot name="header-suffix"></slot>
+ </div>
+ <slot name="header-right"></slot>
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
+ <gl-button
+ category="tertiary"
+ size="small"
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ data-testid="widget-toggle"
+ @click="toggle"
+ />
+ </div>
+ </div>
+ <gl-alert v-if="error" variant="danger" @dismiss="$emit('dismissAlert')">
+ {{ error }}
+ </gl-alert>
+ <div
+ v-if="isOpen"
+ class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ :class="{ 'gl-p-5 gl-pb-3': !error }"
+ data-testid="widget-body"
+ >
+ <slot name="body"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index c2980405a19..fc4c05d96b2 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -313,6 +313,7 @@ export default {
:view-only="!canUpdate"
:allow-clear-all="isEditing"
class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
+ data-testid="work-item-assignees-input"
@input="handleAssigneesInput"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue
deleted file mode 100644
index 65042f1431d..00000000000
--- a/app/assets/javascripts/work_items/components/work_item_comment_form.vue
+++ /dev/null
@@ -1,228 +0,0 @@
-<script>
-import { GlAvatar, GlButton } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
-import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import { __, s__ } from '~/locale';
-import Tracking from '~/tracking';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
-import { getWorkItemQuery, getWorkItemNotesQuery } from '../utils';
-import createNoteMutation from '../graphql/create_work_item_note.mutation.graphql';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
-import WorkItemNoteSignedOut from './work_item_note_signed_out.vue';
-import WorkItemCommentLocked from './work_item_comment_locked.vue';
-
-export default {
- constantOptions: {
- markdownDocsPath: helpPagePath('user/markdown'),
- avatarUrl: window.gon.current_user_avatar_url,
- },
- components: {
- GlAvatar,
- GlButton,
- MarkdownEditor,
- WorkItemNoteSignedOut,
- WorkItemCommentLocked,
- },
- mixins: [glFeatureFlagMixin(), Tracking.mixin()],
- props: {
- workItemId: {
- type: String,
- required: true,
- },
- fullPath: {
- type: String,
- required: true,
- },
- fetchByIid: {
- type: Boolean,
- required: false,
- default: false,
- },
- queryVariables: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- workItem: {},
- isEditing: false,
- isSubmitting: false,
- isSubmittingWithKeydown: false,
- commentText: '',
- };
- },
- apollo: {
- workItem: {
- query() {
- return getWorkItemQuery(this.fetchByIid);
- },
- variables() {
- return this.queryVariables;
- },
- update(data) {
- return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
- },
- skip() {
- return !this.queryVariables.id && !this.queryVariables.iid;
- },
- error() {
- this.$emit('error', i18n.fetchError);
- },
- },
- },
- computed: {
- signedIn() {
- return Boolean(window.gon.current_user_id);
- },
- autosaveKey() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `${this.workItemId}-comment`;
- },
- canEdit() {
- // maybe this should use `NotePermissions.updateNote`, but if
- // we don't have any notes yet, that permission isn't on WorkItem
- return Boolean(this.workItem?.userPermissions?.updateWorkItem);
- },
- tracking() {
- return {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_comment',
- property: `type_${this.workItemType}`,
- };
- },
- workItemType() {
- return this.workItem?.workItemType?.name;
- },
- markdownPreviewPath() {
- return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
- this.workItemType
- }`;
- },
- isProjectArchived() {
- return this.workItem?.project?.archived;
- },
- },
- methods: {
- startEditing() {
- this.isEditing = true;
- this.commentText = getDraft(this.autosaveKey) || '';
- },
- async cancelEditing() {
- if (this.commentText) {
- const msg = s__('WorkItem|Are you sure you want to cancel editing?');
-
- const confirmed = await confirmAction(msg, {
- primaryBtnText: __('Discard changes'),
- cancelBtnText: __('Continue editing'),
- });
-
- if (!confirmed) {
- return;
- }
- }
-
- this.isEditing = false;
- clearDraft(this.autosaveKey);
- },
- async updateWorkItem(event = {}) {
- const { key } = event;
-
- if (key) {
- this.isSubmittingWithKeydown = true;
- }
-
- this.isSubmitting = true;
-
- try {
- this.track('add_work_item_comment');
-
- const {
- data: { createNote },
- } = await this.$apollo.mutate({
- mutation: createNoteMutation,
- variables: {
- input: {
- noteableId: this.workItem.id,
- body: this.commentText,
- },
- },
- });
-
- if (createNote.errors?.length) {
- throw new Error(createNote.errors[0]);
- }
-
- const client = this.$apollo.provider.defaultClient;
- client.refetchQueries({
- include: [getWorkItemNotesQuery(this.fetchByIid)],
- });
-
- this.isEditing = false;
- clearDraft(this.autosaveKey);
- } catch (error) {
- this.$emit('error', error.message);
- Sentry.captureException(error);
- }
-
- this.isSubmitting = false;
- },
- setCommentText(newText) {
- this.commentText = newText;
- updateDraft(this.autosaveKey, this.commentText);
- },
- },
-};
-</script>
-
-<template>
- <li class="timeline-entry">
- <work-item-note-signed-out v-if="!signedIn" />
- <work-item-comment-locked
- v-else-if="!canEdit"
- :work-item-type="workItemType"
- :is-project-archived="isProjectArchived"
- />
- <div v-else class="gl-display-flex gl-align-items-flex-start gl-flex-wrap-nowrap">
- <gl-avatar :src="$options.constantOptions.avatarUrl" :size="32" class="gl-mr-3" />
- <form v-if="isEditing" class="common-note-form gfm-form js-main-target-form gl-flex-grow-1">
- <markdown-editor
- class="gl-mb-3"
- :value="commentText"
- :render-markdown-path="markdownPreviewPath"
- :markdown-docs-path="$options.constantOptions.markdownDocsPath"
- :form-field-aria-label="__('Add a comment')"
- :form-field-placeholder="__('Write a comment or drag your files here…')"
- form-field-id="work-item-add-comment"
- form-field-name="work-item-add-comment"
- enable-autocomplete
- autofocus
- use-bottom-toolbar
- @input="setCommentText"
- @keydown.meta.enter="updateWorkItem"
- @keydown.ctrl.enter="updateWorkItem"
- @keydown.esc="cancelEditing"
- />
- <gl-button
- category="primary"
- variant="confirm"
- :loading="isSubmitting"
- @click="updateWorkItem"
- >{{ __('Comment') }}
- </gl-button>
- <gl-button category="tertiary" class="gl-ml-3" @click="cancelEditing"
- >{{ __('Cancel') }}
- </gl-button>
- </form>
- <gl-button
- v-else
- class="gl-flex-grow-1 gl-justify-content-start! gl-text-secondary!"
- @click="startEditing"
- >{{ __('Add a comment') }}</gl-button
- >
- </div>
- </li>
-</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
new file mode 100644
index 00000000000..d1a707f2a8a
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlAvatarLink, GlSprintf } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { getWorkItemQuery } from '../utils';
+
+export default {
+ components: {
+ GlAvatarLink,
+ GlSprintf,
+ TimeAgoTooltip,
+ },
+ props: {
+ fetchByIid: {
+ type: Boolean,
+ required: true,
+ },
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemIid: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ fullPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ createdAt() {
+ return this.workItem?.createdAt || '';
+ },
+ updatedAt() {
+ return this.workItem?.updatedAt || '';
+ },
+ author() {
+ return this.workItem?.author ?? {};
+ },
+ authorId() {
+ return getIdFromGraphQLId(this.author.id);
+ },
+ queryVariables() {
+ return this.fetchByIid
+ ? {
+ fullPath: this.fullPath,
+ iid: this.workItemIid,
+ }
+ : {
+ id: this.workItemId,
+ };
+ },
+ },
+ apollo: {
+ workItem: {
+ query() {
+ return getWorkItemQuery(this.fetchByIid);
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ skip() {
+ return !this.workItemId && !this.workItemIid;
+ },
+ update(data) {
+ const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
+ return workItem ?? {};
+ },
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mb-3">
+ <span data-testid="work-item-created">
+ <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')">
+ <template #timeAgo>
+ <time-ago-tooltip :time="createdAt" />
+ </template>
+ <template #author>
+ <gl-avatar-link
+ class="js-user-link gl-text-body gl-font-weight-bold"
+ :title="author.name"
+ :data-user-id="authorId"
+ :href="author.webUrl"
+ >
+ {{ author.name }}
+ </gl-avatar-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else-if="createdAt" :message="__('Created %{timeAgo}')">
+ <template #timeAgo>
+ <time-ago-tooltip :time="createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
+
+ <span
+ v-if="updatedAt"
+ class="gl-ml-5 gl-display-none gl-sm-display-inline-block"
+ data-testid="work-item-updated"
+ >
+ <gl-sprintf :message="__('Updated %{timeAgo}')">
+ <template #timeAgo>
+ <time-ago-tooltip :time="updatedAt" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 07da0279b41..399c220bc96 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -10,7 +10,7 @@ import Tracking from '~/tracking';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
-import { getWorkItemQuery } from '../utils';
+import { getWorkItemQuery, autocompleteDataSources, markdownPreviewPath } from '../utils';
import workItemDescriptionSubscription from '../graphql/work_item_description.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
@@ -46,7 +46,8 @@ export default {
required: true,
},
},
- markdownDocsPath: helpPagePath('user/markdown'),
+ markdownDocsPath: helpPagePath('user/project/quick_actions'),
+ quickActionsDocsPath: helpPagePath('user/project/quick_actions'),
data() {
return {
workItem: {},
@@ -56,6 +57,12 @@ export default {
descriptionText: '',
descriptionHtml: '',
conflictedDescription: '',
+ formFieldProps: {
+ 'aria-label': __('Description'),
+ placeholder: __('Write a comment or drag your files here…'),
+ id: 'work-item-description',
+ name: 'work-item-description',
+ },
};
},
apollo: {
@@ -134,9 +141,10 @@ export default {
return this.workItemDescription?.lastEditedBy?.webPath;
},
markdownPreviewPath() {
- return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
- this.workItemType
- }`;
+ return markdownPreviewPath(this.fullPath, this.workItem.iid);
+ },
+ autocompleteDataSources() {
+ return autocompleteDataSources(this.fullPath, this.workItem.iid);
},
},
methods: {
@@ -241,11 +249,11 @@ export default {
:value="descriptionText"
:render-markdown-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
- :form-field-aria-label="__('Description')"
- :form-field-placeholder="__('Write a comment or drag your files here…')"
- form-field-id="work-item-description"
- form-field-name="work-item-description"
+ :form-field-props="formFieldProps"
+ :quick-actions-docs-path="$options.quickActionsDocsPath"
+ :autocomplete-data-sources="autocompleteDataSources"
enable-autocomplete
+ supports-quick-actions
init-on-autofocus
use-bottom-toolbar
@input="setDescriptionText"
@@ -259,19 +267,19 @@ export default {
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
+ :quick-actions-docs-path="$options.quickActionsDocsPath"
+ :autocomplete-data-sources="autocompleteDataSources"
class="gl-px-3 bordered-box gl-mt-5"
>
<template #textarea>
<textarea
- id="work-item-description"
+ v-bind="formFieldProps"
ref="textarea"
v-model="descriptionText"
:disabled="isSubmitting"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
+ data-supports-quick-actions="true"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
@keydown.exact.esc.stop="cancelEditing"
diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
index d58983c013d..9a2cdc1c172 100644
--- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue
@@ -47,6 +47,7 @@ export default {
await this.$nextTick();
renderGFM(this.$refs['gfm-content']);
+ gl?.lazyLoader?.searchLazyImages();
if (this.canEdit) {
this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index ade954b2a7f..262c093a1d0 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -19,7 +19,7 @@ import { getParameterByName, updateHistory, setUrlParams } from '~/lib/utils/url
import { isPositiveInteger } from '~/lib/utils/number_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import {
sprintfWorkItem,
@@ -51,6 +51,7 @@ import WorkItemTree from './work_item_links/work_item_tree.vue';
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
+import WorkItemCreatedUpdated from './work_item_created_updated.vue';
import WorkItemDescription from './work_item_description.vue';
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
@@ -74,6 +75,7 @@ export default {
GlEmptyState,
WorkItemAssignees,
WorkItemActions,
+ WorkItemCreatedUpdated,
WorkItemDescription,
WorkItemDueDate,
WorkItemLabels,
@@ -123,8 +125,9 @@ export default {
workItem: {},
updateInProgress: false,
modalWorkItemId: isPositiveInteger(workItemId)
- ? convertToGraphQLId(TYPE_WORK_ITEM, workItemId)
+ ? convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId)
: null,
+ modalWorkItemIid: getParameterByName('work_item_iid'),
};
},
apollo: {
@@ -136,7 +139,7 @@ export default {
return this.queryVariables;
},
skip() {
- return !this.workItemId;
+ return !this.workItemId && !this.workItemIid;
},
update(data) {
const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem;
@@ -290,7 +293,10 @@ export default {
return this.isWidgetPresent(WIDGET_TYPE_NOTES);
},
fetchByIid() {
- return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
+ return (
+ (this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'))) ||
+ false
+ );
},
queryVariables() {
return this.fetchByIid
@@ -310,8 +316,8 @@ export default {
},
},
mounted() {
- if (this.modalWorkItemId) {
- this.openInModal(undefined, { id: this.modalWorkItemId });
+ if (this.modalWorkItemId || this.modalWorkItemIid) {
+ this.openInModal(undefined, { id: this.modalWorkItemId, iid: this.modalWorkItemIid });
}
},
methods: {
@@ -439,24 +445,33 @@ export default {
Sentry.captureException(error);
}
},
- updateUrl(modalWorkItemId) {
+ updateUrl(modalWorkItem) {
+ const params = this.fetchByIid
+ ? { work_item_iid: modalWorkItem?.iid }
+ : { work_item_id: getIdFromGraphQLId(modalWorkItem?.id) };
+
updateHistory({
- url: setUrlParams({ work_item_id: getIdFromGraphQLId(modalWorkItemId) }),
+ url: setUrlParams(params),
replace: true,
});
},
openInModal(event, modalWorkItem) {
+ if (!this.workItemsMvc2Enabled) {
+ return;
+ }
+
if (event) {
event.preventDefault();
- this.updateUrl(modalWorkItem.id);
+ this.updateUrl(modalWorkItem);
}
if (this.isModal) {
- this.$emit('update-modal', event, modalWorkItem.id);
+ this.$emit('update-modal', event, modalWorkItem);
return;
}
this.modalWorkItemId = modalWorkItem.id;
+ this.modalWorkItemIid = modalWorkItem.iid;
this.$refs.modal.show();
},
},
@@ -559,6 +574,12 @@ export default {
:can-update="canUpdate"
@error="updateError = $event"
/>
+ <work-item-created-updated
+ :work-item-id="workItem.id"
+ :work-item-iid="workItemIid"
+ :full-path="fullPath"
+ :fetch-by-iid="fetchByIid"
+ />
<work-item-state
:work-item="workItem"
:work-item-parent-id="workItemParentId"
@@ -696,6 +717,7 @@ export default {
v-if="!isModal"
ref="modal"
:work-item-id="modalWorkItemId"
+ :work-item-iid="modalWorkItemIid"
:show="true"
@close="updateUrl"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index faea80a9de8..1b8e97bf717 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -50,12 +50,16 @@ export default {
return {
error: undefined,
updatedWorkItemId: null,
+ updatedWorkItemIid: null,
};
},
computed: {
displayedWorkItemId() {
return this.updatedWorkItemId || this.workItemId;
},
+ displayedWorkItemIid() {
+ return this.updatedWorkItemIid || this.workItemIid;
+ },
},
methods: {
deleteWorkItem() {
@@ -122,6 +126,7 @@ export default {
},
closeModal() {
this.updatedWorkItemId = null;
+ this.updatedWorkItemIid = null;
this.error = '';
this.$emit('close');
},
@@ -134,9 +139,10 @@ export default {
show() {
this.$refs.modal.show();
},
- updateModal($event, workItemId) {
- this.updatedWorkItemId = workItemId;
- this.$emit('update-modal', $event, workItemId);
+ updateModal($event, workItem) {
+ this.updatedWorkItemId = workItem.id;
+ this.updatedWorkItemIid = workItem.iid;
+ this.$emit('update-modal', $event, workItem);
},
},
};
@@ -150,6 +156,7 @@ export default {
modal-id="work-item-detail-modal"
header-class="gl-p-0 gl-pb-2!"
scrollable
+ data-testid="work-item-detail-modal"
@hide="closeModal"
>
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
@@ -160,7 +167,7 @@ export default {
is-modal
:work-item-parent-id="issueGid"
:work-item-id="displayedWorkItemId"
- :work-item-iid="workItemIid"
+ :work-item-iid="displayedWorkItemIid"
class="gl-p-5 gl-mt-n3 gl-reset-bg gl-isolate"
@close="hide"
@deleteWorkItem="deleteWorkItem"
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
index 9ee302855c7..03c5b7096b2 100644
--- a/app/assets/javascripts/work_items/components/work_item_due_date.vue
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -215,6 +215,7 @@ export default {
ref="startDatePicker"
v-model="dirtyStartDate"
container="body"
+ data-testid="work-item-start-date-picker"
:disabled="isDatepickerDisabled"
:input-id="$options.startDateInputId"
show-clear-button
@@ -240,6 +241,7 @@ export default {
ref="dueDatePicker"
v-model="dirtyDueDate"
container="body"
+ data-testid="work-item-due-date-picker"
:disabled="isDatepickerDisabled"
:input-id="$options.dueDateInputId"
:min-date="dirtyStartDate"
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 45fb0f7f21a..8e9e1def0b9 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -19,7 +19,13 @@ import {
} from '../constants';
function isTokenSelectorElement(el) {
- return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item');
+ return (
+ el?.classList.contains('gl-label-close') ||
+ el?.classList.contains('dropdown-item') ||
+ // TODO: replace this logic when we have a class added to clear-all button in GitLab UI
+ (el?.classList.contains('gl-button') &&
+ el?.closest('.form-control')?.classList.contains('gl-token-selector'))
+ );
}
function addClass(el) {
@@ -146,7 +152,17 @@ export default {
watch: {
labels(newVal) {
if (!this.isEditing) {
- this.localLabels = newVal.map(addClass);
+ // remove labels that aren't in list from server
+ this.localLabels = this.localLabels.filter((label) =>
+ newVal.find((l) => l.id === label.id),
+ );
+
+ // add any that we don't have to the end
+ const labelsToAdd = newVal
+ .map(addClass)
+ .filter((label) => !this.localLabels.find((l) => l.id === label.id));
+
+ this.localLabels = this.localLabels.concat(labelsToAdd);
}
},
},
@@ -163,10 +179,11 @@ export default {
this.setLabels();
},
async setLabels() {
- if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return;
-
this.searchKey = '';
this.isEditing = false;
+
+ if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return;
+
try {
const {
data: {
@@ -214,18 +231,23 @@ export default {
this.searchStarted = true;
},
async focusTokenSelector(labels) {
- const labelsToAdd = without(labels, ...this.localLabels).map((label) => label.id);
- const labelsToRemove = without(this.localLabels, ...labels).map((label) => label.id);
+ const labelsToAdd = without(labels, ...this.localLabels);
+ const labelIdsToAdd = labelsToAdd.map((label) => label.id);
+ const labelIdsToRemove = without(this.localLabels, ...labels).map((label) => label.id);
- if (labelsToAdd.length > 0) {
- this.addLabelIds.push(...labelsToAdd);
+ if (labelIdsToAdd.length > 0) {
+ this.addLabelIds.push(...labelIdsToAdd);
}
- if (labelsToRemove.length > 0) {
- this.removeLabelIds.push(...labelsToRemove);
+ if (labelIdsToRemove.length > 0) {
+ this.removeLabelIds.push(...labelIdsToRemove);
}
- this.localLabels = labels;
+ if (labels.length === 0) {
+ this.localLabels = [];
+ } else {
+ this.localLabels = this.localLabels.concat(labelsToAdd);
+ }
this.handleFocus();
await this.$nextTick();
@@ -265,7 +287,9 @@ export default {
:dropdown-items="searchLabels"
:loading="isLoading"
:view-only="!canUpdate"
+ :allow-clear-all="isEditing"
class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ data-testid="work-item-labels-input"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
@input="focusTokenSelector"
@text-input="debouncedSearchKeyUpdate"
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index b078711ec5d..e8578a6d49a 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -1,46 +1,39 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlAlert,
- GlLoadingIcon,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { produce } from 'immer';
+import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql';
import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils';
-import { setUrlParams, updateHistory, getParameterByName } from '~/lib/utils/url_utility';
+import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import {
FORM_TYPES,
WIDGET_ICONS,
- WORK_ITEM_STATUS_TEXT,
WIDGET_TYPE_HIERARCHY,
+ WORK_ITEM_STATUS_TEXT,
} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import addHierarchyChildMutation from '../../graphql/add_hierarchy_child.mutation.graphql';
+import removeHierarchyChildMutation from '../../graphql/remove_hierarchy_child.mutation.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
import workItemQuery from '../../graphql/work_item.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import WidgetWrapper from '../widget_wrapper.vue';
import WorkItemDetailModal from '../work_item_detail_modal.vue';
import WorkItemLinkChild from './work_item_link_child.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
export default {
components: {
- GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
- GlAlert,
GlLoadingIcon,
+ WidgetWrapper,
WorkItemLinkChild,
WorkItemLinksForm,
WorkItemDetailModal,
@@ -105,13 +98,13 @@ export default {
data() {
return {
isShownAddForm: false,
- isOpen: true,
activeChild: {},
activeToast: null,
prefetchedWorkItem: null,
error: undefined,
parentIssue: null,
formType: null,
+ workItem: null,
};
},
computed: {
@@ -137,14 +130,8 @@ export default {
isChildrenEmpty() {
return this.children?.length === 0;
},
- toggleIcon() {
- return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
- },
- toggleLabel() {
- return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks');
- },
issuableGid() {
- return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null;
+ return this.issuableId ? convertToGraphQLId(TYPENAME_WORK_ITEM, this.issuableId) : null;
},
isLoading() {
return this.$apollo.queries.workItem.loading;
@@ -168,7 +155,7 @@ export default {
} else {
const workItemId = getParameterByName('work_item_id');
if (workItemId) {
- params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId);
+ params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId);
}
}
return params;
@@ -180,11 +167,8 @@ export default {
}
},
methods: {
- toggle() {
- this.isOpen = !this.isOpen;
- },
showAddForm(formType) {
- this.isOpen = true;
+ this.$refs.wrapper.show();
this.isShownAddForm = true;
this.formType = formType;
this.$nextTick(() => {
@@ -194,10 +178,6 @@ export default {
hideAddForm() {
this.isShownAddForm = false;
},
- addChild(child) {
- const { defaultClient: client } = this.$apollo.provider.clients;
- this.toggleChildFromCache(child, child.id, client);
- },
openChild(child, e) {
if (isMetaKey(e)) {
return;
@@ -211,9 +191,8 @@ export default {
this.activeChild = {};
this.updateWorkItemIdUrlQuery();
},
- handleWorkItemDeleted(childId) {
- const { defaultClient: client } = this.$apollo.provider.clients;
- this.toggleChildFromCache(null, childId, client);
+ handleWorkItemDeleted(child) {
+ this.removeHierarchyChild(child);
this.activeToast = this.$toast.show(s__('WorkItem|Task deleted'));
},
updateWorkItemIdUrlQuery({ id, iid } = {}) {
@@ -222,38 +201,31 @@ export default {
: { work_item_id: getIdFromGraphQLId(id) };
updateHistory({ url: setUrlParams(params), replace: true });
},
- toggleChildFromCache(workItem, childId, store) {
- const sourceData = store.readQuery({
- query: getWorkItemLinksQuery,
- variables: { id: this.issuableGid },
- });
-
- const newData = produce(sourceData, (draftState) => {
- const widgetHierarchy = draftState.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
-
- const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
-
- if (index >= 0) {
- widgetHierarchy.children.nodes.splice(index, 1);
- } else {
- widgetHierarchy.children.nodes.push(workItem);
- }
+ async addHierarchyChild(workItem) {
+ return this.$apollo.mutate({
+ mutation: addHierarchyChildMutation,
+ variables: { id: this.issuableGid, workItem },
});
-
- store.writeQuery({
- query: getWorkItemLinksQuery,
- variables: { id: this.issuableGid },
- data: newData,
+ },
+ async removeHierarchyChild(workItem) {
+ return this.$apollo.mutate({
+ mutation: removeHierarchyChildMutation,
+ variables: { id: this.issuableGid, workItem },
});
},
async updateWorkItem(workItem, childId, parentId) {
- return this.$apollo.mutate({
+ const response = await this.$apollo.mutate({
mutation: updateWorkItemMutation,
variables: { input: { id: childId, hierarchyWidget: { parentId } } },
- update: (store) => this.toggleChildFromCache(workItem, childId, store),
});
+
+ if (parentId === null) {
+ await this.removeHierarchyChild(workItem);
+ } else {
+ await this.addHierarchyChild(workItem);
+ }
+
+ return response;
},
async undoChildRemoval(workItem, childId) {
const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid);
@@ -263,7 +235,7 @@ export default {
}
},
async removeChild(childId) {
- const { data } = await this.updateWorkItem(null, childId, null);
+ const { data } = await this.updateWorkItem({ id: childId }, childId, null);
if (data.workItemUpdate.errors.length === 0) {
this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
@@ -323,24 +295,23 @@ export default {
</script>
<template>
- <div
- class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"
+ <widget-wrapper
+ ref="wrapper"
+ :error="error"
data-testid="work-item-links"
+ @dismissAlert="error = undefined"
>
- <div
- class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
- :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
- >
- <div class="gl-display-flex gl-flex-grow-1">
- <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5>
- <span
- class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3"
- data-testid="children-count"
- >
- <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" />
- {{ childrenCountLabel }}
- </span>
- </div>
+ <template #header>{{ $options.i18n.title }}</template>
+ <template #header-suffix>
+ <span
+ class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3"
+ data-testid="children-count"
+ >
+ <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-secondary" />
+ {{ childrenCountLabel }}
+ </span>
+ </template>
+ <template #header-right>
<gl-dropdown
v-if="canUpdate"
right
@@ -361,26 +332,8 @@ export default {
{{ $options.i18n.addChildOptionLabel }}
</gl-dropdown-item>
</gl-dropdown>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
- <gl-button
- category="tertiary"
- size="small"
- :icon="toggleIcon"
- :aria-label="toggleLabel"
- data-testid="toggle-links"
- @click="toggle"
- />
- </div>
- </div>
- <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined">
- {{ error }}
- </gl-alert>
- <div
- v-if="isOpen"
- class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
- :class="{ 'gl-p-5 gl-pb-3': !error }"
- data-testid="links-body"
- >
+ </template>
+ <template #body>
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
<template v-else>
@@ -401,7 +354,7 @@ export default {
:form-type="formType"
:parent-work-item-type="workItem.workItemType.name"
@cancel="hideAddForm"
- @addWorkItemChild="addChild"
+ @addWorkItemChild="addHierarchyChild"
/>
<work-item-link-child
v-for="child in children"
@@ -420,9 +373,9 @@ export default {
:work-item-id="activeChild.id"
:work-item-iid="activeChild.iid"
@close="closeModal"
- @workItemDeleted="handleWorkItemDeleted(activeChild.id)"
+ @workItemDeleted="handleWorkItemDeleted(activeChild)"
/>
</template>
- </div>
- </div>
+ </template>
+ </widget-wrapper>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index d79aaab38f2..5169a77dd33 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -13,7 +13,6 @@ import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
@@ -42,7 +41,6 @@ export default {
GlFormCheckbox,
GlTooltip,
},
- mixins: [glFeatureFlagMixin()],
inject: ['projectPath', 'hasIterationsFeature'],
props: {
issuableGid: {
@@ -161,12 +159,6 @@ export default {
return workItemInput;
},
- workItemsMvcEnabled() {
- return this.glFeatures.workItemsMvc;
- },
- workItemsMvc2Enabled() {
- return this.glFeatures.workItemsMvc2;
- },
isCreateForm() {
return this.formType === FORM_TYPES.create;
},
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
index 81e2bb76900..aa12df424f1 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue
@@ -1,9 +1,7 @@
<script>
-import { GlButton } from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import { __ } from '~/locale';
import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
@@ -19,6 +17,7 @@ import {
} from '../../constants';
import workItemQuery from '../../graphql/work_item.query.graphql';
import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql';
+import WidgetWrapper from '../widget_wrapper.vue';
import OkrActionsSplitButton from './okr_actions_split_button.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
import WorkItemLinkChild from './work_item_link_child.vue';
@@ -29,8 +28,8 @@ export default {
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
components: {
- GlButton,
OkrActionsSplitButton,
+ WidgetWrapper,
WorkItemLinksForm,
WorkItemLinkChild,
},
@@ -72,20 +71,12 @@ export default {
data() {
return {
isShownAddForm: false,
- isOpen: true,
- error: null,
formType: null,
childType: null,
prefetchedWorkItem: null,
};
},
computed: {
- toggleIcon() {
- return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
- },
- toggleLabel() {
- return this.isOpen ? __('Collapse') : __('Expand');
- },
fetchByIid() {
return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path'));
},
@@ -109,7 +100,7 @@ export default {
} else {
const workItemId = getParameterByName('work_item_id');
if (workItemId) {
- params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId);
+ params.id = convertToGraphQLId(TYPENAME_WORK_ITEM, workItemId);
}
}
return params;
@@ -121,11 +112,8 @@ export default {
}
},
methods: {
- toggle() {
- this.isOpen = !this.isOpen;
- },
showAddForm(formType, childType) {
- this.isOpen = true;
+ this.$refs.wrapper.show();
this.isShownAddForm = true;
this.formType = formType;
this.childType = childType;
@@ -176,19 +164,11 @@ export default {
</script>
<template>
- <div
- class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4"
- data-testid="work-item-tree"
- >
- <div
- class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
- :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
- >
- <div class="gl-display-flex gl-flex-grow-1">
- <h5 class="gl-m-0 gl-line-height-24">
- {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }}
- </h5>
- </div>
+ <widget-wrapper ref="wrapper" data-testid="work-item-tree">
+ <template #header>
+ {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }}
+ </template>
+ <template #header-right>
<okr-actions-split-button
@showCreateObjectiveForm="
showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE)
@@ -203,24 +183,9 @@ export default {
showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT)
"
/>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
- <gl-button
- category="tertiary"
- size="small"
- :icon="toggleIcon"
- :aria-label="toggleLabel"
- data-testid="toggle-tree"
- @click="toggle"
- />
- </div>
- </div>
- <div
- v-if="isOpen"
- class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
- :class="{ 'gl-p-5 gl-pb-3': !error }"
- data-testid="tree-body"
- >
- <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty">
+ </template>
+ <template #body>
+ <div v-if="!isShownAddForm && children.length === 0" data-testid="tree-empty">
<p class="gl-mb-3">
{{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }}
</p>
@@ -253,6 +218,6 @@ export default {
@removeChild="$emit('removeChild', $event)"
@click="$emit('show-modal', $event, $event.childItem || child)"
/>
- </div>
- </div>
+ </template>
+ </widget-wrapper>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue
index a59767d8b70..02b94c5331c 100644
--- a/app/assets/javascripts/work_items/components/work_item_notes.vue
+++ b/app/assets/javascripts/work_items/components/work_item_notes.vue
@@ -1,13 +1,16 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { GlSkeletonLoader, GlModal } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { s__, __ } from '~/locale';
+import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants';
import SystemNote from '~/work_items/components/notes/system_note.vue';
import ActivityFilter from '~/work_items/components/notes/activity_filter.vue';
import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants';
import { ASC, DESC } from '~/notes/constants';
import { getWorkItemNotesQuery } from '~/work_items/utils';
-import WorkItemNote from '~/work_items/components/notes/work_item_note.vue';
-import WorkItemCommentForm from './work_item_comment_form.vue';
+import WorkItemDiscussion from '~/work_items/components/notes/work_item_discussion.vue';
+import deleteNoteMutation from '../graphql/notes/delete_work_item_notes.mutation.graphql';
+import WorkItemAddNote from './notes/work_item_add_note.vue';
export default {
i18n: {
@@ -20,10 +23,11 @@ export default {
},
components: {
GlSkeletonLoader,
+ GlModal,
ActivityFilter,
SystemNote,
- WorkItemCommentForm,
- WorkItemNote,
+ WorkItemAddNote,
+ WorkItemDiscussion,
},
props: {
workItemId: {
@@ -50,38 +54,52 @@ export default {
},
data() {
return {
- notesArray: [],
isLoadingMore: false,
perPage: DEFAULT_PAGE_SIZE_NOTES,
sortOrder: ASC,
- changeNotesSortOrderAfterLoading: false,
+ noteToDelete: null,
};
},
computed: {
initialLoading() {
return this.$apollo.queries.workItemNotes.loading && !this.isLoadingMore;
},
- pageInfo() {
- return this.workItemNotes?.pageInfo;
- },
avatarUrl() {
return window.gon.current_user_avatar_url;
},
+ pageInfo() {
+ return this.workItemNotes?.pageInfo;
+ },
hasNextPage() {
return this.pageInfo?.hasNextPage;
},
- showInitialLoader() {
- return this.initialLoading || this.changeNotesSortOrderAfterLoading;
- },
- showTimeline() {
- return !this.changeNotesSortOrderAfterLoading;
- },
showLoadingMoreSkeleton() {
return this.isLoadingMore && !this.changeNotesSortOrderAfterLoading;
},
disableActivityFilter() {
return this.initialLoading || this.isLoadingMore;
},
+ formAtTop() {
+ return this.sortOrder === DESC;
+ },
+ workItemCommentFormProps() {
+ return {
+ queryVariables: this.queryVariables,
+ fullPath: this.fullPath,
+ workItemId: this.workItemId,
+ fetchByIid: this.fetchByIid,
+ workItemType: this.workItemType,
+ sortOrder: this.sortOrder,
+ };
+ },
+ notesArray() {
+ const notes = this.workItemNotes?.nodes || [];
+
+ if (this.sortOrder === DESC) {
+ return [...notes].reverse();
+ }
+ return notes;
+ },
},
apollo: {
workItemNotes: {
@@ -104,8 +122,6 @@ export default {
: data.workItem?.widgets;
const discussionNodes =
workItemWidgets.find((widget) => widget.type === 'NOTES')?.discussions || [];
- this.notesArray = discussionNodes?.nodes || [];
- this.updateSortingOrderIfApplicable();
return discussionNodes;
},
skip() {
@@ -115,6 +131,8 @@ export default {
this.$emit('error', i18n.fetchError);
},
result() {
+ this.updateSortingOrderIfApplicable();
+
if (this.hasNextPage) {
this.fetchMoreNotes();
}
@@ -122,6 +140,11 @@ export default {
},
},
methods: {
+ getDiscussionKey(discussion) {
+ // discussion key is important like this since after first comment changes
+ const discussionId = discussion.notes.nodes[0].id;
+ return discussionId.split('/')[discussionId.split('/').length - 1];
+ },
isSystemNote(note) {
return note.notes.nodes[0].system;
},
@@ -136,17 +159,8 @@ export default {
this.changeNotesSortOrder(DESC);
}
},
- updateInitialSortedOrder(direction) {
- this.sortOrder = direction;
- // when the direction is reverse , we need to load all since the sorting is on the frontend
- if (direction === DESC) {
- this.changeNotesSortOrderAfterLoading = true;
- }
- },
changeNotesSortOrder(direction) {
this.sortOrder = direction;
- this.notesArray = [...this.notesArray].reverse();
- this.changeNotesSortOrderAfterLoading = false;
},
async fetchMoreNotes() {
this.isLoadingMore = true;
@@ -163,8 +177,44 @@ export default {
})
.catch((error) => this.$emit('error', error.message));
this.isLoadingMore = false;
- if (this.changeNotesSortOrderAfterLoading && !this.hasNextPage) {
- this.changeNotesSortOrder(this.sortOrder);
+ },
+ showDeleteNoteModal(note, discussion) {
+ const isLastNote = discussion.notes.nodes.length === 1;
+ this.$refs.deleteNoteModal.show();
+ this.noteToDelete = { ...note, isLastNote };
+ },
+ cancelDeletingNote() {
+ this.noteToDelete = null;
+ },
+ async deleteNote() {
+ try {
+ const { id, isLastNote, discussion } = this.noteToDelete;
+ await this.$apollo.mutate({
+ mutation: deleteNoteMutation,
+ variables: {
+ input: {
+ id,
+ },
+ },
+ update(cache) {
+ const deletedObject = isLastNote
+ ? { __typename: TYPENAME_DISCUSSION, id: discussion.id }
+ : { __typename: TYPENAME_NOTE, id };
+ cache.modify({
+ id: cache.identify(deletedObject),
+ fields: (_, { DELETE }) => DELETE,
+ });
+ },
+ optimisticResponse: {
+ destroyNote: {
+ note: null,
+ __typename: 'DestroyNotePayload',
+ },
+ },
+ });
+ } catch (error) {
+ this.$emit('error', __('Something went wrong when deleting a comment. Please try again'));
+ Sentry.captureException(error);
}
},
},
@@ -172,7 +222,7 @@ export default {
</script>
<template>
- <div class="gl-border-t gl-mt-5">
+ <div class="gl-border-t gl-mt-5 work-item-notes">
<div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap">
<label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label>
<activity-filter
@@ -181,10 +231,10 @@ export default {
:sort-order="sortOrder"
:work-item-type="workItemType"
@changeSortOrder="changeNotesSortOrder"
- @updateSavedSortOrder="updateInitialSortedOrder"
+ @updateSavedSortOrder="changeNotesSortOrder"
/>
</div>
- <div v-if="showInitialLoader" class="gl-mt-5">
+ <div v-if="initialLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
@@ -197,22 +247,38 @@ export default {
</gl-skeleton-loader>
</div>
<div v-else class="issuable-discussion gl-mb-5 gl-clearfix!">
- <template v-if="showTimeline">
+ <template v-if="!initialLoading">
<ul class="notes main-notes-list timeline gl-clearfix!">
- <template v-for="note in notesArray">
+ <work-item-add-note
+ v-if="formAtTop"
+ v-bind="workItemCommentFormProps"
+ @error="$emit('error', $event)"
+ />
+
+ <template v-for="discussion in notesArray">
<system-note
- v-if="isSystemNote(note)"
- :key="note.notes.nodes[0].id"
- :note="note.notes.nodes[0]"
+ v-if="isSystemNote(discussion)"
+ :key="discussion.notes.nodes[0].id"
+ :note="discussion.notes.nodes[0]"
/>
- <work-item-note v-else :key="note.notes.nodes[0].id" :note="note.notes.nodes[0]" />
+ <template v-else>
+ <work-item-discussion
+ :key="getDiscussionKey(discussion)"
+ :discussion="discussion.notes.nodes"
+ :query-variables="queryVariables"
+ :full-path="fullPath"
+ :work-item-id="workItemId"
+ :fetch-by-iid="fetchByIid"
+ :work-item-type="workItemType"
+ @deleteNote="showDeleteNoteModal($event, discussion)"
+ @error="$emit('error', $event)"
+ />
+ </template>
</template>
- <work-item-comment-form
- :query-variables="queryVariables"
- :full-path="fullPath"
- :work-item-id="workItemId"
- :fetch-by-iid="fetchByIid"
+ <work-item-add-note
+ v-if="!formAtTop"
+ v-bind="workItemCommentFormProps"
@error="$emit('error', $event)"
/>
</ul>
@@ -231,5 +297,17 @@ export default {
</gl-skeleton-loader>
</template>
</div>
+ <gl-modal
+ ref="deleteNoteModal"
+ modal-id="delete-note-modal"
+ :title="__('Delete comment?')"
+ :ok-title="__('Delete comment')"
+ ok-variant="danger"
+ size="sm"
+ @primary="deleteNote"
+ @canceled="cancelDeletingNote"
+ >
+ {{ __('Are you sure you want to delete this comment?') }}
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql
new file mode 100644
index 00000000000..30a5d2388b1
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/add_hierarchy_child.mutation.graphql
@@ -0,0 +1,3 @@
+mutation addHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) {
+ addHierarchyChild(id: $id, workItem: $workItem) @client
+}
diff --git a/app/assets/javascripts/work_items/graphql/cache_utils.js b/app/assets/javascripts/work_items/graphql/cache_utils.js
new file mode 100644
index 00000000000..16b892b3476
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/cache_utils.js
@@ -0,0 +1,62 @@
+import { produce } from 'immer';
+import { WIDGET_TYPE_NOTES } from '~/work_items/constants';
+import { getWorkItemNotesQuery } from '~/work_items/utils';
+
+/**
+ * Updates the cache manually when adding a main comment
+ *
+ * @param store
+ * @param createNoteData
+ * @param fetchByIid
+ * @param queryVariables
+ * @param sortOrder
+ */
+export const updateCommentState = (store, { data: { createNote } }, fetchByIid, queryVariables) => {
+ const notesQuery = getWorkItemNotesQuery(fetchByIid);
+ const variables = {
+ ...queryVariables,
+ pageSize: 100,
+ };
+ const sourceData = store.readQuery({
+ query: notesQuery,
+ variables,
+ });
+
+ const finalData = produce(sourceData, (draftData) => {
+ const notesWidget = fetchByIid
+ ? draftData.workspace.workItems.nodes[0].widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_NOTES,
+ )
+ : draftData.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_NOTES);
+
+ // as notes are currently sorted/reversed on the frontend rather than in the query
+ // we only ever push.
+ // const arrayPushMethod = sortOrder === ASC ? 'push' : 'unshift';
+ const arrayPushMethod = 'push';
+
+ // manual update of cache with a completely new discussion
+ if (createNote.note.discussion.notes.nodes.length === 1) {
+ notesWidget.discussions.nodes[arrayPushMethod]({
+ id: createNote.note.discussion.id,
+ notes: {
+ nodes: createNote.note.discussion.notes.nodes,
+ __typename: 'NoteConnection',
+ },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Discussion',
+ });
+ }
+
+ if (fetchByIid) {
+ draftData.workspace.workItems.nodes[0].widgets[6] = notesWidget;
+ } else {
+ draftData.workItem.widgets[6] = notesWidget;
+ }
+ });
+
+ store.writeQuery({
+ query: notesQuery,
+ variables,
+ data: finalData,
+ });
+};
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql
deleted file mode 100644
index 6a7afd7bd5b..00000000000
--- a/app/assets/javascripts/work_items/graphql/create_work_item_note.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation createWorkItemNote($input: CreateNoteInput!) {
- createNote(input: $input) {
- errors
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql
new file mode 100644
index 00000000000..5050aa7cbda
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/create_work_item_note.mutation.graphql
@@ -0,0 +1,18 @@
+#import "./work_item_note.fragment.graphql"
+
+mutation createWorkItemNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ note {
+ id
+ discussion {
+ id
+ notes {
+ nodes {
+ ...WorkItemNote
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql
new file mode 100644
index 00000000000..592b5c2a991
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/delete_work_item_notes.mutation.graphql
@@ -0,0 +1,7 @@
+mutation deleteWorkItemNote($input: DestroyNoteInput!) {
+ destroyNote(input: $input) {
+ note {
+ id
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql
new file mode 100644
index 00000000000..3da8e7677e4
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/update_work_item_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./work_item_note.fragment.graphql"
+
+mutation updateWorkItemNote($input: UpdateNoteInput!) {
+ updateNote(input: $input) {
+ note {
+ ...WorkItemNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql
new file mode 100644
index 00000000000..58561e33e53
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_discussion_note.fragment.graphql
@@ -0,0 +1,25 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "./work_item_note.fragment.graphql"
+
+fragment WorkItemDiscussionNote on Note {
+ id
+ bodyHtml
+ system
+ internal
+ systemNoteIconName
+ createdAt
+ author {
+ ...User
+ }
+ userPermissions {
+ adminNote
+ }
+ discussion {
+ id
+ notes {
+ nodes {
+ ...WorkItemNote
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
index 5215ea10918..52a7a1f8e23 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_note.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note.fragment.graphql
@@ -2,15 +2,29 @@
fragment WorkItemNote on Note {
id
+ body
bodyHtml
system
internal
systemNoteIconName
createdAt
+ lastEditedAt
+ lastEditedBy {
+ ...User
+ webPath
+ }
+ discussion {
+ id
+ }
author {
...User
}
userPermissions {
adminNote
+ awardEmoji
+ readNote
+ createNote
+ resolveNote
+ repositionNote
}
}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql
new file mode 100644
index 00000000000..739f2101b5e
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_created.subscription.graphql
@@ -0,0 +1,7 @@
+#import "./work_item_discussion_note.fragment.graphql"
+
+subscription workItemNoteCreated($noteableId: NoteableID) {
+ workItemNoteCreated(noteableId: $noteableId) {
+ ...WorkItemDiscussionNote
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql
new file mode 100644
index 00000000000..6a59becdb99
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_deleted.subscription.graphql
@@ -0,0 +1,7 @@
+subscription workItemNoteDeleted($noteableId: NoteableID) {
+ workItemNoteDeleted(noteableId: $noteableId) {
+ id
+ discussionId
+ lastDiscussionNote
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql
new file mode 100644
index 00000000000..c68d5f491cf
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_note_updated.subscription.graphql
@@ -0,0 +1,7 @@
+#import "./work_item_note.fragment.graphql"
+
+subscription workItemNoteUpdated($noteableId: NoteableID) {
+ workItemNoteUpdated(noteableId: $noteableId) {
+ ...WorkItemNote
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql
index 9ea9cecc81a..56dc175109f 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/work_items/graphql/work_item_note.fragment.graphql"
+#import "./work_item_note.fragment.graphql"
query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) {
workItem(id: $id) {
diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql
index f401aa5595e..6b37c68cb43 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/notes/work_item_notes_by_iid.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "~/work_items/graphql/work_item_note.fragment.graphql"
+#import "./work_item_note.fragment.graphql"
query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) {
workspace: project(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql
new file mode 100644
index 00000000000..3fece06eefa
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/remove_hierarchy_child.mutation.graphql
@@ -0,0 +1,3 @@
+mutation removeHierarchyChild($id: WorkItemID!, $workItem: WorkItem!) {
+ removeHierarchyChild(id: $id, workItem: $workItem) @client
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index 3ee263c149d..ada9f737e6e 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -1,4 +1,5 @@
#import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
fragment WorkItem on WorkItem {
id
@@ -8,12 +9,16 @@ fragment WorkItem on WorkItem {
description
confidential
createdAt
+ updatedAt
closedAt
project {
id
fullPath
archived
}
+ author {
+ ...Author
+ }
workItemType {
id
name
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index b7813ca4dc6..b5d27231bef 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -3,6 +3,15 @@
#import "~/work_items/graphql/milestone.fragment.graphql"
fragment WorkItemMetadataWidgets on WorkItemWidget {
+ ... on WorkItemWidgetDescription {
+ type
+ }
+ ... on WorkItemWidgetStartAndDueDate {
+ type
+ }
+ ... on WorkItemWidgetNotes {
+ type
+ }
... on WorkItemWidgetMilestone {
type
milestone {
@@ -11,6 +20,8 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
... on WorkItemWidgetAssignees {
type
+ allowsMultipleAssignees
+ canInviteMembers
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index d2a2d7927d3..bf8eafe3211 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -55,6 +55,7 @@ fragment WorkItemWidgets on WorkItemWidget {
children {
nodes {
id
+ iid
confidential
workItemType {
id
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 98b59449af7..6aa63aae172 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -1,9 +1,12 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { parseBoolean } from '~/lib/utils/common_utils';
import { apolloProvider } from '~/graphql_shared/issuable_client';
import App from './components/app.vue';
import { createRouter } from './router';
+Vue.use(VueApollo);
+
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
const {
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index d04d4942253..4f8c720eb1f 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert } from '@gitlab/ui';
-import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { TYPENAME_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
import ZenMode from '~/zen_mode';
@@ -31,7 +31,7 @@ export default {
},
computed: {
gid() {
- return convertToGraphQLId(TYPE_WORK_ITEM, this.id);
+ return convertToGraphQLId(TYPENAME_WORK_ITEM, this.id);
},
},
mounted() {
diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js
index e58fd19ea31..f2af87d476c 100644
--- a/app/assets/javascripts/work_items/utils.js
+++ b/app/assets/javascripts/work_items/utils.js
@@ -1,7 +1,8 @@
+import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants';
import workItemQuery from './graphql/work_item.query.graphql';
import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql';
-import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql';
-import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql';
+import workItemNotesIdQuery from './graphql/notes/work_item_notes.query.graphql';
+import workItemNotesByIidQuery from './graphql/notes/work_item_notes_by_iid.query.graphql';
export function getWorkItemQuery(isFetchedByIid) {
return isFetchedByIid ? workItemByIidQuery : workItemQuery;
@@ -10,3 +11,23 @@ export function getWorkItemQuery(isFetchedByIid) {
export function getWorkItemNotesQuery(isFetchedByIid) {
return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery;
}
+
+export const findHierarchyWidgetChildren = (workItem) =>
+ workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY).children.nodes;
+
+const autocompleteSourcesPath = (autocompleteType, fullPath, workItemIid) => {
+ return `${
+ gon.relative_url_root || ''
+ }/${fullPath}/-/autocomplete_sources/${autocompleteType}?type=WorkItem&type_id=${workItemIid}`;
+};
+
+export const autocompleteDataSources = (fullPath, iid) => ({
+ labels: autocompleteSourcesPath('labels', fullPath, iid),
+ members: autocompleteSourcesPath('members', fullPath, iid),
+ commands: autocompleteSourcesPath('commands', fullPath, iid),
+});
+
+export const markdownPreviewPath = (fullPath, iid) =>
+ `${
+ gon.relative_url_root || ''
+ }/${fullPath}/preview_markdown?target_type=WorkItem&target_id=${iid}`;