summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-10-20 08:43:02 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-10-20 08:43:02 +0000
commitd9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch)
tree2341ef426af70ad1e289c38036737e04b0aa5007 /app/assets/javascripts
parentd6e514dd13db8947884cd58fe2a9c2a063400a9b (diff)
downloadgitlab-ce-3387b3d60d8a8211b84c6f8671fd7c4d050266f0.tar.gz
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue18
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue6
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue91
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js1
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js62
-rw-r--r--app/assets/javascripts/api.js4
-rw-r--r--app/assets/javascripts/api/bulk_imports_api.js7
-rw-r--r--app/assets/javascripts/artifacts_settings/index.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/emoji.js12
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js2
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js2
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js14
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js7
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js14
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue6
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/boards/boards_util.js7
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue25
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue64
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue4
-rw-r--r--app/assets/javascripts/boards/graphql.js22
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js21
-rw-r--r--app/assets/javascripts/boards/stores/actions.js17
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js4
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/ci_lint/index.js4
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue159
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue122
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js1
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql11
-rw-r--r--app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql34
-rw-r--r--app/assets/javascripts/clusters/agents/index.js30
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js8
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_empty_state.vue119
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue152
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue156
-rw-r--r--app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue83
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue259
-rw-r--r--app/assets/javascripts/clusters_list/constants.js85
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql8
-rw-r--r--app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql9
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql15
-rw-r--r--app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql47
-rw-r--r--app/assets/javascripts/clusters_list/index.js5
-rw-r--r--app/assets/javascripts/clusters_list/load_agents.js44
-rw-r--r--app/assets/javascripts/comment_type_toggle.js71
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/details.vue33
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue32
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/color_chip.js73
-rw-r--r--app/assets/javascripts/content_editor/extensions/details.js36
-rw-r--r--app/assets/javascripts/content_editor/extensions/details_content.js25
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js20
-rw-r--r--app/assets/javascripts/content_editor/extensions/math_inline.js35
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js51
-rw-r--r--app/assets/javascripts/content_editor/extensions/word_break.js29
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js14
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js40
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue12
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue2
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue16
-rw-r--r--app/assets/javascripts/cycle_analytics/components/filter_bar.vue1
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue3
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js20
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js8
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js21
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js11
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js30
-rw-r--r--app/assets/javascripts/dependency_proxy.js5
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue2
-rw-r--r--app/assets/javascripts/deprecated_notes.js73
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js2
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js72
-rw-r--r--app/assets/javascripts/diffs/components/app.vue15
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue13
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue2
-rw-r--r--app/assets/javascripts/diffs/utils/tree_worker_utils.js (renamed from app/assets/javascripts/diffs/utils/workers.js)0
-rw-r--r--app/assets/javascripts/diffs/workers/tree_worker.js2
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js10
-rw-r--r--app/assets/javascripts/editor/schema/NOTICE6
-rw-r--r--app/assets/javascripts/editor/schema/ci.json1444
-rw-r--r--app/assets/javascripts/environments/components/environment_delete.vue28
-rw-r--r--app/assets/javascripts/environments/components/environment_external_url.vue15
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue98
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue26
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue16
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue18
-rw-r--r--app/assets/javascripts/environments/components/environment_stop.vue19
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue26
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue22
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql1
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue31
-rw-r--r--app/assets/javascripts/error_tracking_settings/index.js2
-rw-r--r--app/assets/javascripts/experimentation/utils.js8
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue16
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue2
-rw-r--r--app/assets/javascripts/feature_flags/edit.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js2
-rw-r--r--app/assets/javascripts/filtered_search/droplab/constants.js (renamed from app/assets/javascripts/droplab/constants.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_down.js (renamed from app/assets/javascripts/droplab/drop_down.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js (renamed from app/assets/javascripts/droplab/drop_lab.js)10
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook.js (renamed from app/assets/javascripts/droplab/hook.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_button.js (renamed from app/assets/javascripts/droplab/hook_button.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_input.js (renamed from app/assets/javascripts/droplab/hook_input.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/keyboard.js (renamed from app/assets/javascripts/droplab/keyboard.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/ajax.js (renamed from app/assets/javascripts/droplab/plugins/ajax.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js (renamed from app/assets/javascripts/droplab/plugins/ajax_filter.js)3
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/filter.js (renamed from app/assets/javascripts/droplab/plugins/filter.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js (renamed from app/assets/javascripts/droplab/plugins/input_setter.js)0
-rw-r--r--app/assets/javascripts/filtered_search/droplab/utils.js (renamed from app/assets/javascripts/droplab/utils.js)0
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue7
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql6
-rw-r--r--app/assets/javascripts/header_search/components/app.vue15
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue74
-rw-r--r--app/assets/javascripts/header_search/constants.js8
-rw-r--r--app/assets/javascripts/header_search/index.js4
-rw-r--r--app/assets/javascripts/header_search/store/actions.js14
-rw-r--r--app/assets/javascripts/header_search/store/getters.js32
-rw-r--r--app/assets/javascripts/header_search/store/index.js10
-rw-r--r--app/assets/javascripts/header_search/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js12
-rw-r--r--app/assets/javascripts/header_search/store/state.js5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue6
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue14
-rw-r--r--app/assets/javascripts/ide/components/preview/navigator.vue2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js9
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/actions.js1
-rw-r--r--app/assets/javascripts/ide/stores/utils.js2
-rw-r--r--app/assets/javascripts/import_entities/components/pagination_bar.vue90
-rw-r--r--app/assets/javascripts/integrations/constants.js (renamed from app/assets/javascripts/integrations/edit/constants.js)6
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue3
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/override_dropdown.vue2
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js46
-rw-r--r--app/assets/javascripts/integrations/overrides/components/integration_overrides.vue19
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue13
-rw-r--r--app/assets/javascripts/invite_members/utils/response_message_parser.js5
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue69
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue9
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_modal.vue68
-rw-r--r--app/assets/javascripts/issuable_form.js6
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue239
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue124
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/app.vue2
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue35
-rw-r--r--app/assets/javascripts/issuable_suggestions/index.js7
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue37
-rw-r--r--app/assets/javascripts/issues_list/components/issue_card_time_info.vue17
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue49
-rw-r--r--app/assets/javascripts/issues_list/components/new_issue_dropdown.vue124
-rw-r--r--app/assets/javascripts/issues_list/index.js6
-rw-r--r--app/assets/javascripts/issues_list/queries/search_projects.query.graphql12
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue30
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue4
-rw-r--r--app/assets/javascripts/jobs/components/log/collapsible_section.vue10
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue18
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue20
-rw-r--r--app/assets/javascripts/jobs/store/actions.js62
-rw-r--r--app/assets/javascripts/jobs/store/getters.js7
-rw-r--r--app/assets/javascripts/jobs/store/mutation_types.js14
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js56
-rw-r--r--app/assets/javascripts/jobs/store/state.js20
-rw-r--r--app/assets/javascripts/jobs/store/utils.js18
-rw-r--r--app/assets/javascripts/jobs/utils.js9
-rw-r--r--app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js36
-rw-r--r--app/assets/javascripts/lib/graphql.js2
-rw-r--r--app/assets/javascripts/lib/logger/hello.js29
-rw-r--r--app/assets/javascripts/lib/utils/axios_utils.js8
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js19
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js6
-rw-r--r--app/assets/javascripts/lib/utils/constants.js4
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js29
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js12
-rw-r--r--app/assets/javascripts/lib/utils/is_navigating_away.js23
-rw-r--r--app/assets/javascripts/lib/utils/regexp.js8
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js15
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js29
-rw-r--r--app/assets/javascripts/logs/components/environment_logs.vue2
-rw-r--r--app/assets/javascripts/logs/stores/state.js2
-rw-r--r--app/assets/javascripts/main.js3
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue4
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue11
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue19
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue24
-rw-r--r--app/assets/javascripts/members/components/table/expires_at.vue66
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue87
-rw-r--r--app/assets/javascripts/members/constants.js22
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue6
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue6
-rw-r--r--app/assets/javascripts/merge_request.js8
-rw-r--r--app/assets/javascripts/mr_popover/index.js7
-rw-r--r--app/assets/javascripts/namespace_select.js58
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue9
-rw-r--r--app/assets/javascripts/notes/components/comment_type_dropdown.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue13
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue11
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue15
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue1
-rw-r--r--app/assets/javascripts/notes/stores/actions.js4
-rw-r--r--app/assets/javascripts/notes/stores/getters.js11
-rw-r--r--app/assets/javascripts/notifications/constants.js2
-rw-r--r--app/assets/javascripts/packages/details/components/additional_metadata.vue94
-rw-r--r--app/assets/javascripts/packages/details/components/composer_installation.vue65
-rw-r--r--app/assets/javascripts/packages/details/components/conan_installation.vue59
-rw-r--r--app/assets/javascripts/packages/details/components/dependency_row.vue35
-rw-r--r--app/assets/javascripts/packages/details/components/installation_commands.vue55
-rw-r--r--app/assets/javascripts/packages/details/components/installation_title.vue38
-rw-r--r--app/assets/javascripts/packages/details/components/maven_installation.vue153
-rw-r--r--app/assets/javascripts/packages/details/components/npm_installation.vue103
-rw-r--r--app/assets/javascripts/packages/details/components/nuget_installation.vue58
-rw-r--r--app/assets/javascripts/packages/details/components/package_title.vue113
-rw-r--r--app/assets/javascripts/packages/details/components/pypi_installation.vue71
-rw-r--r--app/assets/javascripts/packages/details/constants.js55
-rw-r--r--app/assets/javascripts/packages/details/index.js32
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js140
-rw-r--r--app/assets/javascripts/packages/details/utils.js10
-rw-r--r--app/assets/javascripts/packages/shared/constants.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue105
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql10
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/index.js26
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue (renamed from app/assets/javascripts/packages/details/components/app.vue)74
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue (renamed from app/assets/javascripts/packages/details/components/file_sha.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue (renamed from app/assets/javascripts/packages/details/components/package_files.vue)2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue (renamed from app/assets/javascripts/packages/details/components/package_history.vue)19
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue (renamed from app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js (renamed from app/assets/javascripts/packages/details/store/actions.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js (renamed from app/assets/javascripts/packages/details/store/index.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js (renamed from app/assets/javascripts/packages/details/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js (renamed from app/assets/javascripts/packages/details/store/mutations.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue15
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue30
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue134
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue151
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue81
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue132
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue61
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql27
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql27
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.js10
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/bundle.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue110
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue167
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue139
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql3
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js13
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js2
-rw-r--r--app/assets/javascripts/pages/admin/projects/components/namespace_select.vue143
-rw-r--r--app/assets/javascripts/pages/admin/projects/index.js38
-rw-r--r--app/assets/javascripts/pages/admin/serverless/domains/index.js17
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js8
-rw-r--r--app/assets/javascripts/pages/admin/topics/new/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/dependency_proxies/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/packages/index/index.js13
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue176
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/index.js15
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js3
-rw-r--r--app/assets/javascripts/pages/profiles/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/constants.js9
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/index.js58
-rw-r--r--app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue82
-rw-r--r--app/assets/javascripts/pages/projects/cluster_agents/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue98
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js66
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js34
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/wikis/diff/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/git_access/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/wikis/show/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js2
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js7
-rw-r--r--app/assets/javascripts/pages/shared/wikis/async_edit.js11
-rw-r--r--app/assets/javascripts/pages/shared/wikis/edit.js (renamed from app/assets/javascripts/pages/shared/wikis/index.js)12
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js6
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue21
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue15
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue49
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue31
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue14
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql19
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js24
-rw-r--r--app/assets/javascripts/profile/profile.js1
-rw-r--r--app/assets/javascripts/projects/new/components/app.vue (renamed from app/assets/javascripts/pages/projects/new/components/app.vue)0
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue (renamed from app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue)0
-rw-r--r--app/assets/javascripts/projects/new/components/new_project_url_select.vue163
-rw-r--r--app/assets/javascripts/projects/new/event_hub.js3
-rw-r--r--app/assets/javascripts/projects/new/index.js66
-rw-r--r--app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql (renamed from app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql)0
-rw-r--r--app/assets/javascripts/projects/project_new.js58
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js53
-rw-r--r--app/assets/javascripts/projects/settings/api/access_dropdown_api.js45
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue409
-rw-r--r--app/assets/javascripts/projects/settings/init_access_dropdown.js39
-rw-r--r--app/assets/javascripts/prometheus_alerts/components/reset_key.vue36
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue45
-rw-r--r--app/assets/javascripts/registry/explorer/constants/common.js3
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js6
-rw-r--r--app/assets/javascripts/registry/explorer/constants/list.js2
-rw-r--r--app/assets/javascripts/registry/explorer/index.js4
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue5
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue18
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue15
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue27
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue3
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue6
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue14
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue10
-rw-r--r--app/assets/javascripts/releases/mount_show.js7
-rw-r--r--app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue9
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue6
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/getters.js34
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js10
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue6
-rw-r--r--app/assets/javascripts/repository/commits_service.js65
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue76
-rw-r--r--app/assets/javascripts/repository/components/blob_edit.vue16
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js9
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue15
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue45
-rw-r--r--app/assets/javascripts/repository/components/fork_suggestion.vue45
-rw-r--r--app/assets/javascripts/repository/components/new_directory_modal.vue183
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue11
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue45
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue58
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue22
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue1
-rw-r--r--app/assets/javascripts/repository/constants.js7
-rw-r--r--app/assets/javascripts/repository/index.js1
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql5
-rw-r--r--app/assets/javascripts/repository/router.js21
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/right_sidebar.js2
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue29
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_actions_cell.vue20
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue (renamed from app/assets/javascripts/runner/components/cells/runner_name_cell.vue)19
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_type_cell.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue14
-rw-r--r--app/assets/javascripts/runner/components/runner_name.vue18
-rw-r--r--app/assets/javascripts/runner/components/runner_state_locked_badge.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_state_paused_badge.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue19
-rw-r--r--app/assets/javascripts/runner/components/runner_type_help.vue60
-rw-r--r--app/assets/javascripts/runner/constants.js8
-rw-r--r--app/assets/javascripts/runner/graphql/get_group_runners.query.graphql7
-rw-r--r--app/assets/javascripts/runner/graphql/get_runners.query.graphql1
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue33
-rw-r--r--app/assets/javascripts/search_settings/components/search_settings.vue58
-rw-r--r--app/assets/javascripts/search_settings/constants.js3
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue31
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue2
-rw-r--r--app/assets/javascripts/sidebar/constants.js15
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue7
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue70
-rw-r--r--app/assets/javascripts/snippets/index.js2
-rw-r--r--app/assets/javascripts/token_access/index.js2
-rw-r--r--app/assets/javascripts/tracking/constants.js5
-rw-r--r--app/assets/javascripts/tracking/get_standard_context.js4
-rw-r--r--app/assets/javascripts/tracking/tracking.js13
-rw-r--r--app/assets/javascripts/tracking/utils.js8
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue70
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue137
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue61
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue43
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js97
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue32
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js52
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue23
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue138
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue87
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js34
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue114
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue137
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js37
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue (renamed from app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue)37
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue10
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js17
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue6
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js3
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js3
486 files changed, 10444 insertions, 3873 deletions
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 7f5f0403de6..2cd3a8f12ee 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -49,7 +49,7 @@ export const initProjectsField = () => {
{ default: createDefaultClient },
]) => {
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index a0f4a4bf382..e6dde5898e7 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -14,7 +14,7 @@ export default {
type: Object,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Array,
required: false,
default: () => [],
@@ -29,7 +29,7 @@ export default {
:username="username"
:paths="paths"
:delete-path="paths.delete"
- :oncall-schedules="oncallSchedules"
+ :user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot>
</shared-delete-action>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
index 02fd3efafa1..bd920a91516 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
@@ -14,7 +14,7 @@ export default {
type: Object,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Array,
required: false,
default: () => [],
@@ -29,7 +29,7 @@ export default {
:username="username"
:paths="paths"
:delete-path="paths.deleteWithContributions"
- :oncall-schedules="oncallSchedules"
+ :user-deletion-obstacles="userDeletionObstacles"
>
<slot></slot>
</shared-delete-action>
diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
index a1589c9d46d..c9f29b55dbf 100644
--- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
+++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
@@ -22,7 +22,7 @@ export default {
type: String,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Array,
required: true,
},
@@ -34,7 +34,7 @@ export default {
'data-delete-user-url': this.deletePath,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
- 'data-oncall-schedules': JSON.stringify(this.oncallSchedules),
+ 'data-user-deletion-obstacles': JSON.stringify(this.userDeletionObstacles),
};
},
},
diff --git a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
index 413163c8536..ed90343777d 100644
--- a/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
+++ b/app/assets/javascripts/admin/users/components/modals/delete_user_modal.vue
@@ -2,7 +2,7 @@
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
-import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
export default {
components: {
@@ -10,7 +10,7 @@ export default {
GlButton,
GlFormInput,
GlSprintf,
- OncallSchedulesList,
+ UserDeletionObstaclesList,
},
props: {
title: {
@@ -45,7 +45,7 @@ export default {
type: String,
required: true,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: String,
required: false,
default: '[]',
@@ -66,9 +66,9 @@ export default {
canSubmit() {
return this.enteredUsername === this.username;
},
- schedules() {
+ obstacles() {
try {
- return JSON.parse(this.oncallSchedules);
+ return JSON.parse(this.userDeletionObstacles);
} catch (e) {
Sentry.captureException(e);
}
@@ -112,12 +112,16 @@ export default {
</gl-sprintf>
</p>
- <oncall-schedules-list v-if="schedules.length" :schedules="schedules" :user-name="username" />
+ <user-deletion-obstacles-list
+ v-if="obstacles.length"
+ :obstacles="obstacles"
+ :user-name="username"
+ />
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username>
- <code>{{ username }}</code>
+ <code class="gl-white-space-pre-wrap">{{ username }}</code>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index c076e0bedf0..4f4e2947341 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { convertArrayToCamelCase } from '~/lib/utils/common_utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { I18N_USER_ACTIONS } from '../constants';
import { generateUserPaths } from '../utils';
import Actions from './actions';
@@ -72,6 +73,9 @@ export default {
href: this.userPaths.edit,
};
},
+ obstaclesForUserDeletion() {
+ return parseUserDeletionObstacles(this.user);
+ },
},
methods: {
isLdapAction(action) {
@@ -141,7 +145,7 @@ export default {
:key="action"
:paths="userPaths"
:username="user.name"
- :oncall-schedules="user.oncallSchedules"
+ :user-deletion-obstacles="obstaclesForUserDeletion"
:data-testid="`delete-${action}`"
>
{{ $options.i18n[action] }}
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index a490111e13b..0bdb45d35c9 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql';
+const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
+
export default {
name: 'ProjectsDropdownFilter',
components: {
@@ -88,6 +90,9 @@ export default {
selectedProjectIds() {
return this.selectedProjects.map((p) => p.id);
},
+ hasSelectedProjects() {
+ return Boolean(this.selectedProjects.length);
+ },
availableProjects() {
return filterBySearchTerm(this.projects, this.searchTerm);
},
@@ -95,6 +100,12 @@ export default {
const { loading, availableProjects } = this;
return !loading && !availableProjects.length;
},
+ selectedItems() {
+ return sortByProjectName(this.selectedProjects);
+ },
+ unselectedItems() {
+ return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
+ },
},
watch: {
searchTerm() {
@@ -105,44 +116,53 @@ export default {
this.search();
},
methods: {
+ handleUpdatedSelectedProjects() {
+ this.$emit('selected', this.selectedProjects);
+ },
search: debounce(function debouncedSearch() {
this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
- getSelectedProjects(selectedProject, isMarking) {
- return isMarking
+ getSelectedProjects(selectedProject, isSelected) {
+ return isSelected
? this.selectedProjects.concat([selectedProject])
: this.selectedProjects.filter((project) => project.id !== selectedProject.id);
},
singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : [];
},
- setSelectedProjects(selectedObj, isMarking) {
+ setSelectedProjects(project) {
this.selectedProjects = this.multiSelect
- ? this.getSelectedProjects(selectedObj, isMarking)
- : this.singleSelectedProject(selectedObj, isMarking);
+ ? this.getSelectedProjects(project, !this.isProjectSelected(project))
+ : this.singleSelectedProject(project, !this.isProjectSelected(project));
},
- onClick({ project, isSelected }) {
- this.setSelectedProjects(project, !isSelected);
- this.$emit('selected', this.selectedProjects);
+ onClick(project) {
+ this.setSelectedProjects(project);
+ this.handleUpdatedSelectedProjects();
},
- onMultiSelectClick({ project, isSelected }) {
- this.setSelectedProjects(project, !isSelected);
+ onMultiSelectClick(project) {
+ this.setSelectedProjects(project);
this.isDirty = true;
},
- onSelected(ev) {
+ onSelected(project) {
if (this.multiSelect) {
- this.onMultiSelectClick(ev);
+ this.onMultiSelectClick(project);
} else {
- this.onClick(ev);
+ this.onClick(project);
}
},
onHide() {
if (this.multiSelect && this.isDirty) {
- this.$emit('selected', this.selectedProjects);
+ this.handleUpdatedSelectedProjects();
}
this.searchTerm = '';
this.isDirty = false;
},
+ onClearAll() {
+ if (this.hasSelectedProjects) {
+ this.isDirty = true;
+ }
+ this.selectedProjects = [];
+ },
fetchData() {
this.loading = true;
@@ -168,8 +188,8 @@ export default {
this.projects = nodes;
});
},
- isProjectSelected(id) {
- return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
+ isProjectSelected(project) {
+ return this.selectedProjectIds.includes(project.id);
},
getEntityId(project) {
return getIdFromGraphQLId(project.id);
@@ -182,6 +202,10 @@ export default {
ref="projectsDropdown"
class="dropdown dropdown-projects"
toggle-class="gl-shadow-none"
+ :show-clear-all="hasSelectedProjects"
+ show-highlighted-items-title
+ highlighted-items-title-class="gl-p-3"
+ @clear-all.stop="onClearAll"
@hide="onHide"
>
<template #button-content>
@@ -204,14 +228,37 @@ export default {
<gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
<gl-search-box-by-type v-model.trim="searchTerm" />
</template>
+ <template #highlighted-items>
+ <gl-dropdown-item
+ v-for="project in selectedItems"
+ :key="project.id"
+ is-check-item
+ :is-checked="isProjectSelected(project)"
+ @click.native.capture.stop="onSelected(project)"
+ >
+ <div class="gl-display-flex">
+ <gl-avatar
+ class="gl-mr-2 gl-vertical-align-middle"
+ :alt="project.name"
+ :size="16"
+ :entity-id="getEntityId(project)"
+ :entity-name="project.name"
+ :src="project.avatarUrl"
+ shape="rect"
+ />
+ <div>
+ <div data-testid="project-name">{{ project.name }}</div>
+ <div class="gl-text-gray-500" data-testid="project-full-path">
+ {{ project.fullPath }}
+ </div>
+ </div>
+ </div>
+ </gl-dropdown-item>
+ </template>
<gl-dropdown-item
- v-for="project in availableProjects"
+ v-for="project in unselectedItems"
:key="project.id"
- :is-check-item="true"
- :is-checked="isProjectSelected(project.id)"
- @click.native.capture.stop="
- onSelected({ project, isSelected: isProjectSelected(project.id) })
- "
+ @click.native.capture.stop="onSelected(project)"
>
<div class="gl-display-flex">
<gl-avatar
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index 44d9b4b4262..c06bd34f86f 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -9,4 +9,5 @@ export const dateFormats = {
isoDate,
defaultDate: mediumDate,
defaultDateTime: 'mmm d, yyyy h:MMtt',
+ month: 'mmmm',
};
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 52901d4c5bb..f55ef99964e 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,4 +1,5 @@
import dateFormat from 'dateformat';
+import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from './constants';
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
@@ -7,3 +8,64 @@ export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'na
};
export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
+
+/**
+ * Takes a url and extracts query parameters used for the shared
+ * filter bar
+ *
+ * @param {string} url The URL to extract query parameters from
+ * @returns {Object}
+ */
+export const extractFilterQueryParameters = (url = '') => {
+ const {
+ source_branch_name = null,
+ target_branch_name = null,
+ author_username = null,
+ milestone_title = null,
+ assignee_username = [],
+ label_name = [],
+ } = urlQueryToFilter(url);
+
+ return {
+ selectedSourceBranch: source_branch_name,
+ selectedTargetBranch: target_branch_name,
+ selectedAuthor: author_username,
+ selectedMilestone: milestone_title,
+ selectedAssigneeList: assignee_username,
+ selectedLabelList: label_name,
+ };
+};
+
+/**
+ * Takes a url and extracts sorting and pagination query parameters into an object
+ *
+ * @param {string} url The URL to extract query parameters from
+ * @returns {Object}
+ */
+export const extractPaginationQueryParameters = (url = '') => {
+ const { sort, direction, page } = urlQueryToFilter(url);
+ return {
+ sort: sort?.value || null,
+ direction: direction?.value || null,
+ page: page?.value || null,
+ };
+};
+
+export const getDataZoomOption = ({
+ totalItems = 0,
+ maxItemsPerPage = 40,
+ dataZoom = [{ type: 'slider', bottom: 10, start: 0 }],
+}) => {
+ if (totalItems <= maxItemsPerPage) {
+ return {};
+ }
+
+ const intervalEnd = Math.ceil((maxItemsPerPage / totalItems) * 100);
+
+ return dataZoom.map((item) => {
+ return {
+ ...item,
+ end: intervalEnd,
+ };
+ });
+};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 01e463c1965..adf3e122a64 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -499,10 +499,10 @@ const Api = {
return axios.put(url, params);
},
- applySuggestionBatch(ids) {
+ applySuggestionBatch(ids, message) {
const url = Api.buildUrl(Api.applySuggestionBatchPath);
- return axios.put(url, { ids });
+ return axios.put(url, { ids, commit_message: message });
},
commitPipelines(projectId, sha) {
diff --git a/app/assets/javascripts/api/bulk_imports_api.js b/app/assets/javascripts/api/bulk_imports_api.js
new file mode 100644
index 00000000000..d636cfdff0b
--- /dev/null
+++ b/app/assets/javascripts/api/bulk_imports_api.js
@@ -0,0 +1,7 @@
+import { buildApiUrl } from '~/api/api_utils';
+import axios from '~/lib/utils/axios_utils';
+
+const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities';
+
+export const getBulkImportsHistory = (params) =>
+ axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params });
diff --git a/app/assets/javascripts/artifacts_settings/index.js b/app/assets/javascripts/artifacts_settings/index.js
index 531b42bc185..5c9f1c3129c 100644
--- a/app/assets/javascripts/artifacts_settings/index.js
+++ b/app/assets/javascripts/artifacts_settings/index.js
@@ -6,7 +6,7 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export default (containerId = 'js-artifacts-settings-app') => {
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
index 367a06ad3c1..9d0890aa1b4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
@@ -26,6 +26,18 @@ export default class Emoji extends Node {
moji: el.textContent,
}),
},
+ {
+ tag: 'img.emoji',
+ getAttrs: (el) => {
+ const name = el.getAttribute('title').replace(/^:|:$/g, '');
+
+ return {
+ name,
+ title: name,
+ moji: name,
+ };
+ },
+ },
],
toDOM: (node) => [
'gl-emoji',
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
index ade5839d10b..4cc28c45739 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/image.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -29,7 +29,7 @@ export default class Image extends BaseImage {
},
// Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
{
- tag: 'img[src]',
+ tag: 'img[src]:not(.emoji)',
getAttrs: (el) => {
const imageSrc = el.src;
const imageUrl =
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index a1911585f80..a548b283142 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) {
})
.catch(() =>
createFlash({
- message: __('An error occurred while fetching markdown preview'),
+ message: __('An error occurred while fetching Markdown preview'),
}),
);
};
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index b1227fb3533..59905035257 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -38,23 +38,9 @@ $.fn.requiresInput = function requiresInput() {
$form.on('change input', fieldSelector, requireInput);
};
-// Hide or Show the help block when creating a new project
-// based on the option selected
-function hideOrShowHelpBlock(form) {
- const selected = $('.js-select-namespace option:selected');
- if (selected.length && selected.data('optionsParent') === 'groups') {
- form.find('.form-text.text-muted').hide();
- } else if (selected.length) {
- form.find('.form-text.text-muted').show();
- }
-}
-
$(() => {
$('form.js-requires-input').each((i, el) => {
const $form = $(el);
-
$form.requiresInput();
- hideOrShowHelpBlock($form);
- $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
});
});
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index ebf2ab0381e..b27dccabdf8 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -306,6 +306,12 @@ export const GO_TO_PROJECT_WIKI = {
defaultKeys: ['g w'], // eslint-disable-line @gitlab/require-i18n-strings
};
+export const GO_TO_PROJECT_WEBIDE = {
+ id: 'project.goToWebIDE',
+ description: __('Open in Web IDE'),
+ defaultKeys: ['.'],
+};
+
export const PROJECT_FILES_MOVE_SELECTION_UP = {
id: 'projectFiles.moveSelectionUp',
description: __('Move selection up'),
@@ -549,6 +555,7 @@ export const PROJECT_SHORTCUTS_GROUP = {
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_SNIPPETS,
GO_TO_PROJECT_WIKI,
+ GO_TO_PROJECT_WEBIDE,
],
};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index b188d3b0ec3..7d8e4dd490c 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,4 +1,5 @@
import Mousetrap from 'mousetrap';
+import { visitUrl, constructWebIDEPath } from '~/lib/utils/url_utility';
import findAndFollowLink from '../../lib/utils/navigation_utility';
import {
keysFor,
@@ -18,6 +19,7 @@ import {
GO_TO_PROJECT_KUBERNETES,
GO_TO_PROJECT_ENVIRONMENTS,
GO_TO_PROJECT_METRICS,
+ GO_TO_PROJECT_WEBIDE,
NEW_ISSUE,
} from './keybindings';
import Shortcuts from './shortcuts';
@@ -58,6 +60,18 @@ export default class ShortcutsNavigation extends Shortcuts {
findAndFollowLink('.shortcuts-environments'),
);
Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_WEBIDE), ShortcutsNavigation.navigateToWebIDE);
Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue'));
}
+
+ static navigateToWebIDE() {
+ const path = constructWebIDEPath({
+ sourceProjectFullPath: window.gl.mrWidgetData?.source_project_full_path,
+ targetProjectFullPath: window.gl.mrWidgetData?.target_project_full_path,
+ iid: window.gl.mrWidgetData?.iid,
+ });
+ if (path) {
+ visitUrl(path);
+ }
+ }
}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 1a74675100b..213e026c41f 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -41,6 +41,11 @@ export default {
type: Object,
required: true,
},
+ hideLineNumbers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
viewer() {
@@ -80,6 +85,7 @@ export default {
:is-raw-content="isRawContent"
:file-name="blob.name"
:type="activeViewer.fileType"
+ :hide-line-numbers="hideLineNumbers"
data-qa-selector="file_content"
/>
</template>
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 136457c115d..991f98c89e7 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -247,7 +247,11 @@ export default class FileTemplateMediator {
}
setFilename(name) {
- this.$filenameInput.val(name).trigger('change');
+ const input = this.$filenameInput.get(0);
+ if (name !== undefined && input.value !== name) {
+ input.value = name;
+ input.dispatchEvent(new Event('change'));
+ }
}
getSelected() {
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index d113a1d39d8..c10241d00d7 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -43,7 +43,9 @@ export function formatListIssues(listIssues) {
let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
- sortedIssues = sortBy(sortedIssues, 'relativePosition');
+ if (list.listType !== ListType.closed) {
+ sortedIssues = sortBy(sortedIssues, 'relativePosition');
+ }
return {
...map,
@@ -146,7 +148,8 @@ export function getMoveData(state, params) {
}
export function moveItemListHelper(item, fromList, toList) {
- const updatedItem = item;
+ const updatedItem = cloneDeep(item);
+
if (
toList.listType === ListType.label &&
!updatedItem.labels.find((label) => label.id === toList.label.id)
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index 22ad619e76b..c5411ec313a 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -52,6 +52,8 @@ export default {
},
setSelectedItem(selectedId) {
+ this.selectedId = selectedId;
+
const label = this.labels.find(({ id }) => id === selectedId);
if (!selectedId || !label) {
this.selectedLabel = null;
@@ -87,8 +89,8 @@ export default {
<template #items>
<gl-form-radio-group
v-if="labels.length > 0"
- v-model="selectedId"
class="gl-overflow-y-auto gl-px-5 gl-pt-3"
+ :checked="selectedId"
@change="setSelectedItem"
>
<label
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
index 2aee84b805f..14c84d3c4e5 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue
@@ -1,13 +1,23 @@
<script>
-import { GlButton } from '@gitlab/ui';
-import { mapActions } from 'vuex';
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
import Tracking from '~/tracking';
export default {
components: {
GlButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [Tracking.mixin()],
+ computed: {
+ ...mapState({ isNewListShowing: ({ addColumnForm }) => addColumnForm.visible }),
+ tooltip() {
+ return this.isNewListShowing ? __('The list creation wizard is already open') : '';
+ },
+ },
methods: {
...mapActions(['setAddColumnFormVisibility']),
handleClick() {
@@ -19,7 +29,14 @@ export default {
</script>
<template>
- <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
- <gl-button variant="confirm" @click="handleClick">{{ __('Create list') }} </gl-button>
+ <div
+ v-gl-tooltip="tooltip"
+ :tabindex="isNewListShowing ? '0' : undefined"
+ class="gl-ml-3 gl-display-flex gl-align-items-center"
+ data-testid="boards-create-list"
+ >
+ <gl-button :disabled="isNewListShowing" variant="confirm" @click="handleClick"
+ >{{ __('Create list') }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index db80d48239b..b6ccc6a00fe 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -316,7 +316,7 @@ export default {
</p>
</gl-tooltip>
- <span ref="countBadge" class="issue-count-badge board-card-info gl-mr-0 gl-pr-0">
+ <span ref="countBadge" class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3">
<span v-if="allowSubEpics" class="gl-mr-3">
<gl-icon name="epic" />
{{ totalEpicsCount }}
@@ -334,7 +334,7 @@ export default {
<span
v-if="shouldRenderEpicProgress"
ref="progressBadge"
- class="issue-count-badge board-card-info gl-pl-0"
+ class="board-card-info gl-pl-0"
>
<span class="gl-mr-3" data-testid="epic-progress">
<gl-icon name="progress" />
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index e0105d63d99..9bbb8a1a1b2 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -3,15 +3,18 @@ import { GlDrawer } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapState, mapActions, mapGetters } from 'vuex';
import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdown_widget.vue';
+import { __, sprintf } from '~/locale';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue';
import { ISSUABLE } from '~/boards/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
+import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -23,6 +26,7 @@ export default {
SidebarConfidentialityWidget,
BoardSidebarTimeTracker,
BoardSidebarLabelsSelect,
+ SidebarLabelsWidget,
SidebarSubscriptionsWidget,
SidebarDropdownWidget,
SidebarTodoWidget,
@@ -46,16 +50,20 @@ export default {
weightFeatureAvailable: {
default: false,
},
+ allowLabelEdit: {
+ default: false,
+ },
},
inheritAttrs: false,
computed: {
...mapGetters([
+ 'isGroupBoard',
'isSidebarOpen',
'activeBoardItem',
'groupPathForActiveIssue',
'projectPathForActiveIssue',
]),
- ...mapState(['sidebarType', 'issuableType']),
+ ...mapState(['sidebarType', 'issuableType', 'isSettingLabels']),
isIssuableSidebar() {
return this.sidebarType === ISSUABLE;
},
@@ -65,17 +73,48 @@ export default {
fullPath() {
return this.activeBoardItem?.referencePath?.split('#')[0] || '';
},
+ createLabelTitle() {
+ return sprintf(__('Create %{workspace} label'), {
+ workspace: this.isGroupBoard ? 'group' : 'project',
+ });
+ },
+ manageLabelTitle() {
+ return sprintf(__('Manage %{workspace} labels'), {
+ workspace: this.isGroupBoard ? 'group' : 'project',
+ });
+ },
+ attrWorkspacePath() {
+ return this.isGroupBoard ? this.groupPathForActiveIssue : undefined;
+ },
},
methods: {
...mapActions([
'toggleBoardItem',
'setAssignees',
'setActiveItemConfidential',
+ 'setActiveBoardItemLabels',
'setActiveItemWeight',
]),
handleClose() {
this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
},
+ handleUpdateSelectedLabels(input) {
+ this.setActiveBoardItemLabels({
+ iid: this.activeBoardItem.iid,
+ projectPath: this.projectPathForActiveIssue,
+ addLabelIds: input.map((label) => getIdFromGraphQLId(label.id)),
+ removeLabelIds: this.activeBoardItem.labels
+ .filter((label) => !input.find((selected) => selected.id === label.id))
+ .map((label) => label.id),
+ });
+ },
+ handleLabelRemove(input) {
+ this.setActiveBoardItemLabels({
+ iid: this.activeBoardItem.iid,
+ projectPath: this.projectPathForActiveIssue,
+ removeLabelIds: [input],
+ });
+ },
},
};
</script>
@@ -160,7 +199,28 @@ export default {
:issuable-type="issuableType"
data-testid="sidebar-due-date"
/>
- <board-sidebar-labels-select class="block labels" />
+ <sidebar-labels-widget
+ v-if="glFeatures.labelsWidget"
+ class="block labels"
+ data-testid="sidebar-labels"
+ :iid="activeBoardItem.iid"
+ :full-path="projectPathForActiveIssue"
+ :allow-label-remove="allowLabelEdit"
+ :allow-multiselect="true"
+ :selected-labels="activeBoardItem.labels"
+ :labels-select-in-progress="isSettingLabels"
+ :footer-create-label-title="createLabelTitle"
+ :footer-manage-label-title="manageLabelTitle"
+ :labels-create-title="createLabelTitle"
+ :labels-filter-base-path="projectPathForActiveIssue"
+ :attr-workspace-path="attrWorkspacePath"
+ :issuable-type="issuableType"
+ @onLabelRemove="handleLabelRemove"
+ @updateSelectedLabels="handleUpdateSelectedLabels"
+ >
+ {{ __('None') }}
+ </sidebar-labels-widget>
+ <board-sidebar-labels-select v-else class="block labels" />
<sidebar-weight-widget
v-if="weightFeatureAvailable"
:iid="activeBoardItem.iid"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index dc5313b1bf6..a8d71ab7a35 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -365,7 +365,7 @@ export default {
>
<span class="gl-display-inline-flex">
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
- <span ref="itemCount" class="issue-count-badge-count">
+ <span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
<gl-icon class="gl-mr-2" :name="countIcon" />
<item-count :items-size="itemsCount" :max-issue-count="list.maxIssueCount" />
</span>
@@ -388,7 +388,7 @@ export default {
v-gl-tooltip.hover
:aria-label="$options.i18n.newIssue"
:title="$options.i18n.newIssue"
- class="issue-count-badge-add-button no-drag"
+ class="no-drag"
icon="plus"
@click="showNewIssueForm"
/>
diff --git a/app/assets/javascripts/boards/graphql.js b/app/assets/javascripts/boards/graphql.js
new file mode 100644
index 00000000000..d8d16184936
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql.js
@@ -0,0 +1,22 @@
+import { IntrospectionFragmentMatcher, defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
+
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
+export const gqlClient = createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: (object) => {
+ // eslint-disable-next-line no-underscore-dangle
+ return object.__typename === 'BoardList' ? object.iid : defaultDataIdFromObject(object);
+ },
+
+ fragmentMatcher,
+ },
+ assumeImmutableResults: true,
+ },
+);
diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 1b14396fb5c..314faae89f8 100644
--- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -1,3 +1,4 @@
+#import "~/graphql_shared/fragments/milestone.fragment.graphql"
#import "~/graphql_shared/fragments/user.fragment.graphql"
fragment IssueNode on Issue {
@@ -15,6 +16,9 @@ fragment IssueNode on Issue {
hidden
webUrl
relativePosition
+ milestone {
+ ...MilestoneFragment
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index d1cb1ecf834..787dd77b901 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -16,6 +16,7 @@ query ListIssues(
nodes {
id
issuesCount
+ listType
issues(first: $first, filters: $filters, after: $after) {
edges {
node {
@@ -37,6 +38,7 @@ query ListIssues(
nodes {
id
issuesCount
+ listType
issues(first: $first, filters: $filters, after: $after) {
edges {
node {
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 21c1bb23dc6..b6b1094fb3a 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,4 +1,3 @@
-import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import PortalVue from 'portal-vue';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
@@ -14,30 +13,17 @@ import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
import toggleFocusMode from '~/boards/toggle_focus';
-import createDefaultClient from '~/lib/graphql';
import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
-import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
import { fullBoardId } from './boards_util';
import boardConfigToggle from './config_toggle';
+import { gqlClient } from './graphql';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
Vue.use(PortalVue);
-const fragmentMatcher = new IntrospectionFragmentMatcher({
- introspectionQueryResultData,
-});
-
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- cacheConfig: {
- fragmentMatcher,
- },
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: gqlClient,
});
function mountBoardApp(el) {
@@ -101,6 +87,9 @@ function mountBoardApp(el) {
iterationListsAvailable: parseBoolean(el.dataset.iterationListsAvailable),
issuableType: issuableTypes.issue,
emailsDisabled: parseBoolean(el.dataset.emailsDisabled),
+ allowLabelCreate: parseBoolean(el.dataset.canUpdate),
+ allowLabelEdit: parseBoolean(el.dataset.canUpdate),
+ allowScopedLabels: parseBoolean(el.dataset.scopedLabels),
},
render: (createComponent) => createComponent(BoardApp),
});
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index dc06b62cebb..ca993e75cf9 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -19,7 +19,6 @@ import {
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
@@ -35,6 +34,7 @@ import {
FiltersInfo,
filterVariables,
} from '../boards_util';
+import { gqlClient } from '../graphql';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
@@ -47,13 +47,6 @@ import projectBoardMilestonesQuery from '../graphql/project_board_milestones.que
import * as types from './mutation_types';
-export const gqlClient = createGqClient(
- {},
- {
- fetchPolicy: fetchPolicies.NO_CACHE,
- },
-);
-
export default {
setInitialBoardData: ({ commit }, data) => {
commit(types.SET_INITIAL_BOARD_DATA, data);
@@ -603,7 +596,7 @@ export default {
});
},
- addListItem: ({ commit }, { list, item, position, inProgress = false }) => {
+ addListItem: ({ commit, dispatch }, { list, item, position, inProgress = false }) => {
commit(types.ADD_BOARD_ITEM_TO_LIST, {
listId: list.id,
itemId: item.id,
@@ -611,6 +604,9 @@ export default {
inProgress,
});
commit(types.UPDATE_BOARD_ITEM, item);
+ if (!inProgress) {
+ dispatch('setActiveId', { id: item.id, sidebarType: ISSUABLE });
+ }
},
removeListItem: ({ commit }, { listId, itemId }) => {
@@ -660,6 +656,7 @@ export default {
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
+ commit(types.SET_LABELS_LOADING, true);
const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabelsMutation,
@@ -673,6 +670,8 @@ export default {
},
});
+ commit(types.SET_LABELS_LOADING, false);
+
if (data.updateIssue?.errors?.length > 0) {
throw new Error(data.updateIssue.errors);
}
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 928cece19f7..26b785932bb 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -28,6 +28,7 @@ export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST';
export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
+export const SET_LABELS_LOADING = 'SET_LABELS_LOADING';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index ef5b84b4575..d381c076c19 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -195,6 +195,10 @@ export default {
Vue.set(state.boardItems[itemId], prop, value);
},
+ [mutationTypes.SET_LABELS_LOADING](state, isLoading) {
+ state.isSettingLabels = isLoading;
+ },
+
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
state.isSettingAssignees = isLoading;
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 80c51c966d2..2a6605e687b 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -12,6 +12,7 @@ export default () => ({
listsFlags: {},
boardItemsByListId: {},
backupItemsList: [],
+ isSettingLabels: false,
isSettingAssignees: false,
pageInfoByListId: {},
boardItems: {},
diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js
index 274aab45deb..f97590ec5db 100644
--- a/app/assets/javascripts/ci_lint/index.js
+++ b/app/assets/javascripts/ci_lint/index.js
@@ -8,7 +8,9 @@ import CiLint from './components/ci_lint.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers),
+ defaultClient: createDefaultClient(resolvers, {
+ assumeImmutableResults: true,
+ }),
});
export default (containerId = '#js-ci-lint') => {
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
new file mode 100644
index 00000000000..5c672d288c5
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -0,0 +1,159 @@
+<script>
+import {
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { MAX_LIST_COUNT } from '../constants';
+import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
+import TokenTable from './token_table.vue';
+
+export default {
+ i18n: {
+ installedInfo: s__('ClusterAgents|Created by %{name} %{time}'),
+ loadingError: s__('ClusterAgents|An error occurred while loading your agent'),
+ tokens: s__('ClusterAgents|Access tokens'),
+ unknownUser: s__('ClusterAgents|Unknown user'),
+ },
+ apollo: {
+ clusterAgent: {
+ query: getClusterAgentQuery,
+ variables() {
+ return {
+ agentName: this.agentName,
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update: (data) => data?.project?.clusterAgent,
+ error() {
+ this.clusterAgent = null;
+ },
+ },
+ },
+ components: {
+ GlAlert,
+ GlBadge,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+ TimeAgoTooltip,
+ TokenTable,
+ },
+ props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
+ projectPath: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ last: null,
+ },
+ };
+ },
+ computed: {
+ createdAt() {
+ return this.clusterAgent?.createdAt;
+ },
+ createdBy() {
+ return this.clusterAgent?.createdByUser?.name || this.$options.i18n.unknownUser;
+ },
+ isLoading() {
+ return this.$apollo.queries.clusterAgent.loading;
+ },
+ showPagination() {
+ return this.tokenPageInfo.hasPreviousPage || this.tokenPageInfo.hasNextPage;
+ },
+ tokenCount() {
+ return this.clusterAgent?.tokens?.count;
+ },
+ tokenPageInfo() {
+ return this.clusterAgent?.tokens?.pageInfo || {};
+ },
+ tokens() {
+ return this.clusterAgent?.tokens?.nodes || [];
+ },
+ },
+ methods: {
+ nextPage() {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ afterToken: this.tokenPageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: MAX_LIST_COUNT,
+ beforeToken: this.tokenPageInfo.startCursor,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <section>
+ <h2>{{ agentName }}</h2>
+
+ <gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" />
+
+ <div v-else-if="clusterAgent">
+ <p data-testid="cluster-agent-create-info">
+ <gl-sprintf :message="$options.i18n.installedInfo">
+ <template #name>
+ {{ createdBy }}
+ </template>
+
+ <template #time>
+ <time-ago-tooltip :time="createdAt" />
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-tabs>
+ <gl-tab>
+ <template #title>
+ <span data-testid="cluster-agent-token-count">
+ {{ $options.i18n.tokens }}
+
+ <gl-badge v-if="tokenCount" size="sm" class="gl-tab-counter-badge">{{
+ tokenCount
+ }}</gl-badge>
+ </span>
+ </template>
+
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-m-3" />
+
+ <div v-else>
+ <TokenTable :tokens="tokens" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination v-bind="tokenPageInfo" @prev="prevPage" @next="nextPage" />
+ </div>
+ </div>
+ </gl-tab>
+ </gl-tabs>
+ </div>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ $options.i18n.loadingError }}
+ </gl-alert>
+ </section>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
new file mode 100644
index 00000000000..70ed2566134
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -0,0 +1,122 @@
+<script>
+import { GlEmptyState, GlLink, GlTable, GlTooltip, GlTruncate } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlLink,
+ GlTable,
+ GlTooltip,
+ GlTruncate,
+ TimeAgoTooltip,
+ },
+ i18n: {
+ createdBy: s__('ClusterAgents|Created by'),
+ createToken: s__('ClusterAgents|You will need to create a token to connect to your agent'),
+ dateCreated: s__('ClusterAgents|Date created'),
+ description: s__('ClusterAgents|Description'),
+ lastUsed: s__('ClusterAgents|Last contact'),
+ learnMore: s__('ClusterAgents|Learn how to create an agent access token'),
+ name: s__('ClusterAgents|Name'),
+ neverUsed: s__('ClusterAgents|Never'),
+ noTokens: s__('ClusterAgents|This agent has no tokens'),
+ unknownUser: s__('ClusterAgents|Unknown user'),
+ },
+ props: {
+ tokens: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: this.$options.i18n.name,
+ tdAttr: { 'data-testid': 'agent-token-name' },
+ },
+ {
+ key: 'lastUsed',
+ label: this.$options.i18n.lastUsed,
+ tdAttr: { 'data-testid': 'agent-token-used' },
+ },
+ {
+ key: 'createdAt',
+ label: this.$options.i18n.dateCreated,
+ tdAttr: { 'data-testid': 'agent-token-created-time' },
+ },
+ {
+ key: 'createdBy',
+ label: this.$options.i18n.createdBy,
+ tdAttr: { 'data-testid': 'agent-token-created-user' },
+ },
+ {
+ key: 'description',
+ label: this.$options.i18n.description,
+ tdAttr: { 'data-testid': 'agent-token-description' },
+ },
+ ];
+ },
+ learnMoreUrl() {
+ return helpPagePath('user/clusters/agent/index.md', {
+ anchor: 'create-an-agent-record-in-gitlab',
+ });
+ },
+ },
+ methods: {
+ createdByName(token) {
+ return token?.createdByUser?.name || this.$options.i18n.unknownUser;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="tokens.length">
+ <div class="gl-text-right gl-my-5">
+ <gl-link target="_blank" :href="learnMoreUrl">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </div>
+
+ <gl-table :items="tokens" :fields="fields" fixed stacked="md">
+ <template #cell(lastUsed)="{ item }">
+ <time-ago-tooltip v-if="item.lastUsedAt" :time="item.lastUsedAt" />
+ <span v-else>{{ $options.i18n.neverUsed }}</span>
+ </template>
+
+ <template #cell(createdAt)="{ item }">
+ <time-ago-tooltip :time="item.createdAt" />
+ </template>
+
+ <template #cell(createdBy)="{ item }">
+ <span>{{ createdByName(item) }}</span>
+ </template>
+
+ <template #cell(description)="{ item }">
+ <div v-if="item.description" :id="`tooltip-description-container-${item.id}`">
+ <gl-truncate :id="`tooltip-description-${item.id}`" :text="item.description" />
+
+ <gl-tooltip
+ :container="`tooltip-description-container-${item.id}`"
+ :target="`tooltip-description-${item.id}`"
+ placement="top"
+ >
+ {{ item.description }}
+ </gl-tooltip>
+ </div>
+ </template>
+ </gl-table>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.noTokens"
+ :primary-button-link="learnMoreUrl"
+ :primary-button-text="$options.i18n.learnMore"
+ />
+</template>
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
new file mode 100644
index 00000000000..bbc4630f83b
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -0,0 +1 @@
+export const MAX_LIST_COUNT = 25;
diff --git a/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
new file mode 100644
index 00000000000..1e9187e8ad1
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/fragments/cluster_agent_token.fragment.graphql
@@ -0,0 +1,11 @@
+fragment Token on ClusterAgentToken {
+ id
+ createdAt
+ description
+ lastUsedAt
+ name
+
+ createdByUser {
+ name
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
new file mode 100644
index 00000000000..d01db8f0a6a
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/graphql/queries/get_cluster_agent.query.graphql
@@ -0,0 +1,34 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/cluster_agent_token.fragment.graphql"
+
+query getClusterAgent(
+ $projectPath: ID!
+ $agentName: String!
+ $first: Int
+ $last: Int
+ $afterToken: String
+ $beforeToken: String
+) {
+ project(fullPath: $projectPath) {
+ clusterAgent(name: $agentName) {
+ id
+ createdAt
+
+ createdByUser {
+ name
+ }
+
+ tokens(first: $first, last: $last, before: $beforeToken, after: $afterToken) {
+ count
+
+ nodes {
+ ...Token
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters/agents/index.js b/app/assets/javascripts/clusters/agents/index.js
new file mode 100644
index 00000000000..bcb5b271203
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/index.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AgentShowPage from './components/show.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const el = document.querySelector('#js-cluster-agent-details');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient();
+ const { agentName, projectPath } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ render(createElement) {
+ return createElement(AgentShowPage, {
+ props: {
+ agentName,
+ projectPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
new file mode 100644
index 00000000000..9b870134512
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -0,0 +1,8 @@
+export function generateAgentRegistrationCommand(agentToken, kasAddress) {
+ return `docker run --pull=always --rm \\
+ registry.gitlab.com/gitlab-org/cluster-integration/gitlab-agent/cli:stable generate \\
+ --agent-token=${agentToken} \\
+ --kas-address=${kasAddress} \\
+ --agent-version stable \\
+ --namespace gitlab-kubernetes-agent | kubectl apply -f -`;
+}
diff --git a/app/assets/javascripts/clusters_list/components/agent_empty_state.vue b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
new file mode 100644
index 00000000000..405339b3d36
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_empty_state.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlButton, GlEmptyState, GlLink, GlSprintf, GlAlert, GlModalDirective } from '@gitlab/ui';
+import { INSTALL_AGENT_MODAL_ID } from '../constants';
+
+export default {
+ modalId: INSTALL_AGENT_MODAL_ID,
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ GlAlert,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: [
+ 'emptyStateImage',
+ 'projectPath',
+ 'agentDocsUrl',
+ 'installDocsUrl',
+ 'getStartedDocsUrl',
+ 'integrationDocsUrl',
+ ],
+ props: {
+ hasConfigurations: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ repositoryPath() {
+ return `/${this.projectPath}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :svg-path="emptyStateImage"
+ :title="s__('ClusterAgents|Integrate Kubernetes with a GitLab Agent')"
+ class="empty-state--agent"
+ >
+ <template #description>
+ <p class="mw-460 gl-mx-auto">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterAgents|The GitLab Kubernetes Agent allows an Infrastructure as Code, GitOps approach to integrating Kubernetes clusters with GitLab. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="agentDocsUrl" target="_blank" data-testid="agent-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p class="mw-460 gl-mx-auto">
+ <gl-sprintf
+ :message="
+ s__(
+ 'ClusterAgents|The GitLab Agent also requires %{linkStart}enabling the Agent Server%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="installDocsUrl" target="_blank" data-testid="install-docs-link">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-alert
+ v-if="!hasConfigurations"
+ variant="warning"
+ class="gl-mb-5 text-left"
+ :dismissible="false"
+ >
+ {{
+ s__(
+ 'ClusterAgents|To install an Agent you should create an agent directory in the Repository first. We recommend that you add the Agent configuration to the directory before you start the installation process.',
+ )
+ }}
+
+ <template #actions>
+ <gl-button
+ category="primary"
+ variant="info"
+ :href="getStartedDocsUrl"
+ target="_blank"
+ class="gl-ml-0!"
+ >
+ {{ s__('ClusterAgents|Read more about getting started') }}
+ </gl-button>
+ <gl-button category="secondary" variant="info" :href="repositoryPath">
+ {{ s__('ClusterAgents|Go to the repository') }}
+ </gl-button>
+ </template>
+ </gl-alert>
+ </template>
+
+ <template #actions>
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ :disabled="!hasConfigurations"
+ data-testid="integration-primary-button"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('ClusterAgents|Integrate with the GitLab Agent') }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
new file mode 100644
index 00000000000..487e512c06d
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -0,0 +1,152 @@
+<script>
+import {
+ GlButton,
+ GlLink,
+ GlModalDirective,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlPopover,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { INSTALL_AGENT_MODAL_ID, AGENT_STATUSES, TROUBLESHOOTING_LINK } from '../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlTable,
+ GlIcon,
+ GlSprintf,
+ GlTooltip,
+ GlPopover,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [timeagoMixin],
+ inject: ['integrationDocsUrl'],
+ INSTALL_AGENT_MODAL_ID,
+ AGENT_STATUSES,
+ TROUBLESHOOTING_LINK,
+ props: {
+ agents: {
+ required: true,
+ type: Array,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: s__('ClusterAgents|Name'),
+ },
+ {
+ key: 'status',
+ label: s__('ClusterAgents|Connection status'),
+ },
+ {
+ key: 'lastContact',
+ label: s__('ClusterAgents|Last contact'),
+ },
+ {
+ key: 'configuration',
+ label: s__('ClusterAgents|Configuration'),
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-display-block gl-text-right gl-my-3">
+ <gl-button
+ v-gl-modal-directive="$options.INSTALL_AGENT_MODAL_ID"
+ variant="confirm"
+ category="primary"
+ >{{ s__('ClusterAgents|Install a new GitLab Agent') }}
+ </gl-button>
+ </div>
+
+ <gl-table
+ :items="agents"
+ :fields="fields"
+ stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
+ data-testid="cluster-agent-list-table"
+ >
+ <template #cell(name)="{ item }">
+ <gl-link :href="item.webPath" data-testid="cluster-agent-name-link">
+ {{ item.name }}
+ </gl-link>
+ </template>
+
+ <template #cell(status)="{ item }">
+ <span
+ :id="`connection-status-${item.name}`"
+ class="gl-pr-5"
+ data-testid="cluster-agent-connection-status"
+ >
+ <span :class="$options.AGENT_STATUSES[item.status].class" class="gl-mr-3">
+ <gl-icon :name="$options.AGENT_STATUSES[item.status].icon" :size="12" /></span
+ >{{ $options.AGENT_STATUSES[item.status].name }}
+ </span>
+ <gl-tooltip
+ v-if="item.status === 'active'"
+ :target="`connection-status-${item.name}`"
+ placement="right"
+ >
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.title"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template>
+ </gl-sprintf>
+ </gl-tooltip>
+ <gl-popover
+ v-else
+ :target="`connection-status-${item.name}`"
+ :title="$options.AGENT_STATUSES[item.status].tooltip.title"
+ placement="right"
+ container="viewport"
+ >
+ <p>
+ <gl-sprintf :message="$options.AGENT_STATUSES[item.status].tooltip.body"
+ ><template #timeAgo>{{ timeFormatted(item.lastContact) }}</template></gl-sprintf
+ >
+ </p>
+ <p class="gl-mb-0">
+ {{ s__('ClusterAgents|For more troubleshooting information go to') }}
+ <gl-link :href="$options.TROUBLESHOOTING_LINK" target="_blank" class="gl-font-sm">
+ {{ $options.TROUBLESHOOTING_LINK }}</gl-link
+ >
+ </p>
+ </gl-popover>
+ </template>
+
+ <template #cell(lastContact)="{ item }">
+ <span data-testid="cluster-agent-last-contact">
+ <time-ago-tooltip v-if="item.lastContact" :time="item.lastContact" />
+ <span v-else>{{ s__('ClusterAgents|Never') }}</span>
+ </span>
+ </template>
+
+ <template #cell(configuration)="{ item }">
+ <span data-testid="cluster-agent-configuration-link">
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <gl-link v-if="item.configFolder" :href="item.configFolder.webPath">
+ .gitlab/agents/{{ item.name }}
+ </gl-link>
+
+ <span v-else>.gitlab/agents/{{ item.name }}</span>
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+ </span>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
new file mode 100644
index 00000000000..ed44c1f5fa7
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -0,0 +1,156 @@
+<script>
+import { GlAlert, GlKeysetPagination, GlLoadingIcon } from '@gitlab/ui';
+import { MAX_LIST_COUNT, ACTIVE_CONNECTION_TIME } from '../constants';
+import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
+import AgentEmptyState from './agent_empty_state.vue';
+import AgentTable from './agent_table.vue';
+import InstallAgentModal from './install_agent_modal.vue';
+
+export default {
+ apollo: {
+ agents: {
+ query: getAgentsQuery,
+ variables() {
+ return {
+ defaultBranchName: this.defaultBranchName,
+ projectPath: this.projectPath,
+ ...this.cursor,
+ };
+ },
+ update(data) {
+ this.updateTreeList(data);
+ return data;
+ },
+ },
+ },
+ components: {
+ AgentEmptyState,
+ AgentTable,
+ InstallAgentModal,
+ GlAlert,
+ GlKeysetPagination,
+ GlLoadingIcon,
+ },
+ inject: ['projectPath'],
+ props: {
+ defaultBranchName: {
+ default: '.noBranch',
+ required: false,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ cursor: {
+ first: MAX_LIST_COUNT,
+ last: null,
+ },
+ folderList: {},
+ };
+ },
+ computed: {
+ agentList() {
+ let list = this.agents?.project?.clusterAgents?.nodes;
+
+ if (list) {
+ list = list.map((agent) => {
+ const configFolder = this.folderList[agent.name];
+ const lastContact = this.getLastContact(agent);
+ const status = this.getStatus(lastContact);
+ return { ...agent, configFolder, lastContact, status };
+ });
+ }
+
+ return list;
+ },
+ agentPageInfo() {
+ return this.agents?.project?.clusterAgents?.pageInfo || {};
+ },
+ isLoading() {
+ return this.$apollo.queries.agents.loading;
+ },
+ showPagination() {
+ return this.agentPageInfo.hasPreviousPage || this.agentPageInfo.hasNextPage;
+ },
+ treePageInfo() {
+ return this.agents?.project?.repository?.tree?.trees?.pageInfo || {};
+ },
+ hasConfigurations() {
+ return Boolean(this.agents?.project?.repository?.tree?.trees?.nodes?.length);
+ },
+ },
+ methods: {
+ reloadAgents() {
+ this.$apollo.queries.agents.refetch();
+ },
+ nextPage() {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ last: null,
+ afterAgent: this.agentPageInfo.endCursor,
+ afterTree: this.treePageInfo.endCursor,
+ };
+ },
+ prevPage() {
+ this.cursor = {
+ first: null,
+ last: MAX_LIST_COUNT,
+ beforeAgent: this.agentPageInfo.startCursor,
+ beforeTree: this.treePageInfo.endCursor,
+ };
+ },
+ updateTreeList(data) {
+ const configFolders = data?.project?.repository?.tree?.trees?.nodes;
+
+ if (configFolders) {
+ configFolders.forEach((folder) => {
+ this.folderList[folder.name] = folder;
+ });
+ }
+ },
+ getLastContact(agent) {
+ const tokens = agent?.tokens?.nodes;
+ let lastContact = null;
+ if (tokens?.length) {
+ tokens.forEach((token) => {
+ const lastContactToDate = new Date(token.lastUsedAt).getTime();
+ if (lastContactToDate > lastContact) {
+ lastContact = lastContactToDate;
+ }
+ });
+ }
+ return lastContact;
+ },
+ getStatus(lastContact) {
+ if (lastContact) {
+ const now = new Date().getTime();
+ const diff = now - lastContact;
+
+ return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
+ }
+ return 'unused';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
+
+ <section v-else-if="agentList" class="gl-mt-3">
+ <div v-if="agentList.length">
+ <AgentTable :agents="agentList" />
+
+ <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-keyset-pagination v-bind="agentPageInfo" @prev="prevPage" @next="nextPage" />
+ </div>
+ </div>
+
+ <AgentEmptyState v-else :has-configurations="hasConfigurations" />
+ <InstallAgentModal @agentRegistered="reloadAgents" />
+ </section>
+
+ <gl-alert v-else variant="danger" :dismissible="false">
+ {{ s__('ClusterAgents|An error occurred while loading your GitLab Agents') }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
new file mode 100644
index 00000000000..9fb020d2f4f
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants';
+import agentConfigurations from '../graphql/queries/agent_configurations.query.graphql';
+
+export default {
+ name: 'AvailableAgentsDropdown',
+ i18n: I18N_AVAILABLE_AGENTS_DROPDOWN,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ inject: ['projectPath'],
+ props: {
+ isRegistering: {
+ required: true,
+ type: Boolean,
+ },
+ },
+ apollo: {
+ agents: {
+ query: agentConfigurations,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ this.populateAvailableAgents(data);
+ },
+ },
+ },
+ data() {
+ return {
+ availableAgents: [],
+ selectedAgent: null,
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.agents.loading;
+ },
+ dropdownText() {
+ if (this.isRegistering) {
+ return this.$options.i18n.registeringAgent;
+ } else if (this.selectedAgent === null) {
+ return this.$options.i18n.selectAgent;
+ }
+
+ return this.selectedAgent;
+ },
+ },
+ methods: {
+ selectAgent(agent) {
+ this.$emit('agentSelected', agent);
+ this.selectedAgent = agent;
+ },
+ isSelected(agent) {
+ return this.selectedAgent === agent;
+ },
+ populateAvailableAgents(data) {
+ const installedAgents = data?.project?.clusterAgents?.nodes.map((agent) => agent.name) ?? [];
+ const configuredAgents =
+ data?.project?.agentConfigurations?.nodes.map((config) => config.agentName) ?? [];
+
+ this.availableAgents = configuredAgents.filter((agent) => !installedAgents.includes(agent));
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="dropdownText" :loading="isLoading || isRegistering">
+ <gl-dropdown-item
+ v-for="agent in availableAgents"
+ :key="agent"
+ :is-checked="isSelected(agent)"
+ is-check-item
+ @click="selectAgent(agent)"
+ >
+ {{ agent }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
new file mode 100644
index 00000000000..5f192fe4d5a
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -0,0 +1,259 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+import { generateAgentRegistrationCommand } from '../clusters_util';
+import { INSTALL_AGENT_MODAL_ID, I18N_INSTALL_AGENT_MODAL } from '../constants';
+import createAgent from '../graphql/mutations/create_agent.mutation.graphql';
+import createAgentToken from '../graphql/mutations/create_agent_token.mutation.graphql';
+import AvailableAgentsDropdown from './available_agents_dropdown.vue';
+
+export default {
+ modalId: INSTALL_AGENT_MODAL_ID,
+ i18n: I18N_INSTALL_AGENT_MODAL,
+ components: {
+ AvailableAgentsDropdown,
+ ClipboardButton,
+ CodeBlock,
+ GlAlert,
+ GlButton,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ },
+ inject: ['projectPath', 'kasAddress'],
+ data() {
+ return {
+ registering: false,
+ agentName: null,
+ agentToken: null,
+ error: null,
+ };
+ },
+ computed: {
+ registered() {
+ return Boolean(this.agentToken);
+ },
+ nextButtonDisabled() {
+ return !this.registering && this.agentName !== null;
+ },
+ canCancel() {
+ return !this.registered && !this.registering;
+ },
+ agentRegistrationCommand() {
+ return generateAgentRegistrationCommand(this.agentToken, this.kasAddress);
+ },
+ basicInstallPath() {
+ return helpPagePath('user/clusters/agent/index', {
+ anchor: 'install-the-agent-into-the-cluster',
+ });
+ },
+ advancedInstallPath() {
+ return helpPagePath('user/clusters/agent/index', { anchor: 'advanced-installation' });
+ },
+ },
+ methods: {
+ setAgentName(name) {
+ this.agentName = name;
+ },
+ cancelClicked() {
+ this.$refs.modal.hide();
+ },
+ doneClicked() {
+ this.$emit('agentRegistered');
+ this.$refs.modal.hide();
+ },
+ resetModal() {
+ this.registering = null;
+ this.agentName = null;
+ this.agentToken = null;
+ this.error = null;
+ },
+ createAgentMutation() {
+ return this.$apollo
+ .mutate({
+ mutation: createAgent,
+ variables: {
+ input: {
+ name: this.agentName,
+ projectPath: this.projectPath,
+ },
+ },
+ })
+ .then(({ data: { createClusterAgent } }) => createClusterAgent);
+ },
+ createAgentTokenMutation(agendId) {
+ return this.$apollo
+ .mutate({
+ mutation: createAgentToken,
+ variables: {
+ input: {
+ clusterAgentId: agendId,
+ name: this.agentName,
+ },
+ },
+ })
+ .then(({ data: { clusterAgentTokenCreate } }) => clusterAgentTokenCreate);
+ },
+ async registerAgent() {
+ this.registering = true;
+ this.error = null;
+
+ try {
+ const { errors: agentErrors, clusterAgent } = await this.createAgentMutation();
+
+ if (agentErrors?.length > 0) {
+ throw new Error(agentErrors[0]);
+ }
+
+ const { errors: tokenErrors, secret } = await this.createAgentTokenMutation(
+ clusterAgent.id,
+ );
+
+ if (tokenErrors?.length > 0) {
+ throw new Error(tokenErrors[0]);
+ }
+
+ this.agentToken = secret;
+ } catch (error) {
+ if (error) {
+ this.error = error.message;
+ } else {
+ this.error = this.$options.i18n.unknownError;
+ }
+ } finally {
+ this.registering = false;
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="$options.i18n.modalTitle"
+ static
+ lazy
+ @hidden="resetModal"
+ >
+ <template v-if="!registered">
+ <p>
+ <strong>{{ $options.i18n.selectAgentTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.selectAgentBody">
+ <template #link="{ content }">
+ <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <form>
+ <gl-form-group label-for="agent-name">
+ <available-agents-dropdown
+ class="gl-w-70p"
+ :is-registering="registering"
+ @agentSelected="setAgentName"
+ />
+ </gl-form-group>
+ </form>
+
+ <p v-if="error">
+ <gl-alert
+ :title="$options.i18n.registrationErrorTitle"
+ variant="danger"
+ :dismissible="false"
+ >
+ {{ error }}
+ </gl-alert>
+ </p>
+ </template>
+
+ <template v-else>
+ <p>
+ <strong>{{ $options.i18n.tokenTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.tokenBody">
+ <template #link="{ content }">
+ <gl-link :href="basicInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <p>
+ <gl-alert
+ :title="$options.i18n.tokenSingleUseWarningTitle"
+ variant="warning"
+ :dismissible="false"
+ >
+ {{ $options.i18n.tokenSingleUseWarningBody }}
+ </gl-alert>
+ </p>
+
+ <p>
+ <gl-form-input-group readonly :value="agentToken" :select-on-click="true">
+ <template #append>
+ <clipboard-button :text="agentToken" :title="$options.i18n.copyToken" />
+ </template>
+ </gl-form-input-group>
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.basicInstallTitle }}</strong>
+ </p>
+
+ <p>
+ {{ $options.i18n.basicInstallBody }}
+ </p>
+
+ <p>
+ <code-block :code="agentRegistrationCommand" />
+ </p>
+
+ <p>
+ <strong>{{ $options.i18n.advancedInstallTitle }}</strong>
+ </p>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.advancedInstallBody">
+ <template #link="{ content }">
+ <gl-link :href="advancedInstallPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+
+ <template #modal-footer>
+ <gl-button v-if="canCancel" @click="cancelClicked">{{ $options.i18n.cancel }} </gl-button>
+
+ <gl-button v-if="registered" variant="confirm" category="primary" @click="doneClicked"
+ >{{ $options.i18n.done }}
+ </gl-button>
+
+ <gl-button
+ v-else
+ :disabled="!nextButtonDisabled"
+ variant="confirm"
+ category="primary"
+ @click="registerAgent"
+ >{{ $options.i18n.next }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index f39678b73dc..0bade1fc281 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -1,4 +1,10 @@
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
+
+export const MAX_LIST_COUNT = 25;
+export const INSTALL_AGENT_MODAL_ID = 'install-agent';
+export const ACTIVE_CONNECTION_TIME = 480000;
+export const TROUBLESHOOTING_LINK =
+ 'https://docs.gitlab.com/ee/user/clusters/agent/#troubleshooting';
export const CLUSTER_ERRORS = {
default: {
@@ -58,3 +64,80 @@ export const STATUSES = {
deleting: { title: __('Deleting') },
creating: { title: __('Creating') },
};
+
+export const I18N_INSTALL_AGENT_MODAL = {
+ next: __('Next'),
+ done: __('Done'),
+ cancel: __('Cancel'),
+
+ modalTitle: s__('ClusterAgents|Install new Agent'),
+
+ selectAgentTitle: s__('ClusterAgents|Select which Agent you want to install'),
+ selectAgentBody: s__(
+ `ClusterAgents|Select the Agent you want to register with GitLab and install on your cluster. To learn more about the Kubernetes Agent registration process %{linkStart}go to the documentation%{linkEnd}.`,
+ ),
+
+ copyToken: s__('ClusterAgents|Copy token'),
+ tokenTitle: s__('ClusterAgents|Registration token'),
+ tokenBody: s__(
+ `ClusterAgents|The registration token will be used to connect the Agent on your cluster to GitLab. To learn more about the registration tokens and how they are used %{linkStart}go to the documentation%{linkEnd}.`,
+ ),
+
+ tokenSingleUseWarningTitle: s__(
+ 'ClusterAgents|The token value will not be shown again after you close this window.',
+ ),
+ tokenSingleUseWarningBody: s__(
+ `ClusterAgents|The recommended installation method provided below includes the token. If you want to follow the alternative installation method provided in the docs make sure you save the token value before you close the window.`,
+ ),
+
+ basicInstallTitle: s__('ClusterAgents|Recommended installation method'),
+ basicInstallBody: s__(
+ `Open a CLI and connect to the cluster you want to install the Agent in. Use this installation method to minimize any manual steps. The token is already included in the command.`,
+ ),
+
+ advancedInstallTitle: s__('ClusterAgents|Alternative installation methods'),
+ advancedInstallBody: s__(
+ 'ClusterAgents|For alternative installation methods %{linkStart}go to the documentation%{linkEnd}.',
+ ),
+
+ registrationErrorTitle: s__('Failed to register Agent'),
+ unknownError: s__('ClusterAgents|An unknown error occurred. Please try again.'),
+};
+
+export const I18N_AVAILABLE_AGENTS_DROPDOWN = {
+ selectAgent: s__('ClusterAgents|Select an Agent'),
+ registeringAgent: s__('ClusterAgents|Registering Agent'),
+};
+
+export const AGENT_STATUSES = {
+ active: {
+ name: s__('ClusterAgents|Connected'),
+ icon: 'status-success',
+ class: 'text-success-500',
+ tooltip: {
+ title: sprintf(s__('ClusterAgents|Last connected %{timeAgo}.')),
+ },
+ },
+ inactive: {
+ name: s__('ClusterAgents|Not connected'),
+ icon: 'severity-critical',
+ class: 'text-danger-800',
+ tooltip: {
+ title: s__('ClusterAgents|Agent might not be connected to GitLab'),
+ body: sprintf(
+ s__(
+ 'ClusterAgents|The Agent has not been connected in a long time. There might be a connectivity issue. Last contact was %{timeAgo}.',
+ ),
+ ),
+ },
+ },
+ unused: {
+ name: s__('ClusterAgents|Never connected'),
+ icon: 'status-neutral',
+ class: 'text-secondary-400',
+ tooltip: {
+ title: s__('ClusterAgents|Agent never connected to GitLab'),
+ body: s__('ClusterAgents|Make sure you are using a valid token.'),
+ },
+ },
+};
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
new file mode 100644
index 00000000000..c29756159f5
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent.mutation.graphql
@@ -0,0 +1,8 @@
+mutation createClusterAgent($input: CreateClusterAgentInput!) {
+ createClusterAgent(input: $input) {
+ clusterAgent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql
new file mode 100644
index 00000000000..e93580cf416
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/mutations/create_agent_token.mutation.graphql
@@ -0,0 +1,9 @@
+mutation createClusterAgentToken($input: ClusterAgentTokenCreateInput!) {
+ clusterAgentTokenCreate(input: $input) {
+ secret
+ token {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
new file mode 100644
index 00000000000..40b61337024
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/queries/agent_configurations.query.graphql
@@ -0,0 +1,15 @@
+query agentConfigurations($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ agentConfigurations {
+ nodes {
+ agentName
+ }
+ }
+
+ clusterAgents {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
new file mode 100644
index 00000000000..61989e00d9e
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/graphql/queries/get_agents.query.graphql
@@ -0,0 +1,47 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getAgents(
+ $defaultBranchName: String!
+ $projectPath: ID!
+ $first: Int
+ $last: Int
+ $afterAgent: String
+ $afterTree: String
+ $beforeAgent: String
+ $beforeTree: String
+) {
+ project(fullPath: $projectPath) {
+ clusterAgents(first: $first, last: $last, before: $beforeAgent, after: $afterAgent) {
+ nodes {
+ id
+ name
+ webPath
+ tokens {
+ nodes {
+ lastUsedAt
+ }
+ }
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+
+ repository {
+ tree(path: ".gitlab/agents", ref: $defaultBranchName) {
+ trees(first: $first, last: $last, after: $afterTree, before: $beforeTree) {
+ nodes {
+ name
+ path
+ webPath
+ }
+
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/index.js b/app/assets/javascripts/clusters_list/index.js
index daa82892773..de18965abbd 100644
--- a/app/assets/javascripts/clusters_list/index.js
+++ b/app/assets/javascripts/clusters_list/index.js
@@ -1,6 +1,11 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import loadClusters from './load_clusters';
+import loadAgents from './load_agents';
+
+Vue.use(VueApollo);
export default () => {
loadClusters(Vue);
+ loadAgents(Vue, VueApollo);
};
diff --git a/app/assets/javascripts/clusters_list/load_agents.js b/app/assets/javascripts/clusters_list/load_agents.js
new file mode 100644
index 00000000000..b77d386df20
--- /dev/null
+++ b/app/assets/javascripts/clusters_list/load_agents.js
@@ -0,0 +1,44 @@
+import createDefaultClient from '~/lib/graphql';
+import Agents from './components/agents.vue';
+
+export default (Vue, VueApollo) => {
+ const el = document.querySelector('#js-cluster-agents-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const defaultClient = createDefaultClient({}, { assumeImmutableResults: true });
+
+ const {
+ emptyStateImage,
+ defaultBranchName,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ kasAddress,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({ defaultClient }),
+ provide: {
+ emptyStateImage,
+ projectPath,
+ agentDocsUrl,
+ installDocsUrl,
+ getStartedDocsUrl,
+ integrationDocsUrl,
+ kasAddress,
+ },
+ render(createElement) {
+ return createElement(Agents, {
+ props: {
+ defaultBranchName,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
deleted file mode 100644
index 2fcd40a901d..00000000000
--- a/app/assets/javascripts/comment_type_toggle.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import DropLab from './droplab/drop_lab';
-import ISetter from './droplab/plugins/input_setter';
-
-// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = { ...ISetter };
-
-class CommentTypeToggle {
- constructor(opts = {}) {
- this.dropdownTrigger = opts.dropdownTrigger;
- this.dropdownList = opts.dropdownList;
- this.noteTypeInput = opts.noteTypeInput;
- this.submitButton = opts.submitButton;
- this.closeButton = opts.closeButton;
- this.reopenButton = opts.reopenButton;
- }
-
- initDroplab() {
- this.droplab = new DropLab();
-
- const config = this.setConfig();
-
- this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
- }
-
- setConfig() {
- const config = {
- InputSetter: [
- {
- input: this.noteTypeInput,
- valueAttribute: 'data-value',
- },
- {
- input: this.submitButton,
- valueAttribute: 'data-submit-text',
- },
- ],
- };
-
- if (this.closeButton) {
- config.InputSetter.push(
- {
- input: this.closeButton,
- valueAttribute: 'data-close-text',
- },
- {
- input: this.closeButton,
- valueAttribute: 'data-close-text',
- inputAttribute: 'data-alternative-text',
- },
- );
- }
-
- if (this.reopenButton) {
- config.InputSetter.push(
- {
- input: this.reopenButton,
- valueAttribute: 'data-reopen-text',
- },
- {
- input: this.reopenButton,
- valueAttribute: 'data-reopen-text',
- inputAttribute: 'data-alternative-text',
- },
- );
- }
-
- return config;
- }
-}
-
-export default CommentTypeToggle;
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 82a449ae6af..89182b3a09f 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -112,6 +112,15 @@ export default {
@execute="trackToolbarControlExecution"
/>
<toolbar-button
+ data-testid="details"
+ content-type="details"
+ icon-name="details-block"
+ class="gl-mx-2"
+ editor-command="toggleDetails"
+ :label="__('Add a collapsible section')"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
data-testid="horizontal-rule"
content-type="horizontalRule"
icon-name="dash"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/details.vue b/app/assets/javascripts/content_editor/components/wrappers/details.vue
new file mode 100644
index 00000000000..aff15ac3e53
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/details.vue
@@ -0,0 +1,33 @@
+<script>
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+
+export default {
+ name: 'DetailsWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ open: true,
+ };
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-flex">
+ <div
+ class="details-toggle-icon"
+ data-testid="details-toggle-icon"
+ :class="{ 'is-open': open }"
+ @click="open = !open"
+ ></div>
+ <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue
new file mode 100644
index 00000000000..97b69afd12e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue
@@ -0,0 +1,32 @@
+<script>
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+import { __ } from '~/locale';
+
+export default {
+ name: 'FrontMatter',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ frontmatter: __('frontmatter'),
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-relative code highlight" as="pre">
+ <span
+ data-testid="frontmatter-label"
+ class="gl-absolute gl-top-0 gl-right-3"
+ contenteditable="false"
+ >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ >
+ <node-view-content as="code" />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 8f2ce8feb5d..9329bbcb2c7 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -2,7 +2,7 @@ import { ContentEditor } from './index';
export default {
component: ContentEditor,
- title: 'Components/Content Editor',
+ title: 'content_editor/components/content_editor',
};
const Template = (_, { argTypes }) => ({
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 25f5837d2a6..1ed1ab0315f 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -11,7 +11,8 @@ export default CodeBlockLowlight.extend({
parseHTML: (element) => extractLanguage(element),
},
class: {
- default: 'code highlight js-syntax-highlight',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ default: 'code highlight',
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js
new file mode 100644
index 00000000000..deb5029a1f0
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/color_chip.js
@@ -0,0 +1,73 @@
+import { Node } from '@tiptap/core';
+import { Plugin, PluginKey } from 'prosemirror-state';
+import { Decoration, DecorationSet } from 'prosemirror-view';
+import { isValidColorExpression } from '~/lib/utils/color_utils';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+const colorExpressionTypes = ['#', 'hsl', 'rgb'];
+
+const isValidColor = (color) => {
+ if (!colorExpressionTypes.some((type) => color.toLowerCase().startsWith(type))) {
+ return false;
+ }
+
+ return isValidColorExpression(color);
+};
+
+const highlightColors = (doc) => {
+ const decorations = [];
+
+ doc.descendants((node, position) => {
+ const { text, marks } = node;
+
+ if (!text || marks.length === 0 || marks[0].type.name !== 'code' || !isValidColor(text)) {
+ return;
+ }
+
+ const from = position;
+ const to = from + text.length;
+ const decoration = Decoration.inline(from, to, {
+ class: 'gl-display-inline-flex gl-align-items-center content-editor-color-chip',
+ style: `--gl-color-chip-color: ${text}`,
+ });
+
+ decorations.push(decoration);
+ });
+
+ return DecorationSet.create(doc, decorations);
+};
+
+export const colorDecoratorPlugin = new Plugin({
+ key: new PluginKey('colorDecorator'),
+ state: {
+ init(_, { doc }) {
+ return highlightColors(doc);
+ },
+ apply(transaction, oldState) {
+ return transaction.docChanged ? highlightColors(transaction.doc) : oldState;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state);
+ },
+ },
+});
+
+export default Node.create({
+ name: 'colorChip',
+
+ parseHTML() {
+ return [
+ {
+ tag: '.gfm-color_chip',
+ ignore: true,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ addProseMirrorPlugins() {
+ return [colorDecoratorPlugin];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js
new file mode 100644
index 00000000000..e3d54ed01fd
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/details.js
@@ -0,0 +1,36 @@
+import { Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+import DetailsWrapper from '../components/wrappers/details.vue';
+
+export const inputRegex = /^\s*(<details>)$/;
+
+export default Node.create({
+ name: 'details',
+ content: 'detailsContent+',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ group: 'block list',
+
+ parseHTML() {
+ return [{ tag: 'details' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['ul', HTMLAttributes, 0];
+ },
+
+ addNodeView() {
+ return VueNodeViewRenderer(DetailsWrapper);
+ },
+
+ addInputRules() {
+ return [wrappingInputRule(inputRegex, this.type)];
+ },
+
+ addCommands() {
+ return {
+ setDetails: () => ({ commands }) => commands.wrapInList('details'),
+ toggleDetails: () => ({ commands }) => commands.toggleList('details', 'detailsContent'),
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js
new file mode 100644
index 00000000000..fb6c49d91aa
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/details_content.js
@@ -0,0 +1,25 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export default Node.create({
+ name: 'detailsContent',
+ content: 'block+',
+ defining: true,
+
+ parseHTML() {
+ return [
+ { tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['li', HTMLAttributes, 0];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: () => this.editor.commands.splitListItem('detailsContent'),
+ 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'),
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js
new file mode 100644
index 00000000000..64c84fe046b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js
@@ -0,0 +1,20 @@
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import FrontmatterWrapper from '../components/wrappers/frontmatter.vue';
+import CodeBlockHighlight from './code_block_highlight';
+
+export default CodeBlockHighlight.extend({
+ name: 'frontmatter',
+ parseHTML() {
+ return [
+ {
+ tag: 'pre[data-lang-params="frontmatter"]',
+ preserveWhitespace: 'full',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+ addNodeView() {
+ return new VueNodeViewRenderer(FrontmatterWrapper);
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js
new file mode 100644
index 00000000000..60f5288dcf6
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/math_inline.js
@@ -0,0 +1,35 @@
+import { Mark, markInputRule } from '@tiptap/core';
+import { __ } from '~/locale';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm;
+
+export default Mark.create({
+ name: 'mathInline',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'code.math[data-math-style=inline]',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ 'code',
+ {
+ title: __('Inline math'),
+ 'data-toggle': 'tooltip',
+ class: 'gl-inset-border-1-gray-400',
+ ...HTMLAttributes,
+ },
+ 0,
+ ];
+ },
+
+ addInputRules() {
+ return [markInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
new file mode 100644
index 00000000000..9e31158837e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
@@ -0,0 +1,51 @@
+import { Node } from '@tiptap/core';
+import { InputRule } from 'prosemirror-inputrules';
+import { s__ } from '~/locale';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/];
+
+export default Node.create({
+ name: 'tableOfContents',
+
+ inline: false,
+
+ group: 'block',
+
+ parseHTML() {
+ return [
+ {
+ tag: 'ul.section-nav',
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ ];
+ },
+
+ renderHTML() {
+ return [
+ 'div',
+ {
+ class:
+ 'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5',
+ },
+ s__('ContentEditor|Table of Contents'),
+ ];
+ },
+
+ addInputRules() {
+ const { type } = this;
+
+ return inputRuleRegExps.map(
+ (regex) =>
+ new InputRule(regex, (state, match, start, end) => {
+ const { tr } = state;
+
+ if (match) {
+ tr.replaceWith(start - 1, end, type.create());
+ }
+
+ return tr;
+ }),
+ );
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js
new file mode 100644
index 00000000000..93b42466850
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/word_break.js
@@ -0,0 +1,29 @@
+import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core';
+
+export const inputRegex = /^<wbr>$/;
+
+export default Node.create({
+ name: 'wordBreak',
+ inline: true,
+ group: 'inline',
+ selectable: false,
+ atom: true,
+
+ defaultOptions: {
+ HTMLAttributes: {
+ class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm',
+ },
+ },
+
+ parseHTML() {
+ return [{ tag: 'wbr' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), '-'];
+ },
+
+ addInputRules() {
+ return [nodeInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 9b2d4c9a062..385f1c63801 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -8,14 +8,18 @@ import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import ColorChip from '../extensions/color_chip';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
+import Details from '../extensions/details';
+import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
+import Frontmatter from '../extensions/frontmatter';
import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
@@ -28,6 +32,7 @@ import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
import Loading from '../extensions/loading';
+import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
@@ -37,11 +42,13 @@ import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
import TableHeader from '../extensions/table_header';
+import TableOfContents from '../extensions/table_of_contents';
import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import Video from '../extensions/video';
+import WordBreak from '../extensions/word_break';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
@@ -75,15 +82,19 @@ export const createContentEditor = ({
Bold,
BulletList,
Code,
+ ColorChip,
CodeBlockHighlight,
DescriptionItem,
DescriptionList,
+ Details,
+ DetailsContent,
Document,
Division,
Dropcursor,
Emoji,
Figure,
FigureCaption,
+ Frontmatter,
Gapcursor,
HardBreak,
Heading,
@@ -96,6 +107,7 @@ export const createContentEditor = ({
Link,
ListItem,
Loading,
+ MathInline,
OrderedList,
Paragraph,
Reference,
@@ -104,12 +116,14 @@ export const createContentEditor = ({
Superscript,
TableCell,
TableHeader,
+ TableOfContents,
TableRow,
Table,
TaskItem,
TaskList,
Text,
Video,
+ WordBreak,
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index bc6d98511f9..0dd3cb5b73f 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -11,10 +11,13 @@ import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list';
+import Details from '../extensions/details';
+import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
+import Frontmatter from '../extensions/frontmatter';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
@@ -24,6 +27,7 @@ import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
import Link from '../extensions/link';
import ListItem from '../extensions/list_item';
+import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
@@ -33,11 +37,13 @@ import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
import TableHeader from '../extensions/table_header';
+import TableOfContents from '../extensions/table_of_contents';
import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import Video from '../extensions/video';
+import WordBreak from '../extensions/word_break';
import {
isPlainURL,
renderHardBreak,
@@ -50,6 +56,7 @@ import {
renderImage,
renderPlayable,
renderHTMLNode,
+ renderContent,
} from './serialization_helpers';
const defaultSerializerConfig = {
@@ -80,6 +87,11 @@ const defaultSerializerConfig = {
: `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
+ [MathInline.name]: {
+ open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`,
+ close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`,
+ escape: false,
+ },
[Strike.name]: {
open: '~~',
close: '~~',
@@ -130,11 +142,34 @@ const defaultSerializerConfig = {
renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node);
if (index === parent.childCount - 1) state.ensureNewLine();
},
+ [Details.name]: renderHTMLNode('details', true),
+ [DetailsContent.name]: (state, node, parent, index) => {
+ if (!index) renderHTMLNode('summary')(state, node);
+ else {
+ if (index === 1) state.ensureNewLine();
+ renderContent(state, node);
+ if (index === parent.childCount - 1) state.ensureNewLine();
+ }
+ },
[Emoji.name]: (state, node) => {
const { name } = node.attrs;
state.write(`:${name}:`);
},
+ [Frontmatter.name]: (state, node) => {
+ const { language } = node.attrs;
+ const syntax = {
+ toml: '+++',
+ json: ';;;',
+ yaml: '---',
+ }[language];
+
+ state.write(`${syntax}\n`);
+ state.text(node.textContent, false);
+ state.ensureNewLine();
+ state.write(syntax);
+ state.closeBlock(node);
+ },
[Figure.name]: renderHTMLNode('figure'),
[FigureCaption.name]: renderHTMLNode('figcaption'),
[HardBreak.name]: renderHardBreak,
@@ -147,6 +182,10 @@ const defaultSerializerConfig = {
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
+ [TableOfContents.name]: (state, node) => {
+ state.write('[[_TOC_]]');
+ state.closeBlock(node);
+ },
[Table.name]: renderTable,
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
@@ -161,6 +200,7 @@ const defaultSerializerConfig = {
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
+ [WordBreak.name]: (state) => state.write('<wbr>'),
},
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 4aee02e45c8..9d4eddc510a 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -293,7 +293,7 @@ export default {
:items="roles"
:loading="isLoadingRoles"
:loading-text="s__('ClusterIntegration|Loading IAM Roles')"
- :placeholder="s__('ClusterIntergation|Select service role')"
+ :placeholder="s__('ClusterIntegration|Select service role')"
:search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
:empty-text="s__('ClusterIntegration|No IAM Roles found')"
:has-errors="Boolean(loadingRolesError)"
@@ -330,7 +330,7 @@ export default {
:disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')"
:loading="isLoadingKeyPairs"
:loading-text="s__('ClusterIntegration|Loading Key Pairs')"
- :placeholder="s__('ClusterIntergation|Select key pair')"
+ :placeholder="s__('ClusterIntegration|Select key pair')"
:search-field-placeholder="s__('ClusterIntegration|Search Key Pairs')"
:empty-text="s__('ClusterIntegration|No Key Pairs found')"
:has-errors="Boolean(loadingKeyPairsError)"
@@ -359,7 +359,7 @@ export default {
:disabled="vpcDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a region to choose a VPC')"
:loading-text="s__('ClusterIntegration|Loading VPCs')"
- :placeholder="s__('ClusterIntergation|Select a VPC')"
+ :placeholder="s__('ClusterIntegration|Select a VPC')"
:search-field-placeholder="s__('ClusterIntegration|Search VPCs')"
:empty-text="s__('ClusterIntegration|No VPCs found')"
:has-errors="Boolean(loadingVpcsError)"
@@ -389,7 +389,7 @@ export default {
:disabled="subnetDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a VPC to choose a subnet')"
:loading-text="s__('ClusterIntegration|Loading subnets')"
- :placeholder="s__('ClusterIntergation|Select a subnet')"
+ :placeholder="s__('ClusterIntegration|Select a subnet')"
:search-field-placeholder="s__('ClusterIntegration|Search subnets')"
:empty-text="s__('ClusterIntegration|No subnet found')"
:has-errors="displaySubnetError"
@@ -420,7 +420,7 @@ export default {
:disabled="securityGroupDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a VPC to choose a security group')"
:loading-text="s__('ClusterIntegration|Loading security groups')"
- :placeholder="s__('ClusterIntergation|Select a security group')"
+ :placeholder="s__('ClusterIntegration|Select a security group')"
:search-field-placeholder="s__('ClusterIntegration|Search security groups')"
:empty-text="s__('ClusterIntegration|No security group found')"
:has-errors="Boolean(loadingSecurityGroupsError)"
@@ -451,7 +451,7 @@ export default {
:items="instanceTypes"
:loading="isLoadingInstanceTypes"
:loading-text="s__('ClusterIntegration|Loading instance types')"
- :placeholder="s__('ClusterIntergation|Select an instance type')"
+ :placeholder="s__('ClusterIntegration|Select an instance type')"
:search-field-placeholder="s__('ClusterIntegration|Search instance types')"
:empty-text="s__('ClusterIntegration|No instance type found')"
:has-errors="Boolean(loadingInstanceTypesError)"
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
index 12b6070a79a..8f18ac29c0f 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
@@ -43,7 +43,7 @@ export default {
:loading="isLoadingItems"
:has-errors="Boolean(loadingItemsError)"
:loading-text="s__('ClusterIntegration|Loading networks')"
- :placeholder="s__('ClusterIntergation|Select a network')"
+ :placeholder="s__('ClusterIntegration|Select a network')"
:search-field-placeholder="s__('ClusterIntegration|Search networks')"
:empty-text="s__('ClusterIntegration|No networks found')"
:error-message="s__('ClusterIntegration|Could not load networks')"
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
index ec7889e2907..dab4adc3789 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
@@ -34,7 +34,7 @@ export default {
:loading="isLoadingItems"
:has-errors="Boolean(loadingItemsError)"
:loading-text="s__('ClusterIntegration|Loading subnetworks')"
- :placeholder="s__('ClusterIntergation|Select a subnetwork')"
+ :placeholder="s__('ClusterIntegration|Select a subnetwork')"
:search-field-placeholder="s__('ClusterIntegration|Search subnetworks')"
:empty-text="s__('ClusterIntegration|No subnetworks found')"
:error-message="s__('ClusterIntegration|Could not load subnetworks')"
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 1c0dab11392..f4a27dc7d1f 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -5,8 +5,8 @@ import {
canCreateConfidentialMergeRequest,
} from './confidential_merge_request';
import confidentialMergeRequestState from './confidential_merge_request/state';
-import DropLab from './droplab/drop_lab';
-import ISetter from './droplab/plugins/input_setter';
+import DropLab from './filtered_search/droplab/drop_lab_deprecated';
+import ISetter from './filtered_search/droplab/plugins/input_setter';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __, sprintf } from './locale';
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index ae78ce33263..1d98a42ce58 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -51,6 +51,7 @@ export default {
'features',
'createdBefore',
'createdAfter',
+ 'pagination',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
@@ -99,7 +100,12 @@ export default {
},
},
methods: {
- ...mapActions(['fetchStageData', 'setSelectedStage', 'setDateRange']),
+ ...mapActions([
+ 'fetchStageData',
+ 'setSelectedStage',
+ 'setDateRange',
+ 'updateStageTablePagination',
+ ]),
onSetDateRange({ startDate, endDate }) {
this.setDateRange({
createdAfter: new Date(startDate),
@@ -108,6 +114,7 @@ export default {
},
onSelectStage(stage) {
this.setSelectedStage(stage);
+ this.updateStageTablePagination({ ...this.pagination, page: 1 });
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
@@ -117,6 +124,9 @@ export default {
const { permissions } = this;
return Boolean(permissions?.[id]);
},
+ onHandleUpdatePagination(data) {
+ this.updateStageTablePagination(data);
+ },
},
dayRangeOptions: [7, 30, 90],
i18n: {
@@ -163,8 +173,8 @@ export default {
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
- :pagination="null"
- :sortable="false"
+ :pagination="pagination"
+ @handleUpdatePagination="onHandleUpdatePagination"
/>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
index 5140b05e189..016fea354fe 100644
--- a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
+++ b/app/assets/javascripts/cycle_analytics/components/filter_bar.vue
@@ -79,7 +79,6 @@ export default {
title: __('Assignees'),
type: 'assignees',
token: AuthorToken,
- defaultAuthors: [],
initialAuthors: this.assigneesData,
unique: false,
operators: OPERATOR_IS_ONLY,
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 8a2667a4ab1..fc4dfafb809 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -194,6 +194,9 @@ export default {
><formatted-stage-count :stage-count="stageCount"
/></gl-badge>
</template>
+ <template #head(duration)="data">
+ <span data-testid="vsa-stage-header-duration">{{ data.label }}</span>
+ </template>
<template #cell(end_event)="{ item }">
<div data-testid="vsa-stage-event">
<div v-if="item.id" data-testid="vsa-stage-content">
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index c1be2ce7096..c205aa1e831 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -44,7 +44,7 @@ export const METRICS_POPOVER_CONTENT = {
},
'cycle-time': {
description: s__(
- 'ValueStreamAnalytics|Median time from issue first merge request created to issue closed.',
+ "ValueStreamAnalytics|Median time from the earliest commit of a linked issue's merge request to when that issue is closed.",
),
},
'new-issue': { description: s__('ValueStreamAnalytics|Number of new issues created.') },
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 620da0104e0..34ef03409b8 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -45,6 +45,7 @@ export default () => {
new Vue({
el,
name: 'CycleAnalytics',
+ apolloProvider: {},
store,
render: (createElement) =>
createElement(CycleAnalytics, {
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index e39cd224199..24b62849db7 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -6,6 +6,7 @@ import {
getValueStreamStageRecords,
getValueStreamStageCounts,
} from '~/api/analytics_api';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEFAULT_VALUE_STREAM, I18N_VSA_ERROR_STAGE_MEDIAN } from '../constants';
@@ -72,16 +73,21 @@ export const fetchCycleAnalyticsData = ({
});
};
-export const fetchStageData = ({ getters: { requestParams, filterParams }, commit }) => {
+export const fetchStageData = ({
+ getters: { requestParams, filterParams, paginationParams },
+ commit,
+}) => {
commit(types.REQUEST_STAGE_DATA);
- return getValueStreamStageRecords(requestParams, filterParams)
- .then(({ data }) => {
+ return getValueStreamStageRecords(requestParams, { ...filterParams, ...paginationParams })
+ .then(({ data, headers }) => {
// when there's a query timeout, the request succeeds but the error is encoded in the response data
if (data?.error) {
commit(types.RECEIVE_STAGE_DATA_ERROR, data.error);
} else {
commit(types.RECEIVE_STAGE_DATA_SUCCESS, data);
+ const { page = null, nextPage = null } = parseIntPagination(normalizeHeaders(headers));
+ commit(types.SET_PAGINATION, { ...paginationParams, page, hasNextPage: Boolean(nextPage) });
}
})
.catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR));
@@ -176,6 +182,14 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore
return refetchStageData(dispatch);
};
+export const updateStageTablePagination = (
+ { commit, dispatch, state: { selectedStage } },
+ paginationParams,
+) => {
+ commit(types.SET_PAGINATION, paginationParams);
+ return dispatch('fetchStageData', selectedStage.id);
+};
+
export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 77c285f5ce0..962e1d50d12 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -1,6 +1,7 @@
import dateFormat from 'dateformat';
import { dateFormats } from '~/analytics/shared/constants';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import { PAGINATION_TYPE } from '../constants';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
@@ -21,6 +22,13 @@ export const requestParams = (state) => {
return { requestPath: fullPath, valueStreamId, stageId };
};
+export const paginationParams = ({ pagination: { page, sort, direction } }) => ({
+ pagination: PAGINATION_TYPE,
+ sort,
+ direction,
+ page,
+});
+
const filterBarParams = ({ filters }) => {
const {
authors: { selected: selectedAuthor },
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 0d94aad2ca5..0ad67d4e6bd 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -4,6 +4,7 @@ export const SET_LOADING = 'SET_LOADING';
export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
+export const SET_PAGINATION = 'SET_PAGINATION';
export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS';
export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 301e7d95f8c..64930a5b51f 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -1,13 +1,24 @@
+import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants';
import { formatMedianValues } from '../utils';
import * as types from './mutation_types';
export default {
- [types.INITIALIZE_VSA](state, { endpoints, features, createdBefore, createdAfter }) {
+ [types.INITIALIZE_VSA](
+ state,
+ { endpoints, features, createdBefore, createdAfter, pagination = {} },
+ ) {
state.endpoints = endpoints;
state.createdBefore = createdBefore;
state.createdAfter = createdAfter;
state.features = features;
+
+ Vue.set(state, 'pagination', {
+ page: pagination.page ?? state.pagination.page,
+ sort: pagination.sort ?? state.pagination.sort,
+ direction: pagination.direction ?? state.pagination.direction,
+ });
},
[types.SET_LOADING](state, loadingState) {
state.isLoading = loadingState;
@@ -22,6 +33,14 @@ export default {
state.createdBefore = createdBefore;
state.createdAfter = createdAfter;
},
+ [types.SET_PAGINATION](state, { page, hasNextPage, sort, direction }) {
+ Vue.set(state, 'pagination', {
+ page,
+ hasNextPage,
+ sort: sort || PAGINATION_SORT_FIELD_END_EVENT,
+ direction: direction || PAGINATION_SORT_DIRECTION_DESC,
+ });
+ },
[types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = [];
},
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 0882db51218..52bc01cafa4 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -1,3 +1,8 @@
+import {
+ PAGINATION_SORT_FIELD_END_EVENT,
+ PAGINATION_SORT_DIRECTION_DESC,
+} from '~/cycle_analytics/constants';
+
export default () => ({
id: null,
features: {},
@@ -20,4 +25,10 @@ export default () => ({
isLoadingStage: false,
isEmptyStage: false,
permissions: {},
+ pagination: {
+ page: null,
+ hasNextPage: false,
+ sort: PAGINATION_SORT_FIELD_END_EVENT,
+ direction: PAGINATION_SORT_DIRECTION_DESC,
+ },
});
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
index fa02fdf914a..3c6267bac06 100644
--- a/app/assets/javascripts/cycle_analytics/utils.js
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -1,13 +1,10 @@
import dateFormat from 'dateformat';
-import { unescape } from 'lodash';
import { dateFormats } from '~/analytics/shared/constants';
import { hideFlash } from '~/flash';
-import { sanitize } from '~/lib/dompurify';
-import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { getDateInPast } from '~/lib/utils/datetime/date_calculation_utility';
import { parseSeconds } from '~/lib/utils/datetime_utility';
+import { formatTimeAsSummary } from '~/lib/utils/datetime/date_format_utility';
import { slugify } from '~/lib/utils/text_utility';
-import { s__, sprintf } from '../locale';
export const removeFlash = (type = 'alert') => {
const flashEl = document.querySelector(`.flash-${type}`);
@@ -45,29 +42,6 @@ export const transformStagesForPathNavigation = ({
return formattedStages;
};
-export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, weeks, months }) => {
- if (months) {
- return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
- value: roundToNearestHalf(months),
- });
- } else if (weeks) {
- return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
- value: roundToNearestHalf(weeks),
- });
- } else if (days) {
- return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
- value: roundToNearestHalf(days),
- });
- } else if (hours) {
- return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
- } else if (minutes) {
- return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
- } else if (seconds) {
- return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
- }
- return '-';
-};
-
/**
* Takes a raw median value in seconds and converts it to a string representation
* ie. converts 172800 => 2d (2 days)
@@ -76,7 +50,7 @@ export const timeSummaryForPathNavigation = ({ seconds, hours, days, minutes, we
* @returns {String} String representation ie 2w
*/
export const medianTimeToParsedSeconds = (value) =>
- timeSummaryForPathNavigation({
+ formatTimeAsSummary({
...parseSeconds(value, { daysPerWeek: 7, hoursPerDay: 24 }),
seconds: value,
});
diff --git a/app/assets/javascripts/dependency_proxy.js b/app/assets/javascripts/dependency_proxy.js
deleted file mode 100644
index ddf5703b28f..00000000000
--- a/app/assets/javascripts/dependency_proxy.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import setupToggleButtons from '~/toggle_buttons';
-
-export default () => {
- setupToggleButtons(document.querySelector('.js-dependency-proxy-toggle-area'));
-};
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
index 051ab710e5f..7acb5549273 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -25,7 +25,7 @@ export default {
lazy: true,
},
translations: {
- cronPlaceholder: __('* * * * *'),
+ cronPlaceholder: '* * * * *',
cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
),
diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js
index a42b50edb8a..4ab3f140b61 100644
--- a/app/assets/javascripts/deprecated_notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -19,9 +19,10 @@ import Vue from 'vue';
import '~/lib/utils/jquery_at_who';
import AjaxCache from '~/lib/utils/ajax_cache';
import syntaxHighlight from '~/syntax_highlight';
+import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
+import * as constants from '~/notes/constants';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
-import CommentTypeToggle from './comment_type_toggle';
import createFlash from './flash';
import { defaultAutocompleteConfig } from './gfm_auto_complete';
import GLForm from './gl_form';
@@ -128,7 +129,13 @@ export default class Notes {
this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this));
this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit);
// Reopen and close actions for Issue/MR combined with note form submit
- this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment);
+ this.$wrapperEl.on(
+ 'click',
+ // this oddly written selector needs to match the old style (input with class) as
+ // well as the new DOM styling from the Vue-based note form
+ 'input.js-comment-submit-button, .js-comment-submit-button > button:first-child',
+ this.postComment,
+ );
this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment);
this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons);
// resolve a discussion
@@ -201,23 +208,39 @@ export default class Notes {
}
static initCommentTypeToggle(form) {
- const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle');
- const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu');
+ const el = form.querySelector('.js-comment-type-dropdown');
+ const { noteableName } = el.dataset;
const noteTypeInput = form.querySelector('#note_type');
- const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button');
- const closeButton = form.querySelector('.js-note-target-close');
- const reopenButton = form.querySelector('.js-note-target-reopen');
-
- const commentTypeToggle = new CommentTypeToggle({
- dropdownTrigger,
- dropdownList,
- noteTypeInput,
- submitButton,
- closeButton,
- reopenButton,
- });
+ const formHasContent = form.querySelector('.js-note-text').value.trim().length > 0;
- commentTypeToggle.initDroplab();
+ form.commentTypeComponent = new Vue({
+ el,
+ data() {
+ return {
+ noteType: constants.COMMENT,
+ disabled: !formHasContent,
+ };
+ },
+ render(createElement) {
+ return createElement(CommentTypeDropdown, {
+ props: {
+ noteType: this.noteType,
+ noteableDisplayName: noteableName,
+ disabled: this.disabled,
+ },
+ on: {
+ change: (arg) => {
+ this.noteType = arg;
+ if (this.noteType === constants.DISCUSSION) {
+ noteTypeInput.value = constants.DISCUSSION_NOTE;
+ } else {
+ noteTypeInput.value = '';
+ }
+ },
+ },
+ });
+ },
+ });
}
keydownNoteText(e) {
@@ -1107,6 +1130,7 @@ export default class Notes {
const form = textarea.parents('form');
const reopenbtn = form.find('.js-note-target-reopen');
const closebtn = form.find('.js-note-target-close');
+ const commentTypeComponent = form.get(0)?.commentTypeComponent;
if (textarea.val().trim().length > 0) {
reopentext = reopenbtn.attr('data-alternative-text');
@@ -1123,6 +1147,9 @@ export default class Notes {
if (closebtn.is(':not(.btn-comment-and-close)')) {
closebtn.addClass('btn-comment-and-close');
}
+ if (commentTypeComponent) {
+ commentTypeComponent.disabled = false;
+ }
} else {
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
@@ -1138,6 +1165,9 @@ export default class Notes {
if (closebtn.is('.btn-comment-and-close')) {
closebtn.removeClass('btn-comment-and-close');
}
+ if (commentTypeComponent) {
+ commentTypeComponent.disabled = true;
+ }
}
}
@@ -1308,9 +1338,6 @@ export default class Notes {
}
cleanForm($form) {
- // Remove JS classes that are not needed here
- $form.find('.js-comment-type-dropdown').removeClass('btn-group');
-
// Remove dropdown
$form.find('.dropdown-menu').remove();
@@ -1505,6 +1532,8 @@ export default class Notes {
const $submitBtn = $(e.target);
$submitBtn.prop('disabled', true);
let $form = $submitBtn.parents('form');
+ const commentTypeComponent = $form.get(0)?.commentTypeComponent;
+ if (commentTypeComponent) commentTypeComponent.disabled = true;
const $closeBtn = $form.find('.js-note-target-close');
const isDiscussionNote =
$submitBtn.parent().find('li.droplab-item-selected').attr('id') === 'discussion';
@@ -1584,6 +1613,8 @@ export default class Notes {
const note = res.data;
$submitBtn.prop('disabled', false);
+ if (commentTypeComponent) commentTypeComponent.disabled = false;
+
// Submission successful! remove placeholder
$notesContainer.find(`#${noteUniqueId}`).remove();
@@ -1662,6 +1693,8 @@ export default class Notes {
// Submission failed, remove placeholder note and show Flash error message
$notesContainer.find(`#${noteUniqueId}`).remove();
$submitBtn.prop('disabled', false);
+ if (commentTypeComponent) commentTypeComponent.disabled = false;
+
const blurEvent = new CustomEvent('blur.imageDiff', {
detail: e,
});
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 38ea5406c02..837320b9423 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -273,7 +273,7 @@ export default {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
},
onDesignDeleteError(e) {
- this.onError(designDeletionError({ singular: true }), e);
+ this.onError(designDeletionError(), e);
},
onResolveDiscussionError(e) {
this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index e66ae822a34..5092c30aa60 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -255,7 +255,7 @@ export default {
if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
},
onDesignDeleteError() {
- const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
+ const errorMessage = designDeletionError(this.selectedDesigns.length);
createFlash({ message: errorMessage });
},
onDesignDropzoneError() {
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index 33c4fd5a7d9..c8f445bfb88 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -250,7 +250,7 @@ export const hasErrors = ({ errors = [] }) => errors?.length;
*/
export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
if (hasErrors(data)) {
- onError(data, designDeletionError({ singular: designs.length === 1 }));
+ onError(data, designDeletionError(designs.length));
} else {
deleteDesignsFromStore(store, query, designs);
addNewVersionToStore(store, query, data.version);
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index afee7e81791..981b50329b2 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -1,4 +1,3 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { __, s__, n__, sprintf } from '~/locale';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
@@ -27,12 +26,6 @@ export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
-const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
-
-const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
- 'The designs you tried uploading did not change.',
-)}`;
-
export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
'You can only upload one design when dropping onto an existing design.',
);
@@ -53,12 +46,9 @@ export const DELETE_DESIGN_TODO_ERROR = __('Failed to remove a to-do item for th
export const TOGGLE_TODO_ERROR = __('Failed to toggle the to-do status for the design.');
-const MAX_SKIPPED_FILES_LISTINGS = 5;
+const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped. %{reason}');
-const oneDesignSkippedMessage = (filename) =>
- `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
- filename,
- })}`;
+const MAX_SKIPPED_FILES_LISTINGS = 5;
/**
* Return warning message indicating that some (but not all) uploaded
@@ -66,25 +56,40 @@ const oneDesignSkippedMessage = (filename) =>
* @param {Array<{ filename }>} skippedFiles
*/
const someDesignsSkippedMessage = (skippedFiles) => {
- const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
- 'Some of the designs you tried uploading did not change:',
- )}`;
-
- const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
- moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
- });
-
- return `${designsSkippedMessage} ${skippedFiles
+ const skippedFilesList = skippedFiles
.slice(0, MAX_SKIPPED_FILES_LISTINGS)
.map(({ filename }) => filename)
- .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
+ .join(', ');
+
+ const uploadSkippedReason =
+ skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS
+ ? sprintf(
+ s__(
+ 'DesignManagement|Some of the designs you tried uploading did not change: %{skippedFiles} and %{moreCount} more.',
+ ),
+ {
+ skippedFiles: skippedFilesList,
+ moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
+ },
+ )
+ : sprintf(
+ s__(
+ 'DesignManagement|Some of the designs you tried uploading did not change: %{skippedFiles}.',
+ ),
+ { skippedFiles: skippedFilesList },
+ );
+
+ return sprintf(DESIGN_UPLOAD_SKIPPED_MESSAGE, {
+ reason: uploadSkippedReason,
+ });
};
-export const designDeletionError = ({ singular = true } = {}) => {
- const design = singular ? __('a design') : __('designs');
- return sprintf(s__('Could not archive %{design}. Please try again.'), {
- design,
- });
+export const designDeletionError = (designsCount = 1) => {
+ return n__(
+ 'Failed to archive a design. Please try again.',
+ 'Failed to archive designs. Please try again.',
+ designsCount,
+ );
};
/**
@@ -101,7 +106,18 @@ export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
if (skippedFiles.length === uploadedDesigns.length) {
const { filename } = skippedFiles[0];
- return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
+ const uploadSkippedReason = sprintf(
+ n__(
+ 'DesignManagement|%{filename} did not change.',
+ 'DesignManagement|The designs you tried uploading did not change.',
+ skippedFiles.length,
+ ),
+ { filename },
+ );
+
+ return sprintf(DESIGN_UPLOAD_SKIPPED_MESSAGE, {
+ reason: uploadSkippedReason,
+ });
}
return someDesignsSkippedMessage(skippedFiles);
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index a2ea42e963c..465f9836140 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -19,6 +19,7 @@ import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '../../notes/event_hub';
import {
@@ -79,6 +80,7 @@ export default {
MrWidgetHowToMergeModal,
GlAlert,
},
+ mixins: [glFeatureFlagsMixin()],
alerts: {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
@@ -252,6 +254,10 @@ export default {
return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
},
isLimitedContainer() {
+ if (this.glFeatures.mrChangesFluidLayout) {
+ return false;
+ }
+
return !this.renderFileTree && !this.isParallelView && !this.isFluidLayout;
},
isFullChangeset() {
@@ -386,6 +392,8 @@ export default {
diffsApp.instrument();
},
created() {
+ this.mergeRequestContainers = document.querySelectorAll('.merge-request-container');
+
this.adjustView();
this.subscribeToEvents();
@@ -513,6 +521,13 @@ export default {
} else {
this.removeEventListeners();
}
+
+ if (!this.isFluidLayout && this.glFeatures.mrChangesFluidLayout) {
+ this.mergeRequestContainers.forEach((el) => {
+ el.classList.toggle('limit-container-width', !this.shouldShow);
+ el.classList.toggle('container-limited', !this.shouldShow);
+ });
+ }
},
setEventListeners() {
Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1));
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index f098d20afd1..da918947cc5 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -100,6 +100,7 @@ export default {
variant="default"
icon="file-tree"
class="gl-mr-3 js-toggle-tree-list btn-icon"
+ data-qa-selector="file_tree_button"
:title="toggleFileBrowserTitle"
:aria-label="toggleFileBrowserTitle"
:selected="showTreeList"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 737c4d8f33c..4e33a02ca0e 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -1,4 +1,9 @@
<script>
+/* eslint-disable vue/no-v-html */
+/**
+NOTE: This file uses v-html over v-safe-html for performance reasons, see:
+https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57842
+* */
import { memoize } from 'lodash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import {
@@ -267,7 +272,9 @@ export default {
]"
class="diff-td line_content with-coverage left-side"
data-testid="left-content"
- v-html="$options.lineContent(props.line.left) /* eslint-disable-line vue/no-v-html */"
+ v-html="
+ $options.lineContent(props.line.left) /* v-html for performance, see top of file */
+ "
></div>
</template>
<template
@@ -389,7 +396,9 @@ export default {
},
]"
class="diff-td line_content with-coverage right-side parallel"
- v-html="$options.lineContent(props.line.right) /* eslint-disable-line vue/no-v-html */"
+ v-html="
+ $options.lineContent(props.line.right) /* v-html for performance, see top of file */
+ "
></div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 39ce849fc03..41d885d3dc1 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -62,7 +62,7 @@ export default {
</script>
<template>
- <div class="tree-list-holder d-flex flex-column">
+ <div class="tree-list-holder d-flex flex-column" data-qa-selector="file_tree_container">
<div class="gl-mb-3 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<gl-icon name="search" class="position-absolute tree-list-icon" />
diff --git a/app/assets/javascripts/diffs/utils/workers.js b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
index 985e75d1a17..985e75d1a17 100644
--- a/app/assets/javascripts/diffs/utils/workers.js
+++ b/app/assets/javascripts/diffs/utils/tree_worker_utils.js
diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js
index 6d1bc78ba1c..04010a99b52 100644
--- a/app/assets/javascripts/diffs/workers/tree_worker.js
+++ b/app/assets/javascripts/diffs/workers/tree_worker.js
@@ -1,5 +1,5 @@
import { sortTree } from '~/ide/stores/utils';
-import { generateTreeList } from '../utils/workers';
+import { generateTreeList } from '../utils/tree_worker_utils';
// eslint-disable-next-line no-restricted-globals
self.addEventListener('message', (e) => {
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index 410aaed86a7..7069568275d 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -1,6 +1,5 @@
-import Api from '~/api';
+import ciSchemaPath from '~/editor/schema/ci.json';
import { registerSchema } from '~/ide/utils';
-import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base';
export class CiSchemaExtension extends SourceEditorExtension {
@@ -16,12 +15,7 @@ export class CiSchemaExtension extends SourceEditorExtension {
* @param {String} opts.projectPath
* @param {String?} opts.ref - Current ref. Defaults to main
*/
- registerCiSchema({ projectNamespace, projectPath, ref } = {}) {
- const ciSchemaPath = Api.buildUrl(Api.projectFileSchemaPath)
- .replace(':namespace_path', projectNamespace)
- .replace(':project_path', projectPath)
- .replace(':ref', ref)
- .replace(':filename', EXTENSION_CI_SCHEMA_FILE_NAME_MATCH);
+ registerCiSchema() {
// In order for workers loaded from `data://` as the
// ones loaded by monaco editor, we use absolute URLs
// to fetch schema files, hence the `gon.gitlab_url`
diff --git a/app/assets/javascripts/editor/schema/NOTICE b/app/assets/javascripts/editor/schema/NOTICE
new file mode 100644
index 00000000000..60a7a81f082
--- /dev/null
+++ b/app/assets/javascripts/editor/schema/NOTICE
@@ -0,0 +1,6 @@
+Copyright (c) 2015-present Mads Kristensen
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
new file mode 100644
index 00000000000..0052bc00406
--- /dev/null
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -0,0 +1,1444 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://gitlab.com/.gitlab-ci.yml",
+ "title": "Gitlab CI configuration",
+ "description": "Gitlab has a built-in solution for doing CI called Gitlab CI. It is configured by supplying a file called `.gitlab-ci.yml`, which will list all the jobs that are going to run for the project. A full list of all options can be found at https://docs.gitlab.com/ee/ci/yaml/. You can read more about Gitlab CI at https://docs.gitlab.com/ee/ci/README.html.",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "format": "uri"
+ },
+ "image": { "$ref": "#/definitions/image" },
+ "services": { "$ref": "#/definitions/services" },
+ "before_script": { "$ref": "#/definitions/before_script" },
+ "after_script": { "$ref": "#/definitions/after_script" },
+ "variables": { "$ref": "#/definitions/globalVariables" },
+ "cache": { "$ref": "#/definitions/cache" },
+ "default": {
+ "type": "object",
+ "properties": {
+ "after_script": { "$ref": "#/definitions/after_script" },
+ "artifacts": { "$ref": "#/definitions/artifacts" },
+ "before_script": { "$ref": "#/definitions/before_script" },
+ "cache": { "$ref": "#/definitions/cache" },
+ "image": { "$ref": "#/definitions/image" },
+ "interruptible": { "$ref": "#/definitions/interruptible" },
+ "retry": { "$ref": "#/definitions/retry" },
+ "services": { "$ref": "#/definitions/services" },
+ "tags": { "$ref": "#/definitions/tags" },
+ "timeout": { "$ref": "#/definitions/timeout" }
+ },
+ "additionalProperties": false
+ },
+ "stages": {
+ "type": "array",
+ "description": "Groups jobs into stages. All jobs in one stage must complete before next stage is executed. Defaults to ['build', 'test', 'deploy'].",
+ "default": ["build", "test", "deploy"],
+ "items": {
+ "type": "string"
+ },
+ "uniqueItems": true,
+ "minItems": 1
+ },
+ "include": {
+ "description": "Can be `IncludeItem` or `IncludeItem[]`. Each `IncludeItem` will be a string, or an object with properties for the method if including external YAML file. The external content will be fetched, included and evaluated along the `.gitlab-ci.yml`.",
+ "oneOf": [
+ { "$ref": "#/definitions/include_item" },
+ {
+ "type": "array",
+ "items": { "$ref": "#/definitions/include_item" }
+ }
+ ]
+ },
+ "pages": {
+ "$ref": "#/definitions/job",
+ "description": "A special job used to upload static sites to Gitlab pages. Requires a `public/` directory with `artifacts.path` pointing to it."
+ },
+ "workflow": {
+ "type": "object",
+ "properties": {
+ "rules": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "if": {
+ "type": "string"
+ },
+ "variables": { "$ref": "#/definitions/variables" },
+ "when": {
+ "type": "string",
+ "enum": ["always", "never"]
+ }
+ },
+ "additionalProperties": false
+ }
+ }
+ }
+ }
+ },
+ "patternProperties": {
+ "^[.]": {
+ "description": "Hidden keys.",
+ "anyOf": [
+ { "$ref": "#/definitions/job_template" },
+ { "description": "Arbitrary YAML anchor." }
+ ]
+ }
+ },
+ "additionalProperties": {
+ "$ref": "#/definitions/job"
+ },
+ "definitions": {
+ "artifacts": {
+ "type": "object",
+ "description": "Used to specify a list of files and directories that should be attached to the job if it succeeds. Artifacts are sent to Gitlab where they can be downloaded.",
+ "additionalProperties": false,
+ "properties": {
+ "paths": {
+ "type": "array",
+ "description": "A list of paths to files/folders that should be included in the artifact.",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "exclude": {
+ "type": "array",
+ "description": "A list of paths to files/folders that should be excluded in the artifact.",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ },
+ "expose_as": {
+ "type": "string",
+ "description": "Can be used to expose job artifacts in the merge request UI. GitLab will add a link <expose_as> to the relevant merge request that points to the artifact."
+ },
+ "name": {
+ "type": "string",
+ "description": "Name for the archive created on job success. Can use variables in the name, e.g. '$CI_JOB_NAME'"
+ },
+ "untracked": {
+ "type": "boolean",
+ "description": "Whether to add all untracked files (along with 'artifacts.paths') to the artifact.",
+ "default": false
+ },
+ "when": {
+ "description": "Configure when artifacts are uploaded depended on job status.",
+ "default": "on_success",
+ "oneOf": [
+ {
+ "enum": ["on_success"],
+ "description": "Upload artifacts only when the job succeeds (this is the default)."
+ },
+ {
+ "enum": ["on_failure"],
+ "description": "Upload artifacts only when the job fails."
+ },
+ {
+ "enum": ["always"],
+ "description": "Upload artifacts regardless of job status."
+ }
+ ]
+ },
+ "expire_in": {
+ "type": "string",
+ "description": "How long artifacts should be kept. They are saved 30 days by default. Artifacts that have expired are removed periodically via cron job. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'.",
+ "default": "30 days"
+ },
+ "reports": {
+ "type": "object",
+ "description": "Reports will be uploaded as artifacts, and often displayed in the Gitlab UI, such as in Merge Requests.",
+ "additionalProperties": false,
+ "properties": {
+ "junit": {
+ "description": "Path for file(s) that should be parsed as JUnit XML result",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Path to a single XML file"
+ },
+ {
+ "type": "array",
+ "description": "A list of paths to XML files that will automatically be concatenated into a single file",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "cobertura": {
+ "description": "Path for file(s) that should be parsed as Cobertura XML coverage report",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Path to a single XML file"
+ },
+ {
+ "type": "array",
+ "description": "A list of paths to XML files that will automatically be merged into one report",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "codequality": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with code quality report(s) (such as Code Climate)."
+ },
+ "dotenv": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files containing runtime-created variables for this job."
+ },
+ "lsif": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files containing code intelligence (Language Server Index Format)."
+ },
+ "sast": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with SAST vulnerabilities report(s)."
+ },
+ "dependency_scanning": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with Dependency scanning vulnerabilities report(s)."
+ },
+ "container_scanning": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with Container scanning vulnerabilities report(s)."
+ },
+ "dast": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with DAST vulnerabilities report(s)."
+ },
+ "license_management": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Deprecated in 12.8: Path to file or list of files with license report(s)."
+ },
+ "license_scanning": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with license report(s)."
+ },
+ "performance": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with performance metrics report(s)."
+ },
+ "requirements": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with requirements report(s)."
+ },
+ "secret_detection": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with secret detection report(s)."
+ },
+ "metrics": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with custom metrics report(s)."
+ },
+ "terraform": {
+ "$ref": "#/definitions/string_file_list",
+ "description": "Path to file or list of files with terraform plan(s)."
+ }
+ }
+ }
+ }
+ },
+ "string_file_list": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "include_item": {
+ "oneOf": [
+ {
+ "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` will be of type `include:local`.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "^(https?://|/).+\\.ya?ml$"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "local": {
+ "description": "Relative path from local repository root (`/`) to the `yaml`/`yml` file template. The file must be on the same branch, and does not work across git submodules.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ },
+ "rules": { "$ref": "#/definitions/rules" }
+ },
+ "required": ["local"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "project": {
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ },
+ "ref": {
+ "description": "Branch/Tag/Commit-hash for the target project.",
+ "type": "string"
+ },
+ "file": {
+ "oneOf": [
+ {
+ "description": "Relative path from project root (`/`) to the `yaml`/`yml` file template.",
+ "type": "string",
+ "pattern": "\\.ya?ml$"
+ },
+ {
+ "description": "List of files by relative path from project root (`/`) to the `yaml`/`yml` file template.",
+ "type": "array",
+ "items": {
+ "type": "string",
+ "pattern": "\\.ya?ml$"
+ }
+ }
+ ]
+ }
+ },
+ "required": ["project", "file"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "template": {
+ "description": "Use a `.gitlab-ci.yml` template as a base, e.g. `Nodejs.gitlab-ci.yml`.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ },
+ "required": ["template"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "remote": {
+ "description": "URL to a `yaml`/`yml` template file using HTTP/HTTPS.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "^https?://.+\\.ya?ml$"
+ }
+ },
+ "required": ["remote"]
+ }
+ ]
+ },
+ "image": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed."
+ },
+ {
+ "type": "object",
+ "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor.",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed."
+ },
+ "entrypoint": {
+ "type": "array",
+ "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
+ "minItems": 1
+ }
+ },
+ "required": ["name"]
+ }
+ ],
+ "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor."
+ },
+ "services": {
+ "type": "array",
+ "description": "Similar to `image` property, but will link the specified services to the `image` container.",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1,
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed."
+ },
+ {
+ "type": "object",
+ "description": "",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Full name of the image that should be used. It should contain the Registry part if needed.",
+ "minLength": 1
+ },
+ "entrypoint": {
+ "type": "array",
+ "description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ },
+ "command": {
+ "type": "array",
+ "description": "Command or script that should be used as the container's command. It will be translated to arguments passed to Docker after the image's name. The syntax is similar to Dockerfile's CMD directive, where each shell token is a separate string in the array.",
+ "minItems": 1,
+ "items": {
+ "type": "string"
+ }
+ },
+ "alias": {
+ "type": "string",
+ "description": "Additional alias that can be used to access the service from the job's container. Read Accessing the services for more information.",
+ "minLength": 1
+ }
+ },
+ "required": ["name"]
+ }
+ ]
+ }
+ },
+ "secrets": {
+ "type": "object",
+ "description": "Defines secrets to be injected as environment variables",
+ "additionalProperties": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "object",
+ "description": "Environment variable name",
+ "properties": {
+ "vault": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "engine": {
+ "type": "object",
+ "properties": {
+ "name": { "type": "string" },
+ "path": { "type": "string" }
+ },
+ "required": ["name", "path"]
+ },
+ "path": { "type": "string" },
+ "field": { "type": "string" }
+ },
+ "required": ["engine", "path", "field"]
+ }
+ ]
+ }
+ },
+ "required": ["vault"]
+ }
+ }
+ },
+ "before_script": {
+ "type": "array",
+ "description": "Defines scripts that should run *before* the job. Can be set globally or per job.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "after_script": {
+ "type": "array",
+ "description": "Defines scripts that should run *after* the job. Can be set globally or per job.",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "rules": {
+ "type": "array",
+ "description": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job.",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "if": {
+ "type": "string",
+ "description": "Expression to evaluate whether additional attributes should be provided to the job"
+ },
+ "changes": {
+ "type": "array",
+ "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file",
+ "items": {
+ "type": "string"
+ }
+ },
+ "exists": {
+ "type": "array",
+ "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository",
+ "items": {
+ "type": "string"
+ }
+ },
+ "variables": { "$ref": "#/definitions/variables" },
+ "when": { "$ref": "#/definitions/when" },
+ "start_in": { "$ref": "#/definitions/start_in" },
+ "allow_failure": { "$ref": "#/definitions/allow_failure" }
+ }
+ }
+ },
+ "globalVariables": {
+ "description": "Defines environment variables globally. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. You can use the value and description keywords to define variables that are prefilled when running a pipeline manually.",
+ "type": "object",
+ "additionalProperties": {
+ "anyOf": [
+ {"type": ["string", "integer"]},
+ {
+ "type": "object",
+ "properties": {
+ "value": { "type": "string" },
+ "description": {
+ "type": "string",
+ "description": "Explains what the variable is used for, what the acceptable values are."
+ }
+ }
+ }
+ ]
+ }
+ },
+ "variables": {
+ "type": "object",
+ "description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.",
+ "additionalProperties": {
+ "type": ["string", "integer"]
+ }
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Allows you to configure a timeout for a specific job (e.g. `1 minute`, `1h 30m 12s`). Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#timeout",
+ "minLength": 1
+ },
+ "start_in": {
+ "type": "string",
+ "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job.",
+ "minLength": 1
+ },
+ "allow_failure": {
+ "description": "Allow job to fail. A failed job does not cause the pipeline to fail.",
+ "oneOf": [
+ {
+ "description": "Setting this option to true will allow the job to fail while still letting the pipeline pass.",
+ "type": "boolean",
+ "default": false
+ },
+ {
+ "description": "Exit code that are not considered failure. The job fails for any other exit code.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["exit_codes"],
+ "properties": {
+ "exit_codes": {
+ "type": "integer"
+ }
+ }
+ },
+ {
+ "description": "You can list which exit codes are not considered failures. The job fails for any other exit code.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["exit_codes"],
+ "properties": {
+ "exit_codes": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "when": {
+ "description": "Describes the conditions for when to run the job. Defaults to 'on_success'.",
+ "default": "on_success",
+ "oneOf": [
+ {
+ "enum": ["on_success"],
+ "description": "Execute job only when all jobs from prior stages succeed."
+ },
+ {
+ "enum": ["on_failure"],
+ "description": "Execute job when at least one job from prior stages fails."
+ },
+ {
+ "enum": ["always"],
+ "description": "Execute job regardless of the status from prior stages."
+ },
+ {
+ "enum": ["manual"],
+ "description": "Execute the job manually from Gitlab UI or API. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-manual"
+ },
+ {
+ "enum": ["delayed"],
+ "description": "Execute a job after the time limit in 'start_in' expires. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-delayed"
+ },
+ {
+ "enum": ["never"],
+ "description": "Never execute the job."
+ }
+ ]
+ },
+ "cache": {
+ "properties": {
+ "when": {
+ "description": "Defines when to save the cache, based on the status of the job.",
+ "default": "on_success",
+ "oneOf": [
+ {
+ "enum": ["on_success"],
+ "description": "Save the cache only when the job succeeds."
+ },
+ {
+ "enum": ["on_failure"],
+ "description": "Save the cache only when the job fails. "
+ },
+ {
+ "enum": ["always"],
+ "description": "Always save the cache. "
+ }
+ ]
+ }
+ }
+ },
+ "cache_entry": {
+ "type": "object",
+ "description": "Specify files or directories to cache between jobs. Can be set globally or per job.",
+ "additionalProperties": false,
+ "properties": {
+ "paths": {
+ "type": "array",
+ "description": "List of files or paths to cache.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "key": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Unique cache ID, to allow e.g. specific branch or job cache. Environment variables can be used to set up unique keys (e.g. \"$CI_COMMIT_REF_SLUG\" for per branch cache)."
+ },
+ {
+ "type": "object",
+ "description": "When you include cache:key:files, you must also list the project files that will be used to generate the key, up to a maximum of two files. The cache key will be a SHA checksum computed from the most recent commits (up to two, if two files are listed) that changed the given files.",
+ "properties": {
+ "files": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "maxItems": 2
+ }
+ }
+ }
+ ]
+ },
+ "untracked": {
+ "type": "boolean",
+ "description": "Set to `true` to cache untracked files.",
+ "default": false
+ },
+ "policy": {
+ "type": "string",
+ "description": "Determines the strategy for downloading and updating the cache.",
+ "default": "pull-push",
+ "oneOf": [
+ {
+ "enum": ["pull"],
+ "description": "Pull will download cache but skip uploading after job completes."
+ },
+ {
+ "enum": ["push"],
+ "description": "Push will skip downloading cache and always recreate cache after job completes."
+ },
+ {
+ "enum": ["pull-push"],
+ "description": "Pull-push will both download cache at job start and upload cache on job success."
+ }
+ ]
+ }
+ }
+ },
+ "filter_refs": {
+ "type": "array",
+ "description": "Filter job by different keywords that determine origin or state, or by supplying string/regex to check against branch/tag names.",
+ "items": {
+ "anyOf": [
+ {
+ "oneOf": [
+ {
+ "enum": ["branches"],
+ "description": "When a branch is pushed."
+ },
+ {
+ "enum": ["tags"],
+ "description": "When a tag is pushed."
+ },
+ {
+ "enum": ["api"],
+ "description": "When a pipeline has been triggered by a second pipelines API (not triggers API)."
+ },
+ {
+ "enum": ["external"],
+ "description": "When using CI services other than Gitlab"
+ },
+ {
+ "enum": ["pipelines"],
+ "description": "For multi-project triggers, created using the API with 'CI_JOB_TOKEN'."
+ },
+ {
+ "enum": ["pushes"],
+ "description": "Pipeline is triggered by a `git push` by the user"
+ },
+ {
+ "enum": ["schedules"],
+ "description": "For scheduled pipelines."
+ },
+ {
+ "enum": ["triggers"],
+ "description": "For pipelines created using a trigger token."
+ },
+ {
+ "enum": ["web"],
+ "description": "For pipelines created using *Run pipeline* button in Gitlab UI (under your project's *Pipelines*)."
+ }
+ ]
+ },
+ {
+ "type": "string",
+ "description": "String or regular expression to match against tag or branch names."
+ }
+ ]
+ }
+ },
+ "filter": {
+ "oneOf": [
+ {
+ "$ref": "#/definitions/filter_refs"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "refs": {
+ "$ref": "#/definitions/filter_refs"
+ },
+ "kubernetes": {
+ "enum": ["active"],
+ "description": "Filter job based on if Kubernetes integration is active."
+ },
+ "variables": {
+ "type": "array",
+ "description": "Filter job by checking comparing values of environment variables. Read more about variable expressions: https://docs.gitlab.com/ee/ci/variables/README.html#variables-expressions",
+ "items": {
+ "type": "string"
+ }
+ },
+ "changes": {
+ "type": "array",
+ "description": "Filter job creation based on files that were modified in a git push.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "retry": {
+ "description": "Retry a job if it fails. Can be a simple integer or object definition.",
+ "oneOf": [
+ { "$ref": "#/definitions/retry_max" },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "max": { "$ref": "#/definitions/retry_max" },
+ "when": {
+ "description": "Either a single or array of error types to trigger job retry.",
+ "oneOf": [
+ { "$ref": "#/definitions/retry_errors" },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/retry_errors"
+ }
+ }
+ ]
+ }
+ }
+ }
+ ]
+ },
+ "retry_max": {
+ "type": "integer",
+ "description": "The number of times the job will be retried if it fails. Defaults to 0 and can max be retried 2 times (3 times total).",
+ "default": 0,
+ "minimum": 0,
+ "maximum": 2
+ },
+ "retry_errors": {
+ "oneOf": [
+ {
+ "const": "always",
+ "description": "Retry on any failure (default)."
+ },
+ {
+ "const": "unknown_failure",
+ "description": "Retry when the failure reason is unknown."
+ },
+ {
+ "const": "script_failure",
+ "description": "Retry when the script failed."
+ },
+ {
+ "const": "api_failure",
+ "description": "Retry on API failure."
+ },
+ {
+ "const": "stuck_or_timeout_failure",
+ "description": "Retry when the job got stuck or timed out."
+ },
+ {
+ "const": "runner_system_failure",
+ "description": "Retry if there is a runner system failure (for example, job setup failed)."
+ },
+ {
+ "const": "runner_unsupported",
+ "description": "Retry if the runner is unsupported."
+ },
+ {
+ "const": "stale_schedule",
+ "description": "Retry if a delayed job could not be executed."
+ },
+ {
+ "const": "job_execution_timeout",
+ "description": "Retry if the script exceeded the maximum execution time set for the job."
+ },
+ {
+ "const": "archived_failure",
+ "description": "Retry if the job is archived and can’t be run."
+ },
+ {
+ "const": "unmet_prerequisites",
+ "description": "Retry if the job failed to complete prerequisite tasks."
+ },
+ {
+ "const": "scheduler_failure",
+ "description": "Retry if the scheduler failed to assign the job to a runner."
+ },
+ {
+ "const": "data_integrity_failure",
+ "description": "Retry if there is a structural integrity problem detected."
+ }
+ ]
+ },
+ "interruptible": {
+ "type": "boolean",
+ "description": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run.",
+ "default": false
+ },
+ "job": {
+ "allOf": [
+ { "$ref": "#/definitions/job_template" },
+ {
+ "anyOf": [
+ { "required": ["script"] },
+ { "required": ["extends"] },
+ { "required": ["trigger"] }
+ ]
+ }
+ ]
+ },
+ "job_template": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "image": { "$ref": "#/definitions/image" },
+ "services": { "$ref": "#/definitions/services" },
+ "before_script": { "$ref": "#/definitions/before_script" },
+ "after_script": { "$ref": "#/definitions/after_script" },
+ "rules": { "$ref": "#/definitions/rules" },
+ "variables": { "$ref": "#/definitions/variables" },
+ "cache": { "$ref": "#/definitions/cache" },
+ "secrets": { "$ref": "#/definitions/secrets" },
+ "script": {
+ "description": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues.",
+ "oneOf": [
+ {
+ "type": "string",
+ "minLength": 1
+ },
+ {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "stage": {
+ "type": "string",
+ "description": "Define what stage the job will run in.",
+ "default": "test"
+ },
+ "only": {
+ "$ref": "#/definitions/filter",
+ "description": "Job will run *only* when these filtering options match."
+ },
+ "extends": {
+ "description": "The name of one or more jobs to inherit configuration from.",
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1
+ }
+ ]
+ },
+ "needs": {
+ "description": "The list of jobs in previous stages whose sole completion is needed to start the current job.",
+ "type": "array",
+ "items": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "type": "string"
+ },
+ "artifacts": {
+ "type": "boolean"
+ },
+ "optional": {
+ "type": "boolean"
+ }
+ },
+ "required": ["job"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "pipeline": {
+ "type": "string"
+ },
+ "job": {
+ "type": "string"
+ },
+ "artifacts": {
+ "type": "boolean"
+ }
+ },
+ "required": ["job", "pipeline"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "job": {
+ "type": "string"
+ },
+ "project": {
+ "type": "string"
+ },
+ "ref": {
+ "type": "string"
+ },
+ "artifacts": {
+ "type": "boolean"
+ }
+ },
+ "required": ["job", "project", "ref"]
+ }
+ ]
+ }
+ },
+ "except": {
+ "$ref": "#/definitions/filter",
+ "description": "Job will run *except* for when these filtering options match."
+ },
+ "tags": {
+ "$ref": "#/definitions/tags"
+ },
+ "allow_failure": {
+ "$ref": "#/definitions/allow_failure"
+ },
+ "timeout": {
+ "$ref": "#/definitions/timeout"
+ },
+ "when": {
+ "$ref": "#/definitions/when"
+ },
+ "start_in": {
+ "$ref": "#/definitions/start_in"
+ },
+ "dependencies": {
+ "type": "array",
+ "description": "Specify a list of job names from earlier stages from which artifacts should be loaded. By default, all previous artifacts are passed. Use an empty array to skip downloading artifacts.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "artifacts": {
+ "$ref": "#/definitions/artifacts"
+ },
+ "environment": {
+ "description": "Used to associate environment metadata with a deploy. Environment can have a name and URL attached to it, and will be displayed under /environments under the project.",
+ "oneOf": [
+ { "type": "string" },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the environment, e.g. 'qa', 'staging', 'production'.",
+ "minLength": 1
+ },
+ "url": {
+ "type": "string",
+ "description": "When set, this will expose buttons in various places for the current environment in Gitlab, that will take you to the defined URL.",
+ "format": "uri",
+ "pattern": "^(https?://.+|\\$[A-Za-z]+)"
+ },
+ "on_stop": {
+ "type": "string",
+ "description": "The name of a job to execute when the environment is about to be stopped."
+ },
+ "action": {
+ "enum": ["start", "prepare", "stop"],
+ "description": "Specifies what this job will do. 'start' (default) indicates the job will start the deployment. 'prepare' indicates this will not affect the deployment. 'stop' indicates this will stop the deployment.",
+ "default": "start"
+ },
+ "auto_stop_in": {
+ "type": "string",
+ "description": "The amount of time it should take before Gitlab will automatically stop the environment. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'."
+ },
+ "kubernetes": {
+ "type": "object",
+ "description": "Used to configure the kubernetes deployment for this environment. This is currently not supported for kubernetes clusters that are managed by Gitlab.",
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "description": "The kubernetes namespace where this environment should be deployed to.",
+ "minLength": 1
+ }
+ }
+ },
+ "deployment_tier": {
+ "type": "string",
+ "description": "Explicitly specifies the tier of the deployment environment if non-standard environment name is used.",
+ "enum": [
+ "production",
+ "staging",
+ "testing",
+ "development",
+ "other"
+ ]
+ }
+ },
+ "required": ["name"]
+ }
+ ]
+ },
+ "release": {
+ "type": "object",
+ "description": "Indicates that the job creates a Release.",
+ "additionalProperties": false,
+ "properties": {
+ "tag_name": {
+ "type": "string",
+ "description": "The tag_name must be specified. It can refer to an existing Git tag or can be specified by the user.",
+ "minLength": 1
+ },
+ "description": {
+ "type": "string",
+ "description": "Specifies the longer description of the Release.",
+ "minLength": 1
+ },
+ "name": {
+ "type": "string",
+ "description": "The Release name. If omitted, it is populated with the value of release: tag_name."
+ },
+ "ref": {
+ "type": "string",
+ "description": "If the release: tag_name doesn’t exist yet, the release is created from ref. ref can be a commit SHA, another tag name, or a branch name."
+ },
+ "milestones": {
+ "type": "array",
+ "description": "The title of each milestone the release is associated with.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "released_at": {
+ "type": "string",
+ "description": "The date and time when the release is ready. Defaults to the current date and time if not defined. Should be enclosed in quotes and expressed in ISO 8601 format.",
+ "format": "date-time",
+ "pattern": "^(?:[1-9]\\d{3}-(?:(?:0[1-9]|1[0-2])-(?:0[1-9]|1\\d|2[0-8])|(?:0[13-9]|1[0-2])-(?:29|30)|(?:0[13578]|1[02])-31)|(?:[1-9]\\d(?:0[48]|[2468][048]|[13579][26])|(?:[2468][048]|[13579][26])00)-02-29)T(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:Z|[+-][01]\\d:[0-5]\\d)$"
+ },
+ "assets": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "links": {
+ "type": "array",
+ "description": "Include asset links in the release.",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "The name of the link.",
+ "minLength": 1
+ },
+ "url": {
+ "type": "string",
+ "description": "The URL to download a file.",
+ "minLength": 1
+ },
+ "filepath": {
+ "type": "string",
+ "description": "The redirect link to the url."
+ },
+ "link_type": {
+ "type": "string",
+ "description": "The content kind of what users can download via url.",
+ "enum": [
+ "runbook",
+ "package",
+ "image",
+ "other"
+ ]
+ }
+ },
+ "required": ["name", "url"]
+ },
+ "minItems": 1
+ }
+ },
+ "required": ["links"]
+ }
+ },
+ "required": ["tag_name", "description"]
+ },
+ "coverage": {
+ "type": "string",
+ "description": "Must be a regular expression, optionally but recommended to be quoted, and must be surrounded with '/'. Example: '/Code coverage: \\d+\\.\\d+/'",
+ "format": "regex",
+ "pattern": "^/.+/$"
+ },
+ "retry": {
+ "$ref": "#/definitions/retry"
+ },
+ "parallel": {
+ "description": "Parallel will split up a single job into several, and provide `CI_NODE_INDEX` and `CI_NODE_TOTAL` environment variables for the running jobs.",
+ "oneOf": [
+ {
+ "type": "integer",
+ "description": "Creates N instances of the same job that run in parallel.",
+ "default": 0,
+ "minimum": 2,
+ "maximum": 50
+ },
+ {
+ "type": "object",
+ "properties": {
+ "matrix": {
+ "type": "array",
+ "description": "Defines different variables for jobs that are running in parallel.",
+ "items": {
+ "type": "object",
+ "description": "Defines environment variables for specific job.",
+ "additionalProperties": {
+ "type": ["string", "number", "array"]
+ }
+ },
+ "maxItems": 50
+ }
+ },
+ "additionalProperties": false,
+ "required": ["matrix"]
+ }
+ ]
+ },
+ "interruptible": {
+ "$ref": "#/definitions/interruptible"
+ },
+ "resource_group": {
+ "type": "string",
+ "description": "Limit job concurrency. Can be used to ensure that the Runner will not run certain jobs simultaneously."
+ },
+ "trigger": {
+ "description": "Trigger allows you to define downstream pipeline trigger. When a job created from trigger definition is started by GitLab, a downstream pipeline gets created. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger",
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#simple-trigger-syntax-for-multi-project-pipelines",
+ "additionalProperties": false,
+ "properties": {
+ "project": {
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ },
+ "branch": {
+ "description": "The branch name that a downstream pipeline will use",
+ "type": "string"
+ },
+ "strategy": {
+ "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
+ "type": "string",
+ "enum": ["depend"]
+ }
+ },
+ "required": ["project"],
+ "dependencies": {
+ "branch": ["project"]
+ }
+ },
+ {
+ "type": "object",
+ "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger-syntax-for-child-pipeline",
+ "additionalProperties": false,
+ "properties": {
+ "include": {
+ "oneOf": [
+ {
+ "description": "Relative path from local repository root (`/`) to the local YAML file to define the pipeline configuration.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ },
+ {
+ "type": "array",
+ "description": "References a local file or an artifact from another job to define the pipeline configuration.",
+ "maxItems": 3,
+ "items": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "local": {
+ "description": "Relative path from local repository root (`/`) to the local YAML file to define the pipeline configuration.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "template": {
+ "description": "Name of the template YAML file to use in the pipeline configuration.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "artifact": {
+ "description": "Relative path to the generated YAML file which is extracted from the artifacts and used as the configuration for triggering the child pipeline.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ },
+ "job": {
+ "description": "Job name which generates the artifact",
+ "type": "string"
+ }
+ },
+ "required": ["artifact", "job"]
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "project": {
+ "description": "Path to another private project under the same GitLab instance, like `group/project` or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ },
+ "ref": {
+ "description": "Branch/Tag/Commit hash for the target project.",
+ "minLength": 1,
+ "type": "string"
+ },
+ "file": {
+ "description": "Relative path from repository root (`/`) to the pipeline configuration YAML file.",
+ "type": "string",
+ "format": "uri-reference",
+ "pattern": "\\.ya?ml$"
+ }
+ },
+ "required": ["project", "file"]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "strategy": {
+ "description": "You can mirror the pipeline status from the triggered pipeline to the source bridge job by using strategy: depend",
+ "type": "string",
+ "enum": ["depend"]
+ }
+ }
+ },
+ {
+ "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
+ "type": "string",
+ "pattern": "\\S/\\S"
+ }
+ ]
+ },
+ "inherit": {
+ "type": "object",
+ "description": "Controls inheritance of globally-defined defaults and variables. Boolean values control inheritance of all default: or variables: keywords. To inherit only a subset of default: or variables: keywords, specify what you wish to inherit. Anything not listed is not inherited.",
+ "properties": {
+ "default": {
+ "description": "Whether to inherit all globally-defined defaults or not. Or subset of inherited defaults",
+ "oneOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "after_script",
+ "artifacts",
+ "before_script",
+ "cache",
+ "image",
+ "interruptible",
+ "retry",
+ "services",
+ "tags",
+ "timeout"
+ ]
+ }
+ }
+ ]
+ },
+ "variables": {
+ "description": "Whether to inherit all globally-defined variables or not. Or subset of inherited variables",
+ "oneOf": [
+ { "type": "boolean" },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ "additionalProperties": false
+ }
+ },
+ "oneOf": [
+ {
+ "properties": {
+ "when": { "enum": ["delayed"] }
+ },
+ "required": ["when", "start_in"]
+ },
+ {
+ "properties": {
+ "when": {
+ "not": {
+ "enum": ["delayed"]
+ }
+ }
+ }
+ }
+ ]
+ },
+ "tags": {
+ "type": "array",
+ "description": "Used to select runners from the list of available runners. A runner must have all tags listed here to run the job.",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue
index 4b7917b4572..8609503e486 100644
--- a/app/assets/javascripts/environments/components/environment_delete.vue
+++ b/app/assets/javascripts/environments/components/environment_delete.vue
@@ -4,17 +4,15 @@
* Used in the environments table.
*/
-import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+import { GlDropdownItem, GlModalDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
- GlButton,
+ GlDropdownItem,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlModalDirective,
},
props: {
@@ -28,10 +26,8 @@ export default {
isLoading: false,
};
},
- computed: {
- title() {
- return s__('Environments|Delete environment');
- },
+ i18n: {
+ title: s__('Environments|Delete environment'),
},
mounted() {
eventHub.$on('deleteEnvironment', this.onDeleteEnvironment);
@@ -41,7 +37,6 @@ export default {
},
methods: {
onClick() {
- this.$root.$emit(BV_HIDE_TOOLTIP, this.$options.deleteEnvironmentTooltipId);
eventHub.$emit('requestDeleteEnvironment', this.environment);
},
onDeleteEnvironment(environment) {
@@ -50,20 +45,15 @@ export default {
}
},
},
- deleteEnvironmentTooltipId: 'delete-environment-button-tooltip',
};
</script>
<template>
- <gl-button
- v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }"
- v-gl-modal-directive="'delete-environment-modal'"
+ <gl-dropdown-item
+ v-gl-modal-directive.delete-environment-modal
:loading="isLoading"
- :title="title"
- :aria-label="title"
- class="gl-display-none gl-md-display-block"
variant="danger"
- category="primary"
- icon="remove"
@click="onClick"
- />
+ >
+ {{ $options.i18n.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue
index 793f7bf0681..b8def676e7d 100644
--- a/app/assets/javascripts/environments/components/environment_external_url.vue
+++ b/app/assets/javascripts/environments/components/environment_external_url.vue
@@ -18,22 +18,23 @@ export default {
required: true,
},
},
- computed: {
- title() {
- return s__('Environments|Open live environment');
- },
+ i18n: {
+ title: s__('Environments|Open live environment'),
+ open: s__('Environments|Open'),
},
};
</script>
<template>
<gl-button
v-gl-tooltip
- :title="title"
- :aria-label="title"
+ :title="$options.i18n.title"
+ :aria-label="$options.i18n.title"
:href="externalUrl"
class="external-url"
target="_blank"
icon="external-link"
rel="noopener noreferrer nofollow"
- />
+ >
+ {{ $options.i18n.open }}
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index d12863ee742..db01d455b2b 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__, sprintf } from '~/locale';
@@ -29,6 +29,7 @@ export default {
ActionsComponent,
CommitComponent,
ExternalUrlComponent,
+ GlDropdown,
GlIcon,
GlLink,
GlSprintf,
@@ -521,6 +522,10 @@ export default {
return this.model.metrics_path || '';
},
+ terminalPath() {
+ return this.model?.terminal_path ?? '';
+ },
+
autoStopUrl() {
return this.model.cancel_auto_stop_path || '';
},
@@ -549,6 +554,15 @@ export default {
tableNameSpacingClass() {
return this.isFolder ? 'section-100' : this.tableData.name.spacing;
},
+ hasExtraActions() {
+ return Boolean(
+ this.canRetry ||
+ this.canShowAutoStopDate ||
+ this.monitoringUrl ||
+ this.terminalPath ||
+ this.canDeleteEnvironment,
+ );
+ },
},
methods: {
@@ -776,13 +790,6 @@ export default {
role="gridcell"
>
<div class="btn-group table-action-buttons" role="group">
- <pin-component
- v-if="canShowAutoStopDate"
- :auto-stop-url="autoStopUrl"
- data-track-action="click_button"
- data-track-label="environment_pin"
- />
-
<external-url-component
v-if="externalURL"
:external-url="externalURL"
@@ -790,13 +797,6 @@ export default {
data-track-label="environment_url"
/>
- <monitoring-button-component
- v-if="monitoringUrl"
- :monitoring-url="monitoringUrl"
- data-track-action="click_button"
- data-track-label="environment_monitoring"
- />
-
<actions-component
v-if="actions.length > 0"
:actions="actions"
@@ -804,35 +804,59 @@ export default {
data-track-label="environment_actions"
/>
- <terminal-button-component
- v-if="model && model.terminal_path"
- :terminal-path="model.terminal_path"
- data-track-action="click_button"
- data-track-label="environment_terminal"
- />
-
- <rollback-component
- v-if="canRetry"
- :environment="model"
- :is-last-deployment="isLastDeployment"
- :retry-url="retryUrl"
- data-track-action="click_button"
- data-track-label="environment_rollback"
- />
-
<stop-component
v-if="canStopEnvironment"
:environment="model"
+ class="gl-z-index-2"
data-track-action="click_button"
data-track-label="environment_stop"
/>
- <delete-component
- v-if="canDeleteEnvironment"
- :environment="model"
- data-track-action="click_button"
- data-track-label="environment_delete"
- />
+ <gl-dropdown
+ v-if="hasExtraActions"
+ icon="ellipsis_v"
+ text-sr-only
+ :text="__('More actions')"
+ category="secondary"
+ no-caret
+ >
+ <rollback-component
+ v-if="canRetry"
+ :environment="model"
+ :is-last-deployment="isLastDeployment"
+ :retry-url="retryUrl"
+ data-track-action="click_button"
+ data-track-label="environment_rollback"
+ />
+
+ <pin-component
+ v-if="canShowAutoStopDate"
+ :auto-stop-url="autoStopUrl"
+ data-track-action="click_button"
+ data-track-label="environment_pin"
+ />
+
+ <monitoring-button-component
+ v-if="monitoringUrl"
+ :monitoring-url="monitoringUrl"
+ data-track-action="click_button"
+ data-track-label="environment_monitoring"
+ />
+
+ <terminal-button-component
+ v-if="terminalPath"
+ :terminal-path="terminalPath"
+ data-track-action="click_button"
+ data-track-label="environment_terminal"
+ />
+
+ <delete-component
+ v-if="canDeleteEnvironment"
+ :environment="model"
+ data-track-action="click_button"
+ data-track-label="environment_delete"
+ />
+ </gl-dropdown>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index 7f70433776d..06c7f10223a 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,15 +1,12 @@
<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
export default {
components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlDropdownItem,
},
props: {
monitoringUrl: {
@@ -17,22 +14,11 @@ export default {
required: true,
},
},
- computed: {
- title() {
- return __('Monitoring');
- },
- },
+ title: __('Monitoring'),
};
</script>
<template>
- <gl-button
- v-gl-tooltip
- :href="monitoringUrl"
- :title="title"
- :aria-label="title"
- class="monitoring-url gl-display-none gl-sm-display-none gl-md-display-block"
- icon="chart"
- rel="noopener noreferrer nofollow"
- variant="default"
- />
+ <gl-dropdown-item :href="monitoringUrl" rel="noopener noreferrer nofollow" target="_blank">
+ {{ $options.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
index 52ac7725bde..0b753d53ee3 100644
--- a/app/assets/javascripts/environments/components/environment_pin.vue
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -3,17 +3,13 @@
* Renders a prevent auto-stop button.
* Used in environments table.
*/
-import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
- GlIcon,
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlDropdownItem,
},
props: {
autoStopUrl: {
@@ -26,11 +22,11 @@ export default {
eventHub.$emit('cancelAutoStop', this.autoStopUrl);
},
},
- title: __('Prevent environment from auto-stopping'),
+ title: __('Prevent auto-stopping'),
};
</script>
<template>
- <gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick">
- <gl-icon name="thumbtack" />
- </gl-button>
+ <gl-dropdown-item @click="onPinClick">
+ {{ $options.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index c0b4e96cea2..00497b3c683 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -5,16 +5,15 @@
*
* Makes a post request when the button is clicked.
*/
-import { GlTooltipDirective, GlModalDirective, GlButton } from '@gitlab/ui';
+import { GlModalDirective, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
components: {
- GlButton,
+ GlDropdownItem,
},
directives: {
- GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
props: {
@@ -65,14 +64,7 @@ export default {
};
</script>
<template>
- <gl-button
- v-gl-tooltip
- v-gl-modal.confirm-rollback-modal
- class="gl-display-none gl-md-display-block text-secondary"
- :loading="isLoading"
- :title="title"
- :aria-label="title"
- :icon="isLastDeployment ? 'repeat' : 'redo'"
- @click="onClick"
- />
+ <gl-dropdown-item v-gl-modal.confirm-rollback-modal @click="onClick">
+ {{ title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue
index dceaf3cacf1..0d4a1e76eb8 100644
--- a/app/assets/javascripts/environments/components/environment_stop.vue
+++ b/app/assets/javascripts/environments/components/environment_stop.vue
@@ -23,16 +23,15 @@ export default {
required: true,
},
},
+ i18n: {
+ title: s__('Environments|Stop environment'),
+ stop: s__('Environments|Stop'),
+ },
data() {
return {
isLoading: false,
};
},
- computed: {
- title() {
- return s__('Environments|Stop environment');
- },
- },
mounted() {
eventHub.$on('stopEnvironment', this.onStopEnvironment);
},
@@ -58,11 +57,13 @@ export default {
v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }"
v-gl-modal-directive="'stop-environment-modal'"
:loading="isLoading"
- :title="title"
- :aria-label="title"
+ :title="$options.i18n.title"
+ :aria-label="$options.i18n.title"
icon="stop"
- category="primary"
+ category="secondary"
variant="danger"
@click="onClick"
- />
+ >
+ {{ $options.i18n.stop }}
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 4750b8ef01b..0df07f0457f 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -3,15 +3,12 @@
* Renders a terminal button to open a web terminal.
* Used in environments table.
*/
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
+ GlDropdownItem,
},
props: {
terminalPath: {
@@ -25,22 +22,11 @@ export default {
default: false,
},
},
- computed: {
- title() {
- return __('Terminal');
- },
- },
+ title: __('Terminal'),
};
</script>
<template>
- <a
- v-gl-tooltip
- :title="title"
- :aria-label="title"
- :href="terminalPath"
- :class="{ disabled: disabled }"
- class="btn terminal-button d-none d-md-block text-secondary"
- >
- <gl-icon name="terminal" />
- </a>
+ <gl-dropdown-item :href="terminalPath" :disabled="disabled">
+ {{ $options.title }}
+ </gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index f1c728b84fd..7b8b756487b 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -67,7 +67,7 @@ export default {
spacing: 'section-10',
},
autoStop: {
- title: s__('Environments|Auto stop in'),
+ title: s__('Environments|Auto stop'),
spacing: 'section-10',
},
actions: {
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 206381e0b7e..f248e9ec079 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -8,7 +8,7 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export default () => {
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 0a15cb56447..4adbf5362b7 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -128,6 +128,12 @@ export default {
lastReleaseLink() {
return `${this.error.externalBaseUrl}/releases/${this.error.lastReleaseVersion}`;
},
+ firstCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.firstReleaseVersion}`;
+ },
+ lastCommitLink() {
+ return `${this.error.externalBaseUrl}/-/commit/${this.error.lastReleaseVersion}`;
+ },
showStacktrace() {
return Boolean(this.stacktrace?.length);
},
@@ -394,7 +400,7 @@ export default {
<span>{{ error.gitlabIssuePath }}</span>
</gl-link>
</li>
- <li>
+ <li v-if="!error.integrated">
<strong class="bold">{{ __('Sentry event') }}:</strong>
<gl-link
v-track-event="trackClickErrorLinkToSentryOptions(error.externalUrl)"
@@ -409,15 +415,21 @@ export default {
<li v-if="error.firstReleaseVersion">
<strong class="bold">{{ __('First seen') }}:</strong>
<time-ago-tooltip :time="error.firstSeen" />
- <gl-link :href="firstReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.firstReleaseVersion }}</span>
+ <gl-link v-if="error.integrated" :href="firstCommitLink">
+ {{ __('GitLab commit') }}: {{ error.firstReleaseVersion }}
+ </gl-link>
+ <gl-link v-else :href="firstReleaseLink" target="_blank">
+ {{ __('Release') }}: {{ error.firstReleaseVersion }}
</gl-link>
</li>
<li v-if="error.lastReleaseVersion">
<strong class="bold">{{ __('Last seen') }}:</strong>
<time-ago-tooltip :time="error.lastSeen" />
- <gl-link :href="lastReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.lastReleaseVersion }}</span>
+ <gl-link v-if="error.integrated" :href="lastCommitLink">
+ {{ __('GitLab commit') }}: {{ error.lastReleaseVersion }}
+ </gl-link>
+ <gl-link v-else :href="lastReleaseLink" target="_blank">
+ {{ __('Release') }}: {{ error.lastReleaseVersion }}
</gl-link>
</li>
<li>
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
index 593cbf2ae52..af386528f00 100644
--- a/app/assets/javascripts/error_tracking/queries/details.query.graphql
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -23,6 +23,7 @@ query errorDetails($fullPath: ID!, $errorId: ID!) {
gitlabCommit
gitlabCommitPath
gitlabIssuePath
+ integrated
}
}
}
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index e12d9cc2b07..4808cd1d1c0 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -1,6 +1,14 @@
<script>
-import { GlButton, GlFormGroup, GlFormCheckbox, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormRadioGroup,
+ GlFormRadio,
+ GlFormInputGroup,
+} from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ErrorTrackingForm from './error_tracking_form.vue';
import ProjectDropdown from './project_dropdown.vue';
@@ -12,7 +20,9 @@ export default {
GlFormGroup,
GlFormRadioGroup,
GlFormRadio,
+ GlFormInputGroup,
ProjectDropdown,
+ ClipboardButton,
},
props: {
initialApiHost: {
@@ -46,6 +56,11 @@ export default {
type: String,
required: true,
},
+ gitlabDsn: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapGetters([
@@ -63,6 +78,9 @@ export default {
'settingsLoading',
'token',
]),
+ showGitlabDsnSetting() {
+ return this.integrated && this.enabled && this.gitlabDsn;
+ },
},
created() {
this.setInitialState({
@@ -119,6 +137,17 @@ export default {
</gl-form-radio>
</gl-form-radio-group>
</gl-form-group>
+ <gl-form-group
+ v-if="showGitlabDsnSetting"
+ :label="__('Paste this DSN into your Sentry SDK')"
+ data-testid="gitlab-dsn-setting-form"
+ >
+ <gl-form-input-group readonly :value="gitlabDsn">
+ <template #append>
+ <clipboard-button :text="gitlabDsn" :title="__('Copy')" />
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
<div v-if="!integrated" class="js-sentry-setting-form" data-testid="sentry-setting-form">
<error-tracking-form />
<div class="form-group">
diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js
index 324b3292834..69388329e1c 100644
--- a/app/assets/javascripts/error_tracking_settings/index.js
+++ b/app/assets/javascripts/error_tracking_settings/index.js
@@ -13,6 +13,7 @@ export default () => {
token,
listProjectsEndpoint,
operationsSettingsEndpoint,
+ gitlabDsn,
},
} = formContainerEl;
@@ -29,6 +30,7 @@ export default () => {
initialToken: token,
listProjectsEndpoint,
operationsSettingsEndpoint,
+ gitlabDsn,
},
});
},
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index 9079c238169..624a04fd7c2 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,5 +1,5 @@
// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
-import { get, pick } from 'lodash';
+import { get } from 'lodash';
import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
function getExperimentsData() {
@@ -14,12 +14,6 @@ export function getExperimentData(experimentName) {
return getExperimentsData()[experimentName];
}
-export function getExperimentContexts(...experimentNames) {
- return Object.values(pick(getExperimentsData(), experimentNames)).map(
- convertExperimentDataToExperimentContext,
- );
-}
-
export function getAllExperimentContexts() {
return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext);
}
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index 05d557db942..2bdc95e798c 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { sprintf, s__ } from '~/locale';
+import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import FeatureFlagForm from './form.vue';
@@ -10,6 +10,7 @@ export default {
GlAlert,
GlLoadingIcon,
GlToggle,
+ FeatureFlagActions: () => import('ee_component/feature_flags/components/actions.vue'),
FeatureFlagForm,
},
mixins: [glFeatureFlagMixin()],
@@ -28,7 +29,7 @@ export default {
title() {
return this.iid
? `^${this.iid} ${this.name}`
- : sprintf(s__('Edit %{name}'), { name: this.name });
+ : sprintf(this.$options.i18n.editTitle, { name: this.name });
},
},
created() {
@@ -37,6 +38,11 @@ export default {
methods: {
...mapActions(['updateFeatureFlag', 'fetchFeatureFlag', 'toggleActive']),
},
+ i18n: {
+ editTitle: __('Edit %{name}'),
+ toggleLabel: __('Feature flag status'),
+ submit: __('Save changes'),
+ },
};
</script>
<template>
@@ -51,11 +57,13 @@ export default {
data-track-action="click_button"
data-track-label="feature_flag_toggle"
class="gl-mr-4"
- :label="__('Feature flag status')"
+ :label="$options.i18n.toggleLabel"
label-position="hidden"
@change="toggleActive"
/>
<h3 class="page-title gl-m-0">{{ title }}</h3>
+
+ <feature-flag-actions class="gl-ml-auto" />
</div>
<gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false">
@@ -67,7 +75,7 @@ export default {
:description="description"
:strategies="strategies"
:cancel-path="path"
- :submit-text="__('Save changes')"
+ :submit-text="$options.i18n.submit"
:active="active"
@handleSubmit="(data) => updateFeatureFlag(data)"
/>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index f7ad2c1f106..29e82289107 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -95,7 +95,7 @@ export default {
return this.formStrategies.filter((s) => !s.shouldBeDestroyed);
},
showRelatedIssues() {
- return this.featureFlagIssuesEndpoint.length > 0;
+ return Boolean(this.featureFlagIssuesEndpoint);
},
},
methods: {
diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
index 858c30649bb..1a470d74b59 100644
--- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
+++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue
@@ -17,7 +17,7 @@ export default {
},
},
i18n: {
- percentageDescription: __('Enter an integer number number between 0 and 100'),
+ percentageDescription: __('Enter an integer number between 0 and 100'),
percentageInvalid: __('Percent rollout must be an integer number between 0 and 100'),
percentageLabel: __('Percentage'),
stickinessDescription: __('Consistency guarantee method'),
diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js
index 98dee7c7e97..55dad87ea5b 100644
--- a/app/assets/javascripts/feature_flags/edit.js
+++ b/app/assets/javascripts/feature_flags/edit.js
@@ -15,6 +15,7 @@ export default () => {
environmentsEndpoint,
projectId,
featureFlagIssuesEndpoint,
+ searchPath,
} = el.dataset;
return new Vue({
@@ -26,6 +27,7 @@ export default () => {
environmentsEndpoint,
projectId,
featureFlagIssuesEndpoint,
+ searchPath,
},
render(createElement) {
return createElement(EditFeatureFlag);
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index 545719ee681..9726b2164b7 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -1,6 +1,6 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import AjaxFilter from '../droplab/plugins/ajax_filter';
+import AjaxFilter from './droplab/plugins/ajax_filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchTokenizer from './filtered_search_tokenizer';
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index a7648a3c463..5adc074b3ce 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,7 +1,7 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import Ajax from '../droplab/plugins/ajax';
-import Filter from '../droplab/plugins/filter';
+import Ajax from './droplab/plugins/ajax';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 47f350dc6a2..9d29782c9a7 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,5 +1,5 @@
-import Filter from '~/droplab/plugins/filter';
import { __ } from '~/locale';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index f78644a3893..ddc3c06a9d1 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,7 +1,7 @@
import createFlash from '~/flash';
import { __ } from '~/locale';
-import Ajax from '../droplab/plugins/ajax';
-import Filter from '../droplab/plugins/filter';
+import Ajax from './droplab/plugins/ajax';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index f933338514a..fb9f25a8c45 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -1,5 +1,5 @@
-import Filter from '~/droplab/plugins/filter';
import { __ } from '~/locale';
+import Filter from './droplab/plugins/filter';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/filtered_search/droplab/constants.js
index 6451af49d36..6451af49d36 100644
--- a/app/assets/javascripts/droplab/constants.js
+++ b/app/assets/javascripts/filtered_search/droplab/constants.js
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js
index 05b741af191..05b741af191 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js
index 6f068aaa800..15c4a4b7c6b 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_lab_deprecated.js
@@ -1,3 +1,13 @@
+/**
+ * This library is deprecated and scheduled to be removed once the
+ * filtered_search component is replaced with GitLab's new Pajamas
+ * filter vue component.
+ *
+ * The documentation has been removed from the gitlab codebase but
+ * can still be found in the commit history here:
+ * https://gitlab.com/gitlab-org/gitlab/-/blob/28f20e28/doc/development/fe_guide/droplab/droplab.md
+ */
+
import { DATA_TRIGGER } from './constants';
import HookButton from './hook_button';
import HookInput from './hook_input';
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/filtered_search/droplab/hook.js
index 8a8dcde9f88..8a8dcde9f88 100644
--- a/app/assets/javascripts/droplab/hook.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook.js
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js
index c51d6167fa3..c51d6167fa3 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js
diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js
index c523dae347f..c523dae347f 100644
--- a/app/assets/javascripts/droplab/hook_input.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/filtered_search/droplab/keyboard.js
index fe1ea2fa6b0..fe1ea2fa6b0 100644
--- a/app/assets/javascripts/droplab/keyboard.js
+++ b/app/assets/javascripts/filtered_search/droplab/keyboard.js
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js
index 77d60454d1a..77d60454d1a 100644
--- a/app/assets/javascripts/droplab/plugins/ajax.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax.js
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
index ac4d44adc17..d0f2d205bb6 100644
--- a/app/assets/javascripts/droplab/plugins/ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/ajax_filter.js
@@ -1,5 +1,6 @@
/* eslint-disable */
-import AjaxCache from '../../lib/utils/ajax_cache';
+
+import AjaxCache from '~/lib/utils/ajax_cache';
const AjaxFilter = {
init: function (hook) {
diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js
index 06391668928..06391668928 100644
--- a/app/assets/javascripts/droplab/plugins/filter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/filter.js
diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
index 148d9a35b81..148d9a35b81 100644
--- a/app/assets/javascripts/droplab/plugins/input_setter.js
+++ b/app/assets/javascripts/filtered_search/droplab/plugins/input_setter.js
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/filtered_search/droplab/utils.js
index d7f49bf19d8..d7f49bf19d8 100644
--- a/app/assets/javascripts/droplab/utils.js
+++ b/app/assets/javascripts/filtered_search/droplab/utils.js
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index ebaa3ef98b1..e467e97dda9 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,6 +1,6 @@
import { last } from 'lodash';
import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings';
-import DropLab from '~/droplab/drop_lab';
+import DropLab from './droplab/drop_lab_deprecated';
import { DROPDOWN_TYPE } from './constants';
import FilteredSearchContainer from './container';
import DropdownUtils from './dropdown_utils';
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 2f451e8353b..5dac315d345 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable vue/require-default-prop */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
@@ -14,6 +14,9 @@ export default {
GlButton,
ProjectAvatar,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
mixins: [trackingMixin],
inject: ['vuexModule'],
props: {
@@ -73,9 +76,9 @@ export default {
<div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container">
<div
ref="frequentItemsItemTitle"
+ v-safe-html="highlightedItemName"
:title="itemName"
class="frequent-items-item-title"
- v-html="highlightedItemName /* eslint-disable-line vue/no-v-html */"
></div>
<div
v-if="namespace"
diff --git a/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql
new file mode 100644
index 00000000000..760d78be20d
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/milestone.fragment.graphql
@@ -0,0 +1,6 @@
+fragment MilestoneFragment on Milestone {
+ expired
+ id
+ state
+ title
+}
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 580c27f6c61..c6590fd8eb3 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -3,6 +3,7 @@ import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue';
@@ -16,6 +17,7 @@ export default {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
+ HeaderSearchAutocompleteItems,
},
data() {
return {
@@ -41,7 +43,7 @@ export default {
},
},
methods: {
- ...mapActions(['setSearch']),
+ ...mapActions(['setSearch', 'fetchAutocompleteOptions']),
openDropdown() {
this.showDropdown = true;
},
@@ -51,6 +53,13 @@ export default {
submitSearch() {
return visitUrl(this.searchQuery);
},
+ getAutocompleteOptions(searchTerm) {
+ if (!searchTerm) {
+ return;
+ }
+
+ this.fetchAutocompleteOptions();
+ },
},
};
</script>
@@ -64,18 +73,20 @@ export default {
:placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown"
@click="openDropdown"
+ @input="getAutocompleteOptions"
@keydown.enter="submitSearch"
@keydown.esc="closeDropdown"
/>
<div
v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu"
- class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
+ class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
>
<div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
<header-search-default-items v-if="showDefaultItems" />
<template v-else>
<header-search-scoped-items />
+ <header-search-autocomplete-items />
</template>
</div>
</div>
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
new file mode 100644
index 00000000000..9bea2b280f7
--- /dev/null
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -0,0 +1,74 @@
+<script>
+import {
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlAvatar,
+ GlLoadingIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import highlight from '~/lib/utils/highlight';
+import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
+
+export default {
+ name: 'HeaderSearchAutocompleteItems',
+ components: {
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlAvatar,
+ GlLoadingIcon,
+ },
+ directives: {
+ SafeHtml,
+ },
+ computed: {
+ ...mapState(['search', 'loading']),
+ ...mapGetters(['autocompleteGroupedSearchOptions']),
+ },
+ methods: {
+ highlightedName(val) {
+ return highlight(val, this.search);
+ },
+ avatarSize(data) {
+ if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) {
+ return LARGE_AVATAR_PX;
+ }
+
+ return SMALL_AVATAR_PX;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template v-if="!loading">
+ <div v-for="option in autocompleteGroupedSearchOptions" :key="option.category">
+ <gl-dropdown-divider />
+ <gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(data, index) in option.data"
+ :id="`autocomplete-${option.category}-${index}`"
+ :key="index"
+ tabindex="-1"
+ :href="data.url"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-avatar
+ v-if="data.avatar_url !== undefined"
+ :src="data.avatar_url"
+ :entity-id="data.id"
+ :entity-name="data.label"
+ :size="avatarSize(data)"
+ shape="square"
+ />
+ <span v-safe-html="highlightedName(data.label)"></span>
+ </div>
+ </gl-dropdown-item>
+ </div>
+ </template>
+ <gl-loading-icon v-else size="lg" class="my-4" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index fffed7bcbdb..2fadb1bd1ee 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -15,3 +15,11 @@ export const MSG_IN_ALL_GITLAB = __('in all GitLab');
export const MSG_IN_GROUP = __('in group');
export const MSG_IN_PROJECT = __('in project');
+
+export const GROUPS_CATEGORY = 'Groups';
+
+export const PROJECTS_CATEGORY = 'Projects';
+
+export const LARGE_AVATAR_PX = 32;
+
+export const SMALL_AVATAR_PX = 16;
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index 2d37ee137fc..d7e21f55ea5 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -12,13 +12,13 @@ export const initHeaderSearchApp = () => {
return false;
}
- const { searchPath, issuesPath, mrPath } = el.dataset;
+ const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset;
let { searchContext } = el.dataset;
searchContext = JSON.parse(searchContext);
return new Vue({
el,
- store: createStore({ searchPath, issuesPath, mrPath, searchContext }),
+ store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
render(createElement) {
return createElement(HeaderSearchApp);
},
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index 841aee04029..2c3b1bd4c0f 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -1,5 +1,19 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
import * as types from './mutation_types';
+export const fetchAutocompleteOptions = ({ commit, getters }) => {
+ commit(types.REQUEST_AUTOCOMPLETE);
+ return axios
+ .get(getters.autocompleteQuery)
+ .then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data))
+ .catch(() => {
+ commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
+ createFlash({ message: __('There was an error fetching search autocomplete suggestions') });
+ });
+};
+
export const setSearch = ({ commit }, value) => {
commit(types.SET_SEARCH, value);
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index d1e1fc8ad73..3f4e231ca55 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -23,6 +23,16 @@ export const searchQuery = (state) => {
return `${state.searchPath}?${objectToQuery(query)}`;
};
+export const autocompleteQuery = (state) => {
+ const query = {
+ term: state.search,
+ project_id: state.searchContext.project?.id,
+ project_ref: state.searchContext.ref,
+ };
+
+ return `${state.autocompletePath}?${objectToQuery(query)}`;
+};
+
export const scopedIssuesPath = (state) => {
return (
state.searchContext.project_metadata?.issues_path ||
@@ -133,3 +143,25 @@ export const scopedSearchOptions = (state, getters) => {
return options;
};
+
+export const autocompleteGroupedSearchOptions = (state) => {
+ const groupedOptions = {};
+ const results = [];
+
+ state.autocompleteOptions.forEach((option) => {
+ const category = groupedOptions[option.category];
+
+ if (category) {
+ category.data.push(option);
+ } else {
+ groupedOptions[option.category] = {
+ category: option.category,
+ data: [option],
+ };
+
+ results.push(groupedOptions[option.category]);
+ }
+ });
+
+ return results;
+};
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
index 8b74f8662a5..06cca4be8a7 100644
--- a/app/assets/javascripts/header_search/store/index.js
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -7,11 +7,17 @@ import createState from './state';
Vue.use(Vuex);
-export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
+export const getStoreConfig = ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ autocompletePath,
+ searchContext,
+}) => ({
actions,
getters,
mutations,
- state: createState({ searchPath, issuesPath, mrPath, searchContext }),
+ state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
});
const createStore = (config) => new Vuex.Store(getStoreConfig(config));
diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js
index 0bc94ae055f..a2358621ce6 100644
--- a/app/assets/javascripts/header_search/store/mutation_types.js
+++ b/app/assets/javascripts/header_search/store/mutation_types.js
@@ -1 +1,5 @@
+export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
+export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
+export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
+
export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 5b1438929d4..175b5406540 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -1,6 +1,18 @@
import * as types from './mutation_types';
export default {
+ [types.REQUEST_AUTOCOMPLETE](state) {
+ state.loading = true;
+ state.autocompleteOptions = [];
+ },
+ [types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
+ state.loading = false;
+ state.autocompleteOptions = data;
+ },
+ [types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
+ state.loading = false;
+ state.autocompleteOptions = [];
+ },
[types.SET_SEARCH](state, value) {
state.search = value;
},
diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js
index fb2c83dbbe3..3d4073f0583 100644
--- a/app/assets/javascripts/header_search/store/state.js
+++ b/app/assets/javascripts/header_search/store/state.js
@@ -1,8 +1,11 @@
-const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
+const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({
searchPath,
issuesPath,
mrPath,
+ autocompletePath,
searchContext,
search: '',
+ autocompleteOptions: [],
+ loading: false,
});
export default createState;
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index 5a7d7917f8a..5272c4310d8 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -1,7 +1,11 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapState } from 'vuex';
export default {
+ directives: {
+ SafeHtml,
+ },
computed: {
...mapState(['lastCommitMsg', 'committedStateSvgPath']),
},
@@ -16,7 +20,7 @@ export default {
<div class="gl-mr-3 gl-ml-3">
<div class="text-content text-center">
<h4>{{ __('All changes are committed') }}</h4>
- <p v-html="lastCommitMsg /* eslint-disable-line vue/no-v-html */"></p>
+ <p v-safe-html="lastCommitMsg"></p>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index c142992a9d1..96cb4f3d495 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -44,18 +44,18 @@ export default {
methods: {
...mapActions('pipelines', ['fetchJobLogs', 'setDetailJob']),
scrollDown() {
- if (this.$refs.buildTrace) {
- this.$refs.buildTrace.scrollTo(0, this.$refs.buildTrace.scrollHeight);
+ if (this.$refs.buildJobLog) {
+ this.$refs.buildJobLog.scrollTo(0, this.$refs.buildJobLog.scrollHeight);
}
},
scrollUp() {
- if (this.$refs.buildTrace) {
- this.$refs.buildTrace.scrollTo(0, 0);
+ if (this.$refs.buildJobLog) {
+ this.$refs.buildJobLog.scrollTo(0, 0);
}
},
scrollBuildLog: throttle(function buildLogScrollDebounce() {
- const { scrollTop } = this.$refs.buildTrace;
- const { offsetHeight, scrollHeight } = this.$refs.buildTrace;
+ const { scrollTop } = this.$refs.buildJobLog;
+ const { offsetHeight, scrollHeight } = this.$refs.buildJobLog;
if (scrollTop + offsetHeight === scrollHeight) {
this.scrollPos = scrollPositions.bottom;
@@ -97,7 +97,7 @@ export default {
<scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" />
</div>
</div>
- <pre ref="buildTrace" class="build-trace mb-0 h-100 mr-3" @scroll="scrollBuildLog">
+ <pre ref="buildJobLog" class="build-log mb-0 h-100 mr-3" @scroll="scrollBuildLog">
<code
v-show="!detailJob.isLoading"
class="bash"
diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue
index 838c363a6a3..96f9a85c23f 100644
--- a/app/assets/javascripts/ide/components/preview/navigator.vue
+++ b/app/assets/javascripts/ide/components/preview/navigator.vue
@@ -117,7 +117,7 @@ export default {
class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent"
@click="refresh"
>
- <gl-icon :size="18" name="retry" use-deprecated-sizes class="m-auto" />
+ <gl-icon :size="16" name="retry" class="m-auto" />
</button>
<div class="position-relative w-100 gl-ml-2">
<input
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index f5e367e16f5..05e3601f381 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -1,14 +1,13 @@
-import { sprintf, n__, __ } from '../../../../locale';
+import { __ } from '../../../../locale';
import { COMMIT_TO_NEW_BRANCH } from './constants';
const BRANCH_SUFFIX_COUNT = 5;
const createTranslatedTextForFiles = (files, text) => {
if (!files.length) return null;
- return sprintf(n__('%{text} %{files}', '%{text} %{files} files', files.length), {
- files: files.reduce((acc, val) => acc.concat(val.path), []).join(', '),
- text,
- });
+ const filesPart = files.reduce((acc, val) => acc.concat(val.path), []).join(', ');
+
+ return `${text} ${filesPart}`;
};
export const discardDraftButtonDisabled = (state) =>
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
index 60561292c9d..9cf8d5a360e 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js
@@ -139,6 +139,7 @@ export const receiveJobLogsSuccess = ({ commit }, data) =>
export const fetchJobLogs = ({ dispatch, state }) => {
dispatch('requestJobLogs');
+ // update trace endpoint once BE compeletes trace re-naming in #340626
return axios
.get(`${state.detailJob.path}/trace`, { params: { format: 'json' } })
.then(({ data }) => dispatch('receiveJobLogsSuccess', data))
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 0cef3b98e61..ec661fdb0d6 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -117,7 +117,7 @@ export const createCommitPayload = ({
action: commitActionForFile(f),
file_path: f.path,
previous_path: f.prevPath || undefined,
- content: f.prevPath && !f.changed ? null : content || undefined,
+ content: content || undefined,
encoding: isBlob ? 'base64' : 'text',
last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
};
diff --git a/app/assets/javascripts/import_entities/components/pagination_bar.vue b/app/assets/javascripts/import_entities/components/pagination_bar.vue
new file mode 100644
index 00000000000..33bd3e08bb1
--- /dev/null
+++ b/app/assets/javascripts/import_entities/components/pagination_bar.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
+
+const DEFAULT_PAGE_SIZES = [20, 50, 100];
+
+export default {
+ components: {
+ PaginationLinks,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ pageInfo: {
+ required: true,
+ type: Object,
+ },
+ pageSizes: {
+ required: false,
+ type: Array,
+ default: () => DEFAULT_PAGE_SIZES,
+ },
+ itemsCount: {
+ required: true,
+ type: Number,
+ },
+ },
+
+ computed: {
+ humanizedTotal() {
+ return this.pageInfo.total >= 1000 ? __('1000+') : this.pageInfo.total;
+ },
+
+ paginationInfo() {
+ const { page, perPage } = this.pageInfo;
+ const start = (page - 1) * perPage + 1;
+ const end = start + this.itemsCount - 1;
+
+ return { start, end };
+ },
+ },
+
+ methods: {
+ setPage(page) {
+ this.$emit('set-page', page);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" />
+ <gl-dropdown category="tertiary" class="gl-ml-auto">
+ <template #button-content>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ pageInfo.perPage }}
+ </template>
+ </gl-sprintf>
+ </span>
+ <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" />
+ </template>
+ <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="$emit('set-page-size', size)">
+ <gl-sprintf :message="__('%{count} items per page')">
+ <template #count>
+ {{ size }}
+ </template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-ml-2" data-testid="information">
+ <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')">
+ <template #start>
+ {{ paginationInfo.start }}
+ </template>
+ <template #end>
+ {{ paginationInfo.end }}
+ </template>
+ <template #total>
+ {{ humanizedTotal }}
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/constants.js b/app/assets/javascripts/integrations/constants.js
index b74ae209eb7..8a8d38b295c 100644
--- a/app/assets/javascripts/integrations/edit/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -1,5 +1,11 @@
import { s__ } from '~/locale';
+export const TEST_INTEGRATION_EVENT = 'testIntegration';
+export const SAVE_INTEGRATION_EVENT = 'saveIntegration';
+export const GET_JIRA_ISSUE_TYPES_EVENT = 'getJiraIssueTypes';
+export const TOGGLE_INTEGRATION_EVENT = 'toggleIntegration';
+export const VALIDATE_INTEGRATION_FORM_EVENT = 'validateIntegrationForm';
+
export const integrationLevels = {
GROUP: 'group',
INSTANCE: 'instance',
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index f7d7f4aa010..9804a9e15f6 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -1,6 +1,7 @@
<script>
import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import { TOGGLE_INTEGRATION_EVENT } from '~/integrations/constants';
import eventHub from '../event_hub';
export default {
@@ -26,7 +27,7 @@ export default {
},
methods: {
onChange(e) {
- eventHub.$emit('toggle', e);
+ eventHub.$emit(TOGGLE_INTEGRATION_EVENT, e);
},
},
};
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index 1fd4083b920..f30298676df 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { mapGetters } from 'vuex';
+import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import eventHub from '../event_hub';
export default {
@@ -121,10 +122,10 @@ export default {
if (this.isNonEmptyPassword) {
this.model = null;
}
- eventHub.$on('validateForm', this.validateForm);
+ eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
beforeDestroy() {
- eventHub.$off('validateForm', this.validateForm);
+ eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
methods: {
validateForm() {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 63f007170d0..ba1aeb28616 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -2,7 +2,11 @@
import { GlButton, GlModalDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { integrationLevels } from '../constants';
+import {
+ TEST_INTEGRATION_EVENT,
+ SAVE_INTEGRATION_EVENT,
+ integrationLevels,
+} from '~/integrations/constants';
import eventHub from '../event_hub';
import ActiveCheckbox from './active_checkbox.vue';
@@ -75,11 +79,11 @@ export default {
]),
onSaveClick() {
this.setIsSaving(true);
- eventHub.$emit('saveIntegration');
+ eventHub.$emit(SAVE_INTEGRATION_EVENT);
},
onTestClick() {
this.setIsTesting(true);
- eventHub.$emit('testIntegration');
+ eventHub.$emit(TEST_INTEGRATION_EVENT);
},
onResetClick() {
this.fetchResetIntegration();
diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
index 1242493fb57..0521e1eeea5 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,6 +1,10 @@
<script>
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import {
+ VALIDATE_INTEGRATION_FORM_EVENT,
+ GET_JIRA_ISSUE_TYPES_EVENT,
+} from '~/integrations/constants';
import eventHub from '../event_hub';
import JiraUpgradeCta from './jira_upgrade_cta.vue';
@@ -77,17 +81,17 @@ export default {
},
},
created() {
- eventHub.$on('validateForm', this.validateForm);
+ eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
beforeDestroy() {
- eventHub.$off('validateForm', this.validateForm);
+ eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
methods: {
validateForm() {
this.validated = true;
},
getJiraIssueTypes() {
- eventHub.$emit('getJiraIssueTypes');
+ eventHub.$emit(GET_JIRA_ISSUE_TYPES_EVENT);
},
},
};
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 1cc5a185f03..249a3e105b1 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -9,6 +9,7 @@ import {
} from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { VALIDATE_INTEGRATION_FORM_EVENT } from '~/integrations/constants';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -118,10 +119,10 @@ export default {
},
},
created() {
- eventHub.$on('validateForm', this.validateForm);
+ eventHub.$on(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
beforeDestroy() {
- eventHub.$off('validateForm', this.validateForm);
+ eventHub.$off(VALIDATE_INTEGRATION_FORM_EVENT, this.validateForm);
},
methods: {
validateForm() {
diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
index 7b3a067b186..63650400bb7 100644
--- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
+++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue
@@ -2,7 +2,7 @@
import { GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
import { s__ } from '~/locale';
-import { defaultIntegrationLevel, overrideDropdownDescriptions } from '../constants';
+import { defaultIntegrationLevel, overrideDropdownDescriptions } from '~/integrations/constants';
const dropdownOptions = [
{
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 801cf3ed27e..f33364d5545 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,20 +1,26 @@
-import $ from 'jquery';
import { delay } from 'lodash';
import { __, s__ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
import axios from '../lib/utils/axios_utils';
import initForm from './edit';
import eventHub from './edit/event_hub';
+import {
+ TEST_INTEGRATION_EVENT,
+ SAVE_INTEGRATION_EVENT,
+ GET_JIRA_ISSUE_TYPES_EVENT,
+ TOGGLE_INTEGRATION_EVENT,
+ VALIDATE_INTEGRATION_FORM_EVENT,
+} from './constants';
export default class IntegrationSettingsForm {
constructor(formSelector) {
- this.$form = $(formSelector);
+ this.$form = document.querySelector(formSelector);
this.formActive = false;
this.vue = null;
// Form Metadata
- this.testEndPoint = this.$form.data('testUrl');
+ this.testEndPoint = this.$form.dataset.testUrl;
}
init() {
@@ -23,22 +29,19 @@ export default class IntegrationSettingsForm {
document.querySelector('.js-vue-integration-settings'),
document.querySelector('.js-vue-default-integration-settings'),
);
- eventHub.$on('toggle', (active) => {
+ eventHub.$on(TOGGLE_INTEGRATION_EVENT, (active) => {
this.formActive = active;
this.toggleServiceState();
});
- eventHub.$on('testIntegration', () => {
+ eventHub.$on(TEST_INTEGRATION_EVENT, () => {
this.testIntegration();
});
- eventHub.$on('saveIntegration', () => {
+ eventHub.$on(SAVE_INTEGRATION_EVENT, () => {
this.saveIntegration();
});
- eventHub.$on('getJiraIssueTypes', () => {
- // eslint-disable-next-line no-jquery/no-serialize
- this.getJiraIssueTypes(this.$form.serialize());
+ eventHub.$on(GET_JIRA_ISSUE_TYPES_EVENT, () => {
+ this.getJiraIssueTypes(new FormData(this.$form));
});
-
- eventHub.$emit('formInitialized');
}
saveIntegration() {
@@ -47,14 +50,14 @@ export default class IntegrationSettingsForm {
// 2) If this service can be saved
// If both conditions are true, we override form submission
// and save the service using provided configuration.
- const formValid = this.$form.get(0).checkValidity() || this.formActive === false;
+ const formValid = this.$form.checkValidity() || this.formActive === false;
if (formValid) {
delay(() => {
- this.$form.trigger('submit');
+ this.$form.submit();
}, 100);
} else {
- eventHub.$emit('validateForm');
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
this.vue.$store.dispatch('setIsSaving', false);
}
}
@@ -65,11 +68,10 @@ export default class IntegrationSettingsForm {
// 2) If this service can be tested
// If both conditions are true, we override form submission
// and test the service using provided configuration.
- if (this.$form.get(0).checkValidity()) {
- // eslint-disable-next-line no-jquery/no-serialize
- this.testSettings(this.$form.serialize());
+ if (this.$form.checkValidity()) {
+ this.testSettings(new FormData(this.$form));
} else {
- eventHub.$emit('validateForm');
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
this.vue.$store.dispatch('setIsTesting', false);
}
}
@@ -79,9 +81,9 @@ export default class IntegrationSettingsForm {
*/
toggleServiceState() {
if (this.formActive) {
- this.$form.removeAttr('novalidate');
- } else if (!this.$form.attr('novalidate')) {
- this.$form.attr('novalidate', 'novalidate');
+ this.$form.removeAttribute('novalidate');
+ } else if (!this.$form.getAttribute('novalidate')) {
+ this.$form.setAttribute('novalidate', 'novalidate');
}
}
@@ -109,7 +111,7 @@ export default class IntegrationSettingsForm {
},
}) => {
if (error || !issuetypes?.length) {
- eventHub.$emit('validateForm');
+ eventHub.$emit(VALIDATE_INTEGRATION_FORM_EVENT);
throw new Error(message);
}
diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
index 707ac946b98..85018f133cb 100644
--- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
+++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
@@ -1,8 +1,8 @@
<script>
-import { GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
+import { GlLink, GlLoadingIcon, GlPagination, GlTable, GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { DEFAULT_PER_PAGE } from '~/api';
-import createFlash from '~/flash';
import { fetchOverrides } from '~/integrations/overrides/api';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -16,6 +16,7 @@ export default {
GlLoadingIcon,
GlPagination,
GlTable,
+ GlAlert,
ProjectAvatar,
},
props: {
@@ -36,6 +37,7 @@ export default {
overrides: [],
page: 1,
totalItems: 0,
+ errorMessage: null,
};
},
computed: {
@@ -49,6 +51,7 @@ export default {
methods: {
loadOverrides(page = this.page) {
this.isLoading = true;
+ this.errorMessage = null;
fetchOverrides(this.overridesPath, {
page,
@@ -61,11 +64,9 @@ export default {
this.overrides = data;
})
.catch((error) => {
- createFlash({
- message: this.$options.i18n.defaultErrorMessage,
- error,
- captureError: true,
- });
+ this.errorMessage = this.$options.i18n.defaultErrorMessage;
+
+ Sentry.captureException(error);
})
.finally(() => {
this.isLoading = false;
@@ -85,7 +86,11 @@ export default {
<template>
<div>
+ <gl-alert v-if="errorMessage" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
<gl-table
+ v-else
:items="overrides"
:fields="$options.fields"
:busy="isLoading"
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index ab42e8cdfeb..cd0b413265b 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,9 +11,10 @@ import {
GlFormInput,
GlFormCheckboxGroup,
} from '@gitlab/ui';
-import { partition, isString } from 'lodash';
+import { partition, isString, unescape } from 'lodash';
import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { sanitize } from '~/lib/dompurify';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import {
@@ -293,7 +294,7 @@ export default {
};
},
conditionallyShowToastSuccess(response) {
- const message = responseMessageFromSuccess(response);
+ const message = this.unescapeMsg(responseMessageFromSuccess(response));
if (message === '') {
this.showToastMessageSuccess();
@@ -309,13 +310,17 @@ export default {
this.closeModal();
},
showInvalidFeedbackMessage(response) {
+ const message = this.unescapeMsg(responseMessageFromError(response));
+
this.isLoading = false;
- this.invalidFeedbackMessage =
- responseMessageFromError(response) || this.$options.labels.invalidFeedbackMessageDefault;
+ this.invalidFeedbackMessage = message || this.$options.labels.invalidFeedbackMessageDefault;
},
handleMembersTokenSelectClear() {
this.invalidFeedbackMessage = '';
},
+ unescapeMsg(message) {
+ return unescape(sanitize(message, { ALLOWED_TAGS: [] }));
+ },
},
labels: {
members: {
diff --git a/app/assets/javascripts/invite_members/utils/response_message_parser.js b/app/assets/javascripts/invite_members/utils/response_message_parser.js
index b7bc9ea5652..52ec3be3205 100644
--- a/app/assets/javascripts/invite_members/utils/response_message_parser.js
+++ b/app/assets/javascripts/invite_members/utils/response_message_parser.js
@@ -18,7 +18,10 @@ function responseMessageStringForMultiple(message) {
return message.includes(':');
}
function responseMessageStringFirstPart(message) {
- return message.split(' and ')[0];
+ const firstPart = message.split(':')[1];
+ const firstMsg = firstPart.split(/ and [\w-]*$/)[0].trim();
+
+ return firstMsg;
}
export function responseMessageFromError(response) {
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 1c88f8dfdca..b0af3612e05 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -1,9 +1,14 @@
<script>
import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
import { ISSUABLE_TYPE } from '../constants';
export default {
- name: 'CsvExportModal',
+ i18n: {
+ exportText: __(
+ 'The CSV export will be created in the background. Once finished, it will be sent to %{email} in an attachment.',
+ ),
+ },
components: {
GlButton,
GlModal,
@@ -32,53 +37,39 @@ export default {
required: true,
},
},
- data() {
- return {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
- };
+ computed: {
+ isIssue() {
+ return this.issuableType === ISSUABLE_TYPE.issues;
+ },
+ exportText() {
+ return this.isIssue ? __('Export issues') : __('Export merge requests');
+ },
+ issuableCountText() {
+ return this.isIssue
+ ? n__('1 issue selected', '%d issues selected', this.issuableCount)
+ : n__('1 merge request selected', '%d merge requests selected', this.issuableCount);
+ },
},
- issueableType: ISSUABLE_TYPE,
};
</script>
<template>
- <gl-modal :modal-id="modalId" body-class="gl-p-0!" data-qa-selector="export_issuable_modal">
- <template #modal-title>
- <gl-sprintf :message="__('Export %{name}')">
- <template #name>{{ issuableName }}</template>
- </gl-sprintf>
- </template>
+ <gl-modal
+ :modal-id="modalId"
+ body-class="gl-p-0!"
+ :title="exportText"
+ data-qa-selector="export_issuable_modal"
+ >
<div
- v-if="issuableCount > -1"
class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50"
>
<gl-icon name="check" class="gl-color-green-400" />
- <strong class="gl-m-3">
- <gl-sprintf
- v-if="issuableType === $options.issueableType.issues"
- :message="n__('1 issue selected', '%d issues selected', issuableCount)"
- >
- <template #issuableCount>{{ issuableCount }}</template>
- </gl-sprintf>
- <gl-sprintf
- v-else
- :message="n__('1 merge request selected', '%d merge requests selected', issuableCount)"
- >
- <template #issuableCount>{{ issuableCount }}</template>
- </gl-sprintf>
- </strong>
+ <strong class="gl-m-3">{{ issuableCountText }}</strong>
</div>
<div class="modal-text gl-px-4 gl-py-5">
- <gl-sprintf
- :message="
- __(
- `The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`,
- )
- "
- >
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
+ <gl-sprintf :message="$options.i18n.exportText">
+ <template #email>
+ <strong>{{ email }}</strong>
</template>
</gl-sprintf>
</div>
@@ -92,9 +83,7 @@ export default {
data-track-action="click_button"
:data-track-label="`export_${issuableType}_csv`"
>
- <gl-sprintf :message="__('Export %{name}')">
- <template #name>{{ issuableName }}</template>
- </gl-sprintf>
+ {{ exportText }}
</gl-button>
</template>
</gl-modal>
diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
index 4fdd094072c..269f720bac9 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -15,6 +15,8 @@ import CsvImportModal from './csv_import_modal.vue';
export default {
i18n: {
exportAsCsvButtonText: __('Export as CSV'),
+ importCsvText: __('Import CSV'),
+ importFromJiraText: __('Import from Jira'),
importIssuesText: __('Import issues'),
},
name: 'CsvImportExportButtons',
@@ -101,13 +103,16 @@ export default {
:text-sr-only="!showLabel"
:icon="importButtonIcon"
>
- <gl-dropdown-item v-gl-modal="importModalId">{{ __('Import CSV') }}</gl-dropdown-item>
+ <gl-dropdown-item v-gl-modal="importModalId">
+ {{ $options.i18n.importCsvText }}
+ </gl-dropdown-item>
<gl-dropdown-item
v-if="canEdit"
:href="projectImportJiraPath"
data-qa-selector="import_from_jira_link"
- >{{ __('Import from Jira') }}</gl-dropdown-item
>
+ {{ $options.i18n.importFromJiraText }}
+ </gl-dropdown-item>
</gl-dropdown>
</gl-button-group>
<csv-export-modal
diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue
index c85efd60b8b..b72abe14ee1 100644
--- a/app/assets/javascripts/issuable/components/csv_import_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue
@@ -1,23 +1,28 @@
<script>
-import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui';
+import { GlModal, GlFormGroup } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
-import { ISSUABLE_TYPE } from '../constants';
+import { __, sprintf } from '~/locale';
export default {
- name: 'CsvImportModal',
+ i18n: {
+ maximumFileSizeText: __('The maximum file size allowed is %{size}.'),
+ importIssuesText: __('Import issues'),
+ uploadCsvFileText: __('Upload CSV file'),
+ mainText: __(
+ "Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
+ ),
+ helpText: __(
+ 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
+ ),
+ },
+ actionPrimary: {
+ text: __('Import issues'),
+ },
components: {
GlModal,
- GlSprintf,
GlFormGroup,
- GlButton,
},
inject: {
- issuableType: {
- default: '',
- },
- exportCsvPath: {
- default: '',
- },
importCsvIssuesPath: {
default: '',
},
@@ -31,11 +36,10 @@ export default {
required: true,
},
},
- data() {
- return {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests',
- };
+ computed: {
+ maxFileSizeText() {
+ return sprintf(this.$options.i18n.maximumFileSizeText, { size: this.maxAttachmentSize });
+ },
},
methods: {
submitForm() {
@@ -47,34 +51,22 @@ export default {
</script>
<template>
- <gl-modal :modal-id="modalId" :title="__('Import issues')">
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.importIssuesText"
+ :action-primary="$options.actionPrimary"
+ @primary="submitForm"
+ >
<form ref="form" :action="importCsvIssuesPath" enctype="multipart/form-data" method="post">
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <p>
- {{
- __(
- "Your issues will be imported in the background. Once finished, you'll get a confirmation email.",
- )
- }}
- </p>
- <gl-form-group :label="__('Upload CSV file')" label-for="file">
+ <p>{{ $options.i18n.mainText }}</p>
+ <gl-form-group :label="$options.i18n.uploadCsvFileText" label-for="file">
<input id="file" type="file" name="file" accept=".csv,text/csv" />
</gl-form-group>
<p class="text-secondary">
- {{
- __(
- 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.',
- )
- }}
- <gl-sprintf :message="__('The maximum file size allowed is %{size}.')"
- ><template #size>{{ maxAttachmentSize }}</template></gl-sprintf
- >
+ {{ $options.i18n.helpText }}
+ {{ maxFileSizeText }}
</p>
</form>
- <template #modal-footer>
- <gl-button category="primary" variant="confirm" @click="submitForm">{{
- __('Import issues')
- }}</gl-button>
- </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 5dc49d3ae15..bafc26befda 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -50,20 +50,16 @@ export default class IssuableForm {
this.renderWipExplanation = this.renderWipExplanation.bind(this);
this.resetAutosave = this.resetAutosave.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
- /* eslint-disable @gitlab/require-i18n-strings */
// prettier-ignore
this.draftRegex = new RegExp(
'^\\s*(' + // Line start, then any amount of leading whitespace
- 'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace
- '|\\[draft\\]\\s*' + // [Draft] and any following whitespace
+ '\\[draft\\]\\s*' + // [Draft] and any following whitespace
'|draft:\\s*' + // Draft: and any following whitespace
- '|draft\\s+' + // Draft_ where "_" is at least one whitespace
'|\\(draft\\)\\s*' + // (Draft) and any following whitespace
')+' + // At least one repeated match of the preceding parenthetical
'\\s*', // Any amount of trailing whitespace
'i', // Match any case(s)
);
- /* eslint-enable @gitlab/require-i18n-strings */
this.gfmAutoComplete = new GfmAutoComplete(
gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources,
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index df9d5c86a4b..ab04c6a38a5 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
+import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -15,6 +15,7 @@ export default {
GlIcon,
GlLabel,
GlFormCheckbox,
+ GlSprintf,
IssuableAssignees,
},
directives: {
@@ -82,9 +83,7 @@ export default {
return this.issuable.assignees?.nodes || this.issuable.assignees || [];
},
createdAt() {
- return sprintf(__('created %{timeAgo}'), {
- timeAgo: getTimeago().format(this.issuable.createdAt),
- });
+ return getTimeago().format(this.issuable.createdAt);
},
updatedAt() {
return sprintf(__('updated %{timeAgo}'), {
@@ -164,132 +163,132 @@ export default {
<template>
<li
:id="`issuable_${issuableId}`"
- class="issue gl-px-5!"
+ class="issue gl-display-flex! gl-px-5!"
:class="{ closed: issuable.closedAt, today: createdInPastDay }"
:data-labels="labelIdsString"
>
- <div class="issuable-info-container">
- <div v-if="showCheckbox" class="issue-check">
- <gl-form-checkbox
- class="gl-mr-0"
- :checked="checked"
- :data-id="issuableId"
- @input="$emit('checked-input', $event)"
+ <gl-form-checkbox
+ v-if="showCheckbox"
+ class="issue-check gl-mr-0"
+ :checked="checked"
+ :data-id="issuableId"
+ @input="$emit('checked-input', $event)"
+ >
+ <span class="gl-sr-only">{{ issuable.title }}</span>
+ </gl-form-checkbox>
+ <div class="issuable-main-info">
+ <div data-testid="issuable-title" class="issue-title title">
+ <gl-icon
+ v-if="issuable.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ :aria-label="__('Confidential')"
+ />
+ <gl-link class="issue-title-text" dir="auto" :href="webUrl" v-bind="issuableTitleProps">
+ {{ issuable.title }}
+ <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
+ </gl-link>
+ <span
+ v-if="taskStatus"
+ class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
+ data-testid="task-status"
>
- <span class="gl-sr-only">{{ issuable.title }}</span>
- </gl-form-checkbox>
+ {{ taskStatus }}
+ </span>
</div>
- <div class="issuable-main-info">
- <div data-testid="issuable-title" class="issue-title title">
- <span class="issue-title-text" dir="auto">
- <gl-icon
- v-if="issuable.confidential"
- v-gl-tooltip
- name="eye-slash"
- :title="__('Confidential')"
- :aria-label="__('Confidential')"
- />
- <gl-link :href="webUrl" v-bind="issuableTitleProps">
- {{ issuable.title
- }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
- /></gl-link>
- </span>
- <span
- v-if="taskStatus"
- class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-3"
- data-testid="task-status"
- >
- {{ taskStatus }}
- </span>
- </div>
- <div class="issuable-info">
- <slot v-if="hasSlotContents('reference')" name="reference"></slot>
- <span v-else data-testid="issuable-reference" class="issuable-reference">
- {{ reference }}
- </span>
- <span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
- <span aria-hidden="true">&middot;</span>
- <span
- v-gl-tooltip:tooltipcontainer.bottom
- data-testid="issuable-created-at"
- :title="tooltipTitle(issuable.createdAt)"
- >{{ createdAt }}</span
- >
- {{ __('by') }}
- <slot v-if="hasSlotContents('author')" name="author"></slot>
- <gl-link
- v-else
- :data-user-id="authorId"
- :data-username="author.username"
- :data-name="author.name"
- :data-avatar-url="author.avatarUrl"
- :href="author.webUrl"
- data-testid="issuable-author"
- class="author-link js-user-link"
- >
- <span class="author">{{ author.name }}</span>
- </gl-link>
+ <div class="issuable-info">
+ <slot v-if="hasSlotContents('reference')" name="reference"></slot>
+ <span v-else data-testid="issuable-reference" class="issuable-reference">
+ {{ reference }}
+ </span>
+ <span class="gl-display-none gl-sm-display-inline-block">
+ <span aria-hidden="true">&middot;</span>
+ <span class="issuable-authored gl-mr-3">
+ <gl-sprintf :message="__('created %{timeAgo} by %{author}')">
+ <template #timeAgo>
+ <span
+ v-gl-tooltip.bottom
+ :title="tooltipTitle(issuable.createdAt)"
+ data-testid="issuable-created-at"
+ >
+ {{ createdAt }}
+ </span>
+ </template>
+ <template #author>
+ <slot v-if="hasSlotContents('author')" name="author"></slot>
+ <gl-link
+ v-else
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :data-avatar-url="author.avatarUrl"
+ :href="author.webUrl"
+ data-testid="issuable-author"
+ class="author-link js-user-link"
+ >
+ <span class="author">{{ author.name }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
</span>
<slot name="timeframe"></slot>
- &nbsp;
- <span v-if="labels.length" role="group" :aria-label="__('Labels')">
- <gl-label
- v-for="(label, index) in labels"
- :key="index"
- :background-color="label.color"
- :title="labelTitle(label)"
- :description="label.description"
- :scoped="scopedLabel(label)"
- :target="labelTarget(label)"
- :class="{ 'gl-ml-2': index }"
- size="sm"
- />
- </span>
- </div>
+ </span>
+ &nbsp;
+ <span v-if="labels.length" role="group" :aria-label="__('Labels')">
+ <gl-label
+ v-for="(label, index) in labels"
+ :key="index"
+ :background-color="label.color"
+ :title="labelTitle(label)"
+ :description="label.description"
+ :scoped="scopedLabel(label)"
+ :target="labelTarget(label)"
+ :class="{ 'gl-ml-2': index }"
+ size="sm"
+ />
+ </span>
</div>
- <div class="issuable-meta">
- <ul v-if="showIssuableMeta" class="controls">
- <li v-if="hasSlotContents('status')" class="issuable-status">
- <slot name="status"></slot>
- </li>
- <li v-if="assignees.length" class="gl-display-flex">
- <issuable-assignees
- :assignees="assignees"
- :icon-size="16"
- :max-visible="4"
- img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
- />
- </li>
- <slot name="statistics"></slot>
- <li
- v-if="showDiscussions"
- data-testid="issuable-discussions"
- class="issuable-comments gl-display-none gl-sm-display-block"
- >
- <gl-link
- v-gl-tooltip:tooltipcontainer.top
- :title="__('Comments')"
- :href="issuableNotesLink"
- :class="{ 'no-comments': !notesCount }"
- class="gl-reset-color!"
- >
- <gl-icon name="comments" />
- {{ notesCount }}
- </gl-link>
- </li>
- </ul>
- <div
- data-testid="issuable-updated-at"
- class="float-right issuable-updated-at gl-display-none gl-sm-display-inline-block"
+ </div>
+ <div class="issuable-meta">
+ <ul v-if="showIssuableMeta" class="controls">
+ <li v-if="hasSlotContents('status')" class="issuable-status">
+ <slot name="status"></slot>
+ </li>
+ <li v-if="assignees.length">
+ <issuable-assignees
+ :assignees="assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ <slot name="statistics"></slot>
+ <li
+ v-if="showDiscussions"
+ data-testid="issuable-discussions"
+ class="issuable-comments gl-display-none gl-sm-display-block"
>
- <span
- v-gl-tooltip:tooltipcontainer.bottom
- :title="tooltipTitle(issuable.updatedAt)"
- class="issuable-updated-at"
- >{{ updatedAt }}</span
+ <gl-link
+ v-gl-tooltip.top
+ :title="__('Comments')"
+ :href="issuableNotesLink"
+ :class="{ 'no-comments': !notesCount }"
+ class="gl-reset-color!"
>
- </div>
+ <gl-icon name="comments" />
+ {{ notesCount }}
+ </gl-link>
+ </li>
+ </ul>
+ <div
+ v-gl-tooltip.bottom
+ class="gl-text-gray-500 gl-display-none gl-sm-display-inline-block"
+ :title="tooltipTitle(issuable.updatedAt)"
+ data-testid="issuable-updated-at"
+ >
+ {{ updatedAt }}
</div>
</div>
</li>
diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
index 87066a0a0b6..c1082987146 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -284,72 +284,70 @@ export default {
<slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot>
</template>
</issuable-bulk-edit-sidebar>
- <div class="issuables-holder">
- <ul v-if="issuablesLoading" class="content-list">
- <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
- <gl-skeleton-loading />
- </li>
- </ul>
- <template v-else>
- <component
- :is="issuablesWrapper"
- v-if="issuables.length > 0"
- class="content-list issuable-list issues-list"
- :class="{ 'manual-ordering': isManualOrdering }"
- v-bind="$options.vueDraggableAttributes"
- @update="handleVueDraggableUpdate"
+ <ul v-if="issuablesLoading" class="content-list">
+ <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
+ <template v-else>
+ <component
+ :is="issuablesWrapper"
+ v-if="issuables.length > 0"
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ v-bind="$options.vueDraggableAttributes"
+ @update="handleVueDraggableUpdate"
+ >
+ <issuable-item
+ v-for="issuable in issuables"
+ :key="issuableId(issuable)"
+ :class="{ 'gl-cursor-grab': isManualOrdering }"
+ :issuable-symbol="issuableSymbol"
+ :issuable="issuable"
+ :enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
+ :show-checkbox="showBulkEditSidebar"
+ :checked="issuableChecked(issuable)"
+ @checked-input="handleIssuableCheckedInput(issuable, $event)"
>
- <issuable-item
- v-for="issuable in issuables"
- :key="issuableId(issuable)"
- :class="{ 'gl-cursor-grab': isManualOrdering }"
- :issuable-symbol="issuableSymbol"
- :issuable="issuable"
- :enable-label-permalinks="enableLabelPermalinks"
- :label-filter-param="labelFilterParam"
- :show-checkbox="showBulkEditSidebar"
- :checked="issuableChecked(issuable)"
- @checked-input="handleIssuableCheckedInput(issuable, $event)"
- >
- <template #reference>
- <slot name="reference" :issuable="issuable"></slot>
- </template>
- <template #author>
- <slot name="author" :author="issuable.author"></slot>
- </template>
- <template #timeframe>
- <slot name="timeframe" :issuable="issuable"></slot>
- </template>
- <template #status>
- <slot name="status" :issuable="issuable"></slot>
- </template>
- <template #statistics>
- <slot name="statistics" :issuable="issuable"></slot>
- </template>
- </issuable-item>
- </component>
- <slot v-else name="empty-state"></slot>
- </template>
+ <template #reference>
+ <slot name="reference" :issuable="issuable"></slot>
+ </template>
+ <template #author>
+ <slot name="author" :author="issuable.author"></slot>
+ </template>
+ <template #timeframe>
+ <slot name="timeframe" :issuable="issuable"></slot>
+ </template>
+ <template #status>
+ <slot name="status" :issuable="issuable"></slot>
+ </template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
+ </issuable-item>
+ </component>
+ <slot v-else name="empty-state"></slot>
+ </template>
- <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
- <gl-keyset-pagination
- :has-next-page="hasNextPage"
- :has-previous-page="hasPreviousPage"
- @next="$emit('next-page')"
- @prev="$emit('previous-page')"
- />
- </div>
- <gl-pagination
- v-else-if="showPaginationControls"
- :per-page="defaultPageSize"
- :total-items="totalItems"
- :value="currentPage"
- :prev-page="previousPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination gl-mt-3"
- @input="$emit('page-change', $event)"
+ <div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
+ <gl-keyset-pagination
+ :has-next-page="hasNextPage"
+ :has-previous-page="hasPreviousPage"
+ @next="$emit('next-page')"
+ @prev="$emit('previous-page')"
/>
</div>
+ <gl-pagination
+ v-else-if="showPaginationControls"
+ :per-page="defaultPageSize"
+ :total-items="totalItems"
+ :value="currentPage"
+ :prev-page="previousPage"
+ :next-page="nextPage"
+ align="center"
+ class="gl-pagination gl-mt-3"
+ @input="$emit('page-change', $event)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue
index d0642b64e7e..48a5e220abf 100644
--- a/app/assets/javascripts/issuable_suggestions/components/app.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/app.vue
@@ -74,7 +74,7 @@ export default {
:title="$options.helpText"
:aria-label="$options.helpText"
name="question-o"
- class="text-secondary suggestion-help-hover"
+ class="text-secondary gl-cursor-help"
/>
</div>
<div class="col-sm-10">
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
index dea7608685a..a01f4f747b9 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
@@ -26,12 +25,6 @@ export default {
},
},
computed: {
- isOpen() {
- return this.suggestion.state === 'opened';
- },
- isClosed() {
- return this.suggestion.state === 'closed';
- },
counts() {
return [
{
@@ -48,7 +41,13 @@ export default {
},
].filter(({ count }) => count);
},
- stateIcon() {
+ isClosed() {
+ return this.suggestion.state === 'closed';
+ },
+ stateIconClass() {
+ return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500';
+ },
+ stateIconName() {
return this.isClosed ? 'issue-close' : 'issue-open-m';
},
stateTitle() {
@@ -72,7 +71,7 @@ export default {
v-gl-tooltip.bottom
:title="__('Confidential')"
name="eye-slash"
- class="suggestion-help-hover mr-1 suggestion-confidential"
+ class="gl-cursor-help gl-mr-2 gl-text-orange-500"
/>
<gl-link
:href="suggestion.webUrl"
@@ -83,15 +82,7 @@ export default {
</gl-link>
</div>
<div class="text-secondary suggestion-footer">
- <gl-icon
- ref="state"
- :name="stateIcon"
- :class="{
- 'suggestion-state-open': isOpen,
- 'suggestion-state-closed': isClosed,
- }"
- class="suggestion-help-hover"
- />
+ <gl-icon ref="state" :name="stateIconName" :class="stateIconClass" class="gl-cursor-help" />
<gl-tooltip :target="() => $refs.state" placement="bottom">
<span class="d-block">
<span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }}
@@ -102,9 +93,9 @@ export default {
<timeago-tooltip
:time="suggestion.createdAt"
tooltip-placement="bottom"
- class="suggestion-help-hover"
+ class="gl-cursor-help"
/>
- by
+ {{ __('by') }}
<gl-link :href="suggestion.author.webUrl">
<user-avatar-image
:img-src="suggestion.author.avatarUrl"
@@ -122,7 +113,7 @@ export default {
<timeago-tooltip
:time="suggestion.updatedAt"
tooltip-placement="bottom"
- class="suggestion-help-hover"
+ class="gl-cursor-help"
/>
</template>
<span class="suggestion-counts">
@@ -131,7 +122,7 @@ export default {
:key="id"
v-gl-tooltip.bottom
:title="tooltipTitle"
- class="suggestion-help-hover gl-ml-3 text-tertiary"
+ class="gl-cursor-help gl-ml-3 text-tertiary"
>
<gl-icon :name="icon" /> {{ count }}
</span>
diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js
index 8f7f317d6b4..22a99a17741 100644
--- a/app/assets/javascripts/issuable_suggestions/index.js
+++ b/app/assets/javascripts/issuable_suggestions/index.js
@@ -10,7 +10,12 @@ export default function initIssuableSuggestions() {
const issueTitle = document.getElementById('issue_title');
const { projectPath } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
return new Vue({
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
index f3c2a31bd5b..4b99888ae73 100644
--- a/app/assets/javascripts/issue_show/components/locked_warning.vue
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -1,30 +1,33 @@
<script>
-import { __, sprintf } from '~/locale';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const alertMessage = __(
+ 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.',
+);
export default {
+ alertMessage,
+ components: {
+ GlSprintf,
+ GlLink,
+ },
computed: {
currentPath() {
return window.location.pathname;
},
- alertMessage() {
- return sprintf(
- __(
- 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.',
- ),
- {
- linkStart: `<a href="${this.currentPath}" target="_blank" rel="nofollow">`,
- linkEnd: `</a>`,
- },
- false,
- );
- },
},
};
</script>
<template>
- <div
- class="alert alert-danger"
- v-html="alertMessage /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div class="alert alert-danger">
+ <gl-sprintf :message="$options.alertMessage">
+ <template #link="{ content }">
+ <gl-link :href="currentPath" target="_blank" rel="nofollow">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</template>
diff --git a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
index a687a58a6ad..4a2f7861492 100644
--- a/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
+++ b/app/assets/javascripts/issues_list/components/issue_card_time_info.vue
@@ -85,7 +85,7 @@ export default {
<span>
<span
v-if="issue.milestone"
- class="issuable-milestone gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ class="issuable-milestone gl-mr-3"
data-testid="issuable-milestone"
>
<gl-link v-gl-tooltip :href="milestoneLink" :title="milestoneDate">
@@ -96,7 +96,7 @@ export default {
<span
v-if="issue.dueDate"
v-gl-tooltip
- class="issuable-due-date gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ class="issuable-due-date gl-mr-3"
:class="{ 'gl-text-red-500': showDueDateInRed }"
:title="__('Due date')"
data-testid="issuable-due-date"
@@ -107,21 +107,14 @@ export default {
<span
v-if="timeEstimate"
v-gl-tooltip
- class="gl-display-none gl-sm-display-inline-block! gl-mr-3"
+ class="gl-mr-3"
:title="__('Estimate')"
data-testid="time-estimate"
>
<gl-icon name="timer" />
{{ timeEstimate }}
</span>
- <weight-count
- class="issuable-weight gl-display-none gl-sm-display-inline-block gl-mr-3"
- :weight="issue.weight"
- />
- <issue-health-status
- v-if="showHealthStatus"
- class="gl-display-none gl-sm-display-inline-block"
- :health-status="healthStatus"
- />
+ <weight-count class="issuable-weight gl-mr-3" :weight="issue.weight" />
+ <issue-health-status v-if="showHealthStatus" :health-status="healthStatus" />
</span>
</template>
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index 8e37339fca6..7b51f6ee46a 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -68,13 +68,6 @@ import {
TOKEN_TITLE_TYPE,
TOKEN_TITLE_WEIGHT,
} from '~/vue_shared/components/filtered_search_bar/constants';
-import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
-import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
-import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
-import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
-import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
-import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
-import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
import searchIterationsQuery from '../queries/search_iterations.query.graphql';
@@ -82,6 +75,21 @@ import searchLabelsQuery from '../queries/search_labels.query.graphql';
import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
import searchUsersQuery from '../queries/search_users.query.graphql';
import IssueCardTimeInfo from './issue_card_time_info.vue';
+import NewIssueDropdown from './new_issue_dropdown.vue';
+
+const AuthorToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue');
+const EmojiToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
+const EpicToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue');
+const IterationToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue');
+const LabelToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue');
+const MilestoneToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue');
+const WeightToken = () =>
+ import('~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue');
export default {
i18n,
@@ -96,6 +104,7 @@ export default {
IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
+ NewIssueDropdown,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
@@ -120,12 +129,15 @@ export default {
fullPath: {
default: '',
},
- groupEpicsPath: {
+ groupPath: {
default: '',
},
hasAnyIssues: {
default: false,
},
+ hasAnyProjects: {
+ default: false,
+ },
hasBlockedIssuesFeature: {
default: false,
},
@@ -253,6 +265,9 @@ export default {
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
+ showNewIssueDropdown() {
+ return !this.isProject && this.hasAnyProjects;
+ },
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
@@ -363,16 +378,18 @@ export default {
});
}
- if (this.groupEpicsPath) {
+ if (this.groupPath) {
tokens.push({
type: TOKEN_TYPE_EPIC,
title: TOKEN_TITLE_EPIC,
icon: 'epic',
token: EpicToken,
unique: true,
+ symbol: '&',
idProperty: 'id',
useIdValue: true,
- fetchEpics: this.fetchEpics,
+ recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-epic_id`,
+ fullPath: this.groupPath,
});
}
@@ -442,16 +459,6 @@ export default {
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
- async fetchEpics({ search }) {
- const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics');
- if (!search) {
- return epics.slice(0, MAX_LIST_SIZE);
- }
- const number = Number(search);
- return Number.isNaN(number)
- ? fuzzaldrinPlus.filter(epics, search, { key: 'title' })
- : epics.filter((epic) => epic.id === number);
- },
fetchLabels(search) {
return this.$apollo
.query({
@@ -662,6 +669,7 @@ export default {
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
+ <new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
<template #timeframe="{ issuable = {} }">
@@ -765,6 +773,7 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
+ <new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
</gl-empty-state>
<hr />
diff --git a/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
new file mode 100644
index 00000000000..037fd9be542
--- /dev/null
+++ b/app/assets/javascripts/issues_list/components/new_issue_dropdown.vue
@@ -0,0 +1,124 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
+import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
+import { __, sprintf } from '~/locale';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+
+export default {
+ i18n: {
+ defaultDropdownText: __('Select project to create issue'),
+ noMatchesFound: __('No matches found'),
+ toggleButtonLabel: __('Toggle project select'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ inject: ['fullPath'],
+ data() {
+ return {
+ projects: [],
+ search: '',
+ selectedProject: {},
+ shouldSkipQuery: true,
+ };
+ },
+ apollo: {
+ projects: {
+ query: searchProjectsQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.search,
+ };
+ },
+ update: ({ group }) => group.projects.nodes ?? [],
+ error(error) {
+ createFlash({
+ message: __('An error occurred while loading projects.'),
+ captureError: true,
+ error,
+ });
+ },
+ skip() {
+ return this.shouldSkipQuery;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ computed: {
+ dropdownHref() {
+ return this.hasSelectedProject
+ ? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new')
+ : undefined;
+ },
+ dropdownText() {
+ return this.hasSelectedProject
+ ? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name })
+ : this.$options.i18n.defaultDropdownText;
+ },
+ hasSelectedProject() {
+ return this.selectedProject.id;
+ },
+ showNoSearchResultsText() {
+ return !this.projects.length && this.search;
+ },
+ },
+ methods: {
+ handleDropdownClick() {
+ if (!this.dropdownHref) {
+ this.$refs.dropdown.show();
+ }
+ },
+ handleDropdownShown() {
+ if (this.shouldSkipQuery) {
+ this.shouldSkipQuery = false;
+ }
+ this.$refs.search.focusInput();
+ },
+ selectProject(project) {
+ this.selectedProject = project;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ right
+ split
+ :split-href="dropdownHref"
+ :text="dropdownText"
+ :toggle-text="$options.i18n.toggleButtonLabel"
+ variant="confirm"
+ @click="handleDropdownClick"
+ @shown="handleDropdownShown"
+ >
+ <gl-search-box-by-type ref="search" v-model.trim="search" />
+ <gl-loading-icon v-if="$apollo.queries.projects.loading" />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="project of projects"
+ :key="project.id"
+ @click="selectProject(project)"
+ >
+ {{ project.nameWithNamespace }}
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="showNoSearchResultsText">
+ {{ $options.i18n.noMatchesFound }}
+ </gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index e89e3e8e681..47af20f5271 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -119,8 +119,9 @@ export function mountIssuesListApp() {
emptyStateSvgPath,
exportCsvPath,
fullPath,
- groupEpicsPath,
+ groupPath,
hasAnyIssues,
+ hasAnyProjects,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
@@ -151,8 +152,9 @@ export function mountIssuesListApp() {
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
fullPath,
- groupEpicsPath,
+ groupPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
+ hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
diff --git a/app/assets/javascripts/issues_list/queries/search_projects.query.graphql b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
new file mode 100644
index 00000000000..df1f330139a
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/search_projects.query.graphql
@@ -0,0 +1,12 @@
+query searchProjects($fullPath: ID!, $search: String) {
+ group(fullPath: $fullPath) {
+ projects(search: $search, includeSubgroups: true) {
+ nodes {
+ id
+ name
+ nameWithNamespace
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 059772e8cb9..fe4158a1bd1 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -80,13 +80,13 @@ export default {
'isLoading',
'job',
'isSidebarOpen',
- 'trace',
- 'isTraceComplete',
- 'traceSize',
- 'isTraceSizeVisible',
+ 'jobLog',
+ 'isJobLogComplete',
+ 'jobLogSize',
+ 'isJobLogSizeVisible',
'isScrollBottomDisabled',
'isScrollTopDisabled',
- 'isScrolledToBottomBeforeReceivingTrace',
+ 'isScrolledToBottomBeforeReceivingJobLog',
'hasError',
'selectedStage',
]),
@@ -97,7 +97,7 @@ export default {
'shouldRenderTriggeredLabel',
'hasEnvironment',
'shouldRenderSharedRunnerLimitWarning',
- 'hasTrace',
+ 'hasJobLog',
'emptyStateIllustration',
'isScrollingDown',
'emptyStateAction',
@@ -155,7 +155,7 @@ export default {
this.updateSidebar();
},
beforeDestroy() {
- this.stopPollingTrace();
+ this.stopPollingJobLog();
this.stopPolling();
window.removeEventListener('resize', this.onResize);
window.removeEventListener('scroll', this.updateScroll);
@@ -168,7 +168,7 @@ export default {
'toggleSidebar',
'scrollBottom',
'scrollTop',
- 'stopPollingTrace',
+ 'stopPollingJobLog',
'stopPolling',
'toggleScrollButtons',
'toggleScrollAnimation',
@@ -270,7 +270,7 @@ export default {
<div
v-if="job.archived"
class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job"
- :class="{ 'sticky-top gl-border-bottom-0': hasTrace }"
+ :class="{ 'sticky-top gl-border-bottom-0': hasJobLog }"
data-testid="archived-job"
>
<gl-icon name="lock" class="gl-vertical-align-bottom" />
@@ -278,8 +278,8 @@ export default {
</div>
<!-- job log -->
<div
- v-if="hasTrace"
- class="build-trace-container gl-relative"
+ v-if="hasJobLog"
+ class="build-log-container gl-relative"
:class="{ 'gl-mt-3': !job.archived }"
>
<log-top-bar
@@ -289,22 +289,22 @@ export default {
'has-archived-block': job.archived,
}"
:erase-path="job.erase_path"
- :size="traceSize"
+ :size="jobLogSize"
:raw-path="job.raw_path"
:is-scroll-bottom-disabled="isScrollBottomDisabled"
:is-scroll-top-disabled="isScrollTopDisabled"
- :is-trace-size-visible="isTraceSizeVisible"
+ :is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
/>
- <log :trace="trace" :is-complete="isTraceComplete" />
+ <log :job-log="jobLog" :is-complete="isJobLogComplete" />
</div>
<!-- EO job log -->
<!-- empty state -->
<empty-state
- v-if="!hasTrace"
+ v-if="!hasJobLog"
:illustration-path="emptyStateIllustration.image"
:illustration-size-class="emptyStateIllustration.size"
:title="emptyStateTitle"
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 957e8243f33..6105299e15c 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -44,7 +44,7 @@ export default {
type: Boolean,
required: true,
},
- isTraceSizeVisible: {
+ isJobLogSizeVisible: {
type: Boolean,
required: true,
},
@@ -73,7 +73,7 @@ export default {
class="truncated-info gl-display-none gl-sm-display-block gl-float-left"
data-testid="log-truncated-info"
>
- <template v-if="isTraceSizeVisible">
+ <template v-if="isJobLogSizeVisible">
{{ jobLogSize }}
<gl-link
v-if="rawPath"
diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
index c0d5fac0e8d..757b2e458e9 100644
--- a/app/assets/javascripts/jobs/components/log/collapsible_section.vue
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -17,7 +17,7 @@ export default {
type: Object,
required: true,
},
- traceEndpoint: {
+ jobLogEndpoint: {
type: String,
required: true,
},
@@ -42,7 +42,7 @@ export default {
<log-line-header
:line="section.line"
:duration="badgeDuration"
- :path="traceEndpoint"
+ :path="jobLogEndpoint"
:is-closed="section.isClosed"
@toggleLine="handleOnClickCollapsibleLine(section)"
/>
@@ -53,10 +53,10 @@ export default {
v-if="line.isHeader"
:key="line.line.offset"
:section="line"
- :trace-endpoint="traceEndpoint"
+ :job-log-endpoint="jobLogEndpoint"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" />
+ <log-line v-else :key="line.offset" :line="line" :path="jobLogEndpoint" />
</template>
</template>
<template v-else>
@@ -64,7 +64,7 @@ export default {
v-for="line in section.lines"
:key="line.offset"
:line="line"
- :path="traceEndpoint"
+ :path="jobLogEndpoint"
/>
</template>
</template>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 0134e5dafe8..ef95d79b8ab 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -10,10 +10,10 @@ export default {
},
computed: {
...mapState([
- 'traceEndpoint',
- 'trace',
- 'isTraceComplete',
- 'isScrolledToBottomBeforeReceivingTrace',
+ 'jobLogEndpoint',
+ 'jobLog',
+ 'isJobLogComplete',
+ 'isScrolledToBottomBeforeReceivingJobLog',
]),
},
updated() {
@@ -39,7 +39,7 @@ export default {
* In order to scroll the page down after `v-html` has finished, we need to use setTimeout
*/
handleScrollDown() {
- if (this.isScrolledToBottomBeforeReceivingTrace) {
+ if (this.isScrolledToBottomBeforeReceivingJobLog) {
setTimeout(() => {
this.scrollBottom();
}, 0);
@@ -50,18 +50,18 @@ export default {
</script>
<template>
<code class="job-log d-block" data-qa-selector="job_log_content">
- <template v-for="(section, index) in trace">
+ <template v-for="(section, index) in jobLog">
<collapsible-log-section
v-if="section.isHeader"
:key="`collapsible-${index}`"
:section="section"
- :trace-endpoint="traceEndpoint"
+ :job-log-endpoint="jobLogEndpoint"
@onClickCollapsibleLine="handleOnClickCollapsibleLine"
/>
- <log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" />
+ <log-line v-else :key="section.offset" :line="section" :path="jobLogEndpoint" />
</template>
- <div v-if="!isTraceComplete" class="js-log-animation loader-animation pt-3 pl-3">
+ <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 6b3a4424a5b..51251c0cacc 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -18,6 +18,7 @@ import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql'
import playJobMutation from '../graphql/mutations/job_play.mutation.graphql';
import retryJobMutation from '../graphql/mutations/job_retry.mutation.graphql';
import unscheduleJobMutation from '../graphql/mutations/job_unschedule.mutation.graphql';
+import { reportMessageToSentry } from '../../../utils';
export default {
ACTIONS_DOWNLOAD_ARTIFACTS,
@@ -34,6 +35,7 @@ export default {
jobPlay: 'jobPlay',
jobUnschedule: 'jobUnschedule',
playJobModalId: 'play-job-modal',
+ name: 'JobActionsCell',
components: {
GlButton,
GlButtonGroup,
@@ -99,15 +101,17 @@ export default {
variables: { id: this.job.id },
});
if (errors.length > 0) {
- this.reportFailure();
+ reportMessageToSentry(this.$options.name, errors.join(', '), {});
+ this.showToastMessage();
} else {
eventHub.$emit('jobActionPerformed');
}
- } catch {
- this.reportFailure();
+ } catch (failure) {
+ reportMessageToSentry(this.$options.name, failure, {});
+ this.showToastMessage();
}
},
- reportFailure() {
+ showToastMessage() {
const toastProps = {
text: this.$options.GENERIC_ERROR,
variant: 'danger',
@@ -136,7 +140,13 @@ export default {
<template>
<gl-button-group>
<template v-if="canReadJob">
- <gl-button v-if="isActive" icon="cancel" :title="$options.CANCEL" @click="cancelJob()" />
+ <gl-button
+ v-if="isActive"
+ data-testid="cancel-button"
+ icon="cancel"
+ :title="$options.CANCEL"
+ @click="cancelJob()"
+ />
<template v-else-if="isScheduled">
<gl-button icon="planning" disabled data-testid="countdown">
<gl-countdown :end-date-string="scheduledAt" />
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 53e3dbbad0d..927ba7c7e1e 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -18,16 +18,16 @@ import * as types from './mutation_types';
export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
dispatch('setJobEndpoint', endpoint);
- dispatch('setTraceOptions', {
+ dispatch('setJobLogOptions', {
logState,
pagePath,
});
- return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]);
+ return Promise.all([dispatch('fetchJob'), dispatch('fetchJobLog')]);
};
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
-export const setTraceOptions = ({ commit }, options) => commit(types.SET_TRACE_OPTIONS, options);
+export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options);
export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR);
export const showSidebar = ({ commit }) => commit(types.SHOW_SIDEBAR);
@@ -107,7 +107,7 @@ export const receiveJobError = ({ commit }) => {
};
/**
- * Job's Trace
+ * Job Log
*/
export const scrollTop = ({ dispatch }) => {
scrollUp();
@@ -156,59 +156,62 @@ export const toggleScrollAnimation = ({ commit }, toggle) =>
* Responsible to handle automatic scroll
*/
export const toggleScrollisInBottom = ({ commit }, toggle) => {
- commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE, toggle);
+ commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG, toggle);
};
-export const requestTrace = ({ commit }) => commit(types.REQUEST_TRACE);
+export const requestJobLog = ({ commit }) => commit(types.REQUEST_JOB_LOG);
-export const fetchTrace = ({ dispatch, state }) =>
+export const fetchJobLog = ({ dispatch, state }) =>
+ // update trace endpoint once BE compeletes trace re-naming in #340626
axios
- .get(`${state.traceEndpoint}/trace.json`, {
- params: { state: state.traceState },
+ .get(`${state.jobLogEndpoint}/trace.json`, {
+ params: { state: state.jobLogState },
})
.then(({ data }) => {
dispatch('toggleScrollisInBottom', isScrolledToBottom());
- dispatch('receiveTraceSuccess', data);
+ dispatch('receiveJobLogSuccess', data);
if (data.complete) {
- dispatch('stopPollingTrace');
- } else if (!state.traceTimeout) {
- dispatch('startPollingTrace');
+ dispatch('stopPollingJobLog');
+ } else if (!state.jobLogTimeout) {
+ dispatch('startPollingJobLog');
}
})
.catch((e) => {
if (e.response.status === httpStatusCodes.FORBIDDEN) {
- dispatch('receiveTraceUnauthorizedError');
+ dispatch('receiveJobLogUnauthorizedError');
} else {
reportToSentry('job_actions', e);
- dispatch('receiveTraceError');
+ dispatch('receiveJobLogError');
}
});
-export const startPollingTrace = ({ dispatch, commit }) => {
- const traceTimeout = setTimeout(() => {
- commit(types.SET_TRACE_TIMEOUT, 0);
- dispatch('fetchTrace');
+export const startPollingJobLog = ({ dispatch, commit }) => {
+ const jobLogTimeout = setTimeout(() => {
+ commit(types.SET_JOB_LOG_TIMEOUT, 0);
+ dispatch('fetchJobLog');
}, 4000);
- commit(types.SET_TRACE_TIMEOUT, traceTimeout);
+ commit(types.SET_JOB_LOG_TIMEOUT, jobLogTimeout);
};
-export const stopPollingTrace = ({ state, commit }) => {
- clearTimeout(state.traceTimeout);
- commit(types.SET_TRACE_TIMEOUT, 0);
- commit(types.STOP_POLLING_TRACE);
+export const stopPollingJobLog = ({ state, commit }) => {
+ clearTimeout(state.jobLogTimeout);
+ commit(types.SET_JOB_LOG_TIMEOUT, 0);
+ commit(types.STOP_POLLING_JOB_LOG);
};
-export const receiveTraceSuccess = ({ commit }, log) => commit(types.RECEIVE_TRACE_SUCCESS, log);
-export const receiveTraceError = ({ dispatch }) => {
- dispatch('stopPollingTrace');
+export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JOB_LOG_SUCCESS, log);
+
+export const receiveJobLogError = ({ dispatch }) => {
+ dispatch('stopPollingJobLog');
createFlash({
message: __('An error occurred while fetching the job log.'),
});
};
-export const receiveTraceUnauthorizedError = ({ dispatch }) => {
- dispatch('stopPollingTrace');
+
+export const receiveJobLogUnauthorizedError = ({ dispatch }) => {
+ dispatch('stopPollingJobLog');
createFlash({
message: __('The current user is not authorized to access the job log.'),
});
@@ -248,6 +251,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
};
export const receiveJobsForStageSuccess = ({ commit }, data) =>
commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data);
+
export const receiveJobsForStageError = ({ commit }) => {
commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR);
createFlash({
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 6cb96bee07d..9d255822250 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -21,11 +21,12 @@ export const shouldRenderTriggeredLabel = (state) => isString(state.job.started)
export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status);
/**
- * Checks if it the job has trace.
+ * Checks if it the job has a log.
* Used to check if it should render the job log or the empty state
* @returns {Boolean}
*/
-export const hasTrace = (state) =>
+export const hasJobLog = (state) =>
+ // update has_trace once BE compeletes trace re-naming in #340626
state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running');
export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {};
@@ -43,7 +44,7 @@ export const shouldRenderSharedRunnerLimitWarning = (state) =>
!isEmpty(state.job.runners.quota) &&
state.job.runners.quota.used >= state.job.runners.quota.limit;
-export const isScrollingDown = (state) => isScrolledToBottom() && !state.isTraceComplete;
+export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete;
export const hasRunnersForProject = (state) =>
state?.job?.runners?.available && !state?.job?.runners?.online;
diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js
index 6c4f1b5a191..4915a826b84 100644
--- a/app/assets/javascripts/jobs/store/mutation_types.js
+++ b/app/assets/javascripts/jobs/store/mutation_types.js
@@ -1,5 +1,5 @@
export const SET_JOB_ENDPOINT = 'SET_JOB_ENDPOINT';
-export const SET_TRACE_OPTIONS = 'SET_TRACE_OPTIONS';
+export const SET_JOB_LOG_OPTIONS = 'SET_JOB_LOG_OPTIONS';
export const HIDE_SIDEBAR = 'HIDE_SIDEBAR';
export const SHOW_SIDEBAR = 'SHOW_SIDEBAR';
@@ -12,17 +12,17 @@ export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM';
export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP';
export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION';
-export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE = 'TOGGLE_IS_SCROLL_IN_BOTTOM';
+export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG = 'TOGGLE_IS_SCROLL_IN_BOTTOM';
export const REQUEST_JOB = 'REQUEST_JOB';
export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS';
export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR';
-export const REQUEST_TRACE = 'REQUEST_TRACE';
-export const SET_TRACE_TIMEOUT = 'SET_TRACE_TIMEOUT';
-export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE';
-export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS';
-export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR';
+export const REQUEST_JOB_LOG = 'REQUEST_JOB_LOG';
+export const SET_JOB_LOG_TIMEOUT = 'SET_JOB_LOG_TIMEOUT';
+export const STOP_POLLING_JOB_LOG = 'STOP_POLLING_JOB_LOG';
+export const RECEIVE_JOB_LOG_SUCCESS = 'RECEIVE_JOB_LOG_SUCCESS';
+export const RECEIVE_JOB_LOG_ERROR = 'RECEIVE_JOB_LOG_ERROR';
export const TOGGLE_COLLAPSIBLE_LINE = 'TOGGLE_COLLAPSIBLE_LINE';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 4045d8a0c16..eda2ee0349a 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -1,16 +1,16 @@
import Vue from 'vue';
import { INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF } from '../constants';
import * as types from './mutation_types';
-import { logLinesParser, logLinesParserLegacy, updateIncrementalTrace } from './utils';
+import { logLinesParser, logLinesParserLegacy, updateIncrementalJobLog } from './utils';
export default {
[types.SET_JOB_ENDPOINT](state, endpoint) {
state.jobEndpoint = endpoint;
},
- [types.SET_TRACE_OPTIONS](state, options = {}) {
- state.traceEndpoint = options.pagePath;
- state.traceState = options.logState;
+ [types.SET_JOB_LOG_OPTIONS](state, options = {}) {
+ state.jobLogEndpoint = options.pagePath;
+ state.jobLogState = options.logState;
},
[types.HIDE_SIDEBAR](state) {
@@ -20,11 +20,11 @@ export default {
state.isSidebarOpen = true;
},
- [types.RECEIVE_TRACE_SUCCESS](state, log = {}) {
+ [types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) {
const infinitelyCollapsibleSectionsFlag =
gon.features?.[INFINITELY_NESTED_COLLAPSIBLE_SECTIONS_FF];
if (log.state) {
- state.traceState = log.state;
+ state.jobLogState = log.state;
}
if (log.append) {
@@ -32,52 +32,52 @@ export default {
if (log.lines) {
const parsedResult = logLinesParser(
log.lines,
- state.auxiliaryPartialTraceHelpers,
- state.trace,
+ state.auxiliaryPartialJobLogHelpers,
+ state.jobLog,
);
- state.trace = parsedResult.parsedLines;
- state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
+ state.jobLog = parsedResult.parsedLines;
+ state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
}
} else {
- state.trace = log.lines ? updateIncrementalTrace(log.lines, state.trace) : state.trace;
+ state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog;
}
- state.traceSize += log.size;
+ state.jobLogSize += log.size;
} else {
- // When the job still does not have a trace
- // the trace response will not have a defined
+ // When the job still does not have a log
+ // the job log response will not have a defined
// html or size. We keep the old value otherwise these
// will be set to `null`
if (infinitelyCollapsibleSectionsFlag) {
const parsedResult = logLinesParser(log.lines);
- state.trace = parsedResult.parsedLines;
- state.auxiliaryPartialTraceHelpers = parsedResult.auxiliaryPartialTraceHelpers;
+ state.jobLog = parsedResult.parsedLines;
+ state.auxiliaryPartialJobLogHelpers = parsedResult.auxiliaryPartialJobLogHelpers;
} else {
- state.trace = log.lines ? logLinesParserLegacy(log.lines) : state.trace;
+ state.jobLog = log.lines ? logLinesParserLegacy(log.lines) : state.jobLog;
}
- state.traceSize = log.size || state.traceSize;
+ state.jobLogSize = log.size || state.jobLogSize;
}
- if (state.traceSize < log.total) {
- state.isTraceSizeVisible = true;
+ if (state.jobLogSize < log.total) {
+ state.isJobLogSizeVisible = true;
} else {
- state.isTraceSizeVisible = false;
+ state.isJobLogSizeVisible = false;
}
- state.isTraceComplete = log.complete || state.isTraceComplete;
+ state.isJobLogComplete = log.complete || state.isJobLogComplete;
},
- [types.SET_TRACE_TIMEOUT](state, id) {
- state.traceTimeout = id;
+ [types.SET_JOB_LOG_TIMEOUT](state, id) {
+ state.jobLogTimeout = id;
},
/**
* Will remove loading animation
*/
- [types.STOP_POLLING_TRACE](state) {
- state.isTraceComplete = true;
+ [types.STOP_POLLING_JOB_LOG](state) {
+ state.isJobLogComplete = true;
},
/**
@@ -137,8 +137,8 @@ export default {
state.isScrollingDown = toggle;
},
- [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_TRACE](state, toggle) {
- state.isScrolledToBottomBeforeReceivingTrace = toggle;
+ [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG](state, toggle) {
+ state.isScrolledToBottomBeforeReceivingJobLog = toggle;
},
[types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) {
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 718324c8bad..a1ba64aa71e 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -1,6 +1,6 @@
export default () => ({
jobEndpoint: null,
- traceEndpoint: null,
+ jobLogEndpoint: null,
// sidebar
isSidebarOpen: true,
@@ -14,16 +14,16 @@ export default () => ({
isScrollTopDisabled: true,
// Used to check if we should keep the automatic scroll
- isScrolledToBottomBeforeReceivingTrace: true,
+ isScrolledToBottomBeforeReceivingJobLog: true,
- trace: [],
- isTraceComplete: false,
- traceSize: 0,
- isTraceSizeVisible: false,
- traceTimeout: 0,
+ jobLog: [],
+ isJobLogComplete: false,
+ jobLogSize: 0,
+ isJobLogSizeVisible: false,
+ jobLogTimeout: 0,
- // used as a query parameter to fetch the trace
- traceState: null,
+ // used as a query parameter to fetch the job log
+ jobLogState: null,
// sidebar dropdown & list of jobs
isLoadingJobs: false,
@@ -32,5 +32,5 @@ export default () => ({
jobs: [],
// to parse partial logs
- auxiliaryPartialTraceHelpers: {},
+ auxiliaryPartialJobLogHelpers: {},
});
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index b64734e29f6..8bca448ee11 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -131,17 +131,17 @@ export const logLinesParserLegacy = (lines = [], accumulator = []) =>
[...accumulator],
);
-export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLines = []) => {
- let currentLineCount = previousTraceState?.prevLineCount ?? 0;
- let currentHeader = previousTraceState?.currentHeader;
- let isPreviousLineHeader = previousTraceState?.isPreviousLineHeader ?? false;
+export const logLinesParser = (lines = [], previousJobLogState = {}, prevParsedLines = []) => {
+ let currentLineCount = previousJobLogState?.prevLineCount ?? 0;
+ let currentHeader = previousJobLogState?.currentHeader;
+ let isPreviousLineHeader = previousJobLogState?.isPreviousLineHeader ?? false;
const parsedLines = prevParsedLines.length > 0 ? prevParsedLines : [];
- const sectionsQueue = previousTraceState?.sectionsQueue ?? [];
+ const sectionsQueue = previousJobLogState?.sectionsQueue ?? [];
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
// First run we can use the current index, later runs we have to retrieve the last number of lines
- currentLineCount = previousTraceState?.prevLineCount ? currentLineCount + 1 : i + 1;
+ currentLineCount = previousJobLogState?.prevLineCount ? currentLineCount + 1 : i + 1;
if (line.section_header && !isPreviousLineHeader) {
// If there's no previous line header that means we're at the root of the log
@@ -198,7 +198,7 @@ export const logLinesParser = (lines = [], previousTraceState = {}, prevParsedLi
return {
parsedLines,
- auxiliaryPartialTraceHelpers: {
+ auxiliaryPartialJobLogHelpers: {
isPreviousLineHeader,
currentHeader,
sectionsQueue,
@@ -241,7 +241,7 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
};
/**
- * When the trace is not complete, backend may send the last received line
+ * When the job log is not complete, backend may send the last received line
* in the new response.
*
* We need to check if that is the case by looking for the offset property
@@ -250,7 +250,7 @@ export const findOffsetAndRemove = (newLog = [], oldParsed = []) => {
* @param array oldLog
* @param array newLog
*/
-export const updateIncrementalTrace = (newLog = [], oldParsed = []) => {
+export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => {
const parsedLog = findOffsetAndRemove(newLog, oldParsed);
return logLinesParserLegacy(newLog, parsedLog);
diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js
index bb27658369f..a4e695518f1 100644
--- a/app/assets/javascripts/jobs/utils.js
+++ b/app/assets/javascripts/jobs/utils.js
@@ -19,3 +19,12 @@ export const reportToSentry = (component, failureType) => {
Sentry.captureException(failureType);
});
};
+
+export const reportMessageToSentry = (component, message, context) => {
+ Sentry.withScope((scope) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ scope.setContext('Vue data', context);
+ scope.setTag('component', component);
+ Sentry.captureMessage(message);
+ });
+};
diff --git a/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
new file mode 100644
index 00000000000..ad92bd4de42
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/suppress_network_errors_during_navigation_link.js
@@ -0,0 +1,36 @@
+import { Observable } from 'apollo-link';
+import { onError } from 'apollo-link-error';
+import { isNavigatingAway } from '~/lib/utils/is_navigating_away';
+
+/**
+ * Returns an ApolloLink (or null if not enabled) which supresses network
+ * errors when the browser is navigating away.
+ *
+ * @returns {ApolloLink|null}
+ */
+export const getSuppressNetworkErrorsDuringNavigationLink = () => {
+ if (!gon.features?.suppressApolloErrorsDuringNavigation) {
+ return null;
+ }
+
+ return onError(({ networkError }) => {
+ if (networkError && isNavigatingAway()) {
+ // Return an observable that will never notify any subscribers with any
+ // values, errors, or completions. This ensures that requests aborted due
+ // to navigating away do not trigger any failure behaviour.
+ //
+ // See '../utils/suppress_ajax_errors_during_navigation.js' for an axios
+ // interceptor that performs a similar role.
+ return new Observable(() => {});
+ }
+
+ // We aren't suppressing anything here, so simply do nothing.
+ // The onError helper will forward all values/errors/completions from the
+ // underlying request observable to the next link if you return a falsey
+ // value.
+ //
+ // Note that this return statement is technically redundant, but is kept
+ // for explicitness.
+ return undefined;
+ });
+};
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index b96a55fe116..39bf804b54e 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -11,6 +11,7 @@ import csrf from '~/lib/utils/csrf';
import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
import { getInstrumentationLink } from './apollo/instrumentation_link';
+import { getSuppressNetworkErrorsDuringNavigationLink } from './apollo/suppress_network_errors_during_navigation_link';
export const fetchPolicies = {
CACHE_FIRST: 'cache-first',
@@ -143,6 +144,7 @@ export default (resolvers = {}, config = {}) => {
new ActionCableLink(),
ApolloLink.from(
[
+ getSuppressNetworkErrorsDuringNavigationLink(),
getInstrumentationLink(),
requestCounterLink,
performanceBarLink,
diff --git a/app/assets/javascripts/lib/logger/hello.js b/app/assets/javascripts/lib/logger/hello.js
index 18fa35ab55b..ccfdfe91e60 100644
--- a/app/assets/javascripts/lib/logger/hello.js
+++ b/app/assets/javascripts/lib/logger/hello.js
@@ -1,15 +1,36 @@
+import { s__, sprintf } from '~/locale';
+
const HANDSHAKE = String.fromCodePoint(0x1f91d);
const MAG = String.fromCodePoint(0x1f50e);
+const ROCKET = String.fromCodePoint(0x1f680);
export const logHello = () => {
// eslint-disable-next-line no-console
console.log(
- `%cWelcome to GitLab!%c
+ `%c${s__('HelloMessage|Welcome to GitLab!')}%c
-Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!
+${s__(
+ 'HelloMessage|Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!',
+)}
-${HANDSHAKE} Contribute to GitLab: https://about.gitlab.com/community/contribute/
-${MAG} Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new`,
+${sprintf(s__('HelloMessage|%{handshake_emoji} Contribute to GitLab: %{contribute_link}'), {
+ handshake_emoji: `${HANDSHAKE}`,
+ contribute_link: 'https://about.gitlab.com/community/contribute/',
+})}
+${sprintf(s__('HelloMessage|%{magnifier_emoji} Create a new GitLab issue: %{new_issue_link}'), {
+ magnifier_emoji: `${MAG}`,
+ new_issue_link: 'https://gitlab.com/gitlab-org/gitlab/-/issues/new',
+})}
+${
+ window.gon?.dot_com
+ ? `${sprintf(
+ s__(
+ 'HelloMessage|%{rocket_emoji} We like your curiosity! Help us improve GitLab by joining the team: %{jobs_page_link}',
+ ),
+ { rocket_emoji: `${ROCKET}`, jobs_page_link: 'https://about.gitlab.com/jobs/' },
+ )}`
+ : ''
+}`,
`padding-top: 0.5em; font-size: 2em;`,
'padding-bottom: 0.5em;',
);
diff --git a/app/assets/javascripts/lib/utils/axios_utils.js b/app/assets/javascripts/lib/utils/axios_utils.js
index 0a26f78e253..de6d85b8a18 100644
--- a/app/assets/javascripts/lib/utils/axios_utils.js
+++ b/app/assets/javascripts/lib/utils/axios_utils.js
@@ -2,6 +2,7 @@ import axios from 'axios';
import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor';
import setupAxiosStartupCalls from './axios_startup_calls';
import csrf from './csrf';
+import { isNavigatingAway } from './is_navigating_away';
import suppressAjaxErrorsDuringNavigation from './suppress_ajax_errors_during_navigation';
axios.defaults.headers.common[csrf.headerKey] = csrf.token;
@@ -30,16 +31,11 @@ axios.interceptors.response.use(
},
);
-let isUserNavigating = false;
-window.addEventListener('beforeunload', () => {
- isUserNavigating = true;
-});
-
// Ignore AJAX errors caused by requests
// being cancelled due to browser navigation
axios.interceptors.response.use(
(response) => response,
- (err) => suppressAjaxErrorsDuringNavigation(err, isUserNavigating),
+ (err) => suppressAjaxErrorsDuringNavigation(err, isNavigatingAway()),
);
registerCaptchaModalInterceptor(axios);
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index da2c10076b1..66d52051905 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -1,3 +1,22 @@
+const colorValidatorEl = document.createElement('div');
+
+/**
+ * Validates whether the specified color expression
+ * is supported by the browser’s DOM API and has a valid form.
+ *
+ * This utility assigns the color expression to a detached DOM
+ * element’s color property. If the color expression is valid,
+ * the DOM API will accept the value.
+ *
+ * @param {String} color color expression rgba, hex, hsla, etc.
+ */
+export const isValidColorExpression = (colorExpression) => {
+ colorValidatorEl.style.color = '';
+ colorValidatorEl.style.color = colorExpression;
+
+ return colorValidatorEl.style.color.length > 0;
+};
+
/**
* Convert hex color to rgb array
*
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index fd9629499b0..813fd3dbb1e 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -6,6 +6,7 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/util
import $ from 'jquery';
import Cookies from 'js-cookie';
import { isFunction, defer } from 'lodash';
+import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
import { getLocationHash } from './url_utility';
@@ -685,7 +686,7 @@ export const searchBy = (query = '', searchSpace = {}) => {
* @param {Object} label
* @returns Boolean
*/
-export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1;
+export const isScopedLabel = ({ title = '' } = {}) => title.includes(SCOPED_LABEL_DELIMITER);
/**
* Returns the base value of the scoped label
@@ -696,7 +697,8 @@ export const isScopedLabel = ({ title = '' } = {}) => title.indexOf('::') !== -1
* @param {Object} label
* @returns String
*/
-export const scopedLabelKey = ({ title = '' }) => isScopedLabel({ title }) && title.split('::')[0];
+export const scopedLabelKey = ({ title = '' }) =>
+ isScopedLabel({ title }) && title.split(SCOPED_LABEL_DELIMITER)[0];
// Methods to set and get Cookie
export const setCookie = (name, value) => Cookies.set(name, value, { expires: 365 });
diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js
index e41de72ded4..0e5a23a5cbb 100644
--- a/app/assets/javascripts/lib/utils/constants.js
+++ b/app/assets/javascripts/lib/utils/constants.js
@@ -20,3 +20,7 @@ export const BV_DROPDOWN_HIDE = 'bv::dropdown::hide';
export const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
+
+// We set the drawer's z-index to 252 to clear flash messages that might
+// be displayed in the page and that have a z-index of 251.
+export const DRAWER_Z_INDEX = 252;
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 0a35efb0ac8..3c446c21865 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -1,6 +1,8 @@
import dateFormat from 'dateformat';
-import { isString, mapValues, reduce, isDate } from 'lodash';
-import { s__, n__, __ } from '../../../locale';
+import { isString, mapValues, reduce, isDate, unescape } from 'lodash';
+import { roundToNearestHalf } from '~/lib/utils/common_utils';
+import { sanitize } from '~/lib/dompurify';
+import { s__, n__, __, sprintf } from '../../../locale';
/**
* Returns i18n month names array.
@@ -361,3 +363,26 @@ export const dateToTimeInputValue = (date) => {
hour12: false,
});
};
+
+export const formatTimeAsSummary = ({ seconds, hours, days, minutes, weeks, months }) => {
+ if (months) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}M'), {
+ value: roundToNearestHalf(months),
+ });
+ } else if (weeks) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}w'), {
+ value: roundToNearestHalf(weeks),
+ });
+ } else if (days) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}d'), {
+ value: roundToNearestHalf(days),
+ });
+ } else if (hours) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}h'), { value: hours });
+ } else if (minutes) {
+ return sprintf(s__('ValueStreamAnalytics|%{value}m'), { value: minutes });
+ } else if (seconds) {
+ return unescape(sanitize(s__('ValueStreamAnalytics|&lt;1m'), { ALLOWED_TAGS: [] }));
+ }
+ return '-';
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js
index a2b161d1446..840cc4600fe 100644
--- a/app/assets/javascripts/lib/utils/datetime_range.js
+++ b/app/assets/javascripts/lib/utils/datetime_range.js
@@ -26,7 +26,17 @@ const isValidDateString = (dateString) => {
return false;
}
- return !Number.isNaN(Date.parse(dateformat(dateString, 'isoUtcDateTime')));
+ let isoFormatted;
+ try {
+ isoFormatted = dateformat(dateString, 'isoUtcDateTime');
+ } catch (e) {
+ if (e instanceof TypeError) {
+ // not a valid date string
+ return false;
+ }
+ throw e;
+ }
+ return !Number.isNaN(Date.parse(isoFormatted));
};
const handleRangeDirection = ({ direction = DEFAULT_DIRECTION, anchorDate, minDate, maxDate }) => {
diff --git a/app/assets/javascripts/lib/utils/is_navigating_away.js b/app/assets/javascripts/lib/utils/is_navigating_away.js
new file mode 100644
index 00000000000..7df00b45379
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/is_navigating_away.js
@@ -0,0 +1,23 @@
+let navigating = false;
+
+window.addEventListener('beforeunload', () => {
+ navigating = true;
+});
+
+/**
+ * To only be used for testing purposes. Allows the navigating state to be set
+ * to a given value.
+ *
+ * @param {boolean} value The value to set the navigating flag to.
+ */
+export const setNavigatingForTestsOnly = (value) => {
+ navigating = value;
+};
+
+/**
+ * Returns a boolean indicating whether the browser is in the process of
+ * navigating away from the current page.
+ *
+ * @returns {boolean}
+ */
+export const isNavigatingAway = () => navigating;
diff --git a/app/assets/javascripts/lib/utils/regexp.js b/app/assets/javascripts/lib/utils/regexp.js
index 25b60dcd14a..f212bf80bd7 100644
--- a/app/assets/javascripts/lib/utils/regexp.js
+++ b/app/assets/javascripts/lib/utils/regexp.js
@@ -1,6 +1,5 @@
/**
* Regexp utility for the convenience of working with regular expressions.
- *
*/
// Inspired by https://github.com/mishoo/UglifyJS/blob/2bc1d02363db3798d5df41fb5059a19edca9b7eb/lib/parse-js.js#L203
@@ -8,4 +7,9 @@
const unicodeLetters =
'\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u16EE-\\u16F0\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2160-\\u2188\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005-\\u3007\\u3021-\\u3029\\u3031-\\u3035\\u3038-\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6EF\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC';
-export default { unicodeLetters };
+/**
+ * A regex that matches all single quotes in a string
+ */
+export const allSingleQuotes = /'/g;
+
+export default { unicodeLetters, allSingleQuotes };
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 5ee00464a8b..419afa0a0a9 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -4,6 +4,7 @@ import {
TRUNCATE_WIDTH_DEFAULT_WIDTH,
TRUNCATE_WIDTH_DEFAULT_FONT_SIZE,
} from '~/lib/utils/constants';
+import { allSingleQuotes } from '~/lib/utils/regexp';
/**
* Adds a , to a string composed by numbers, at every 3 chars.
@@ -479,3 +480,17 @@ export const markdownConfig = {
ALLOWED_ATTR: ['class', 'style', 'href', 'src'],
ALLOW_DATA_ATTR: false,
};
+
+/**
+ * Escapes a string into a shell string, for example
+ * when you want to give a user the command to checkout
+ * a branch.
+ *
+ * It replaces all single-quotes with an escaped "'\''"
+ * that is interpreted by shell as a single-quote. It also
+ * encapsulates the string in single-quotes.
+ *
+ * If the branch is `fix-'bug-behavior'`, that should be
+ * escaped to `'fix-'\''bug-behavior'\'''`.
+ */
+export const escapeShellString = (str) => `'${str.replace(allSingleQuotes, () => "'\\''")}'`;
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index bca0e45d98d..1c22d21a313 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,3 +1,5 @@
+export const DASH_SCOPE = '-';
+
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
@@ -588,3 +590,30 @@ export function isSameOriginUrl(url) {
return false;
}
}
+
+/**
+ * Returns a URL to WebIDE considering the current user's position in
+ * repository's tree. If not MR `iid` has been passed, the URL is fetched
+ * from the global `gl.webIDEPath`.
+ *
+ * @param sourceProjectFullPath Source project's full path. Used in MRs
+ * @param targetProjectFullPath Target project's full path. Used in MRs
+ * @param iid MR iid
+ * @returns {string}
+ */
+
+export function constructWebIDEPath({
+ sourceProjectFullPath,
+ targetProjectFullPath = '',
+ iid,
+} = {}) {
+ if (!iid || !sourceProjectFullPath) {
+ return window.gl?.webIDEPath;
+ }
+ return mergeUrlParams(
+ {
+ target_project: sourceProjectFullPath !== targetProjectFullPath ? targetProjectFullPath : '',
+ },
+ webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`),
+ );
+}
diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue
index 3db9fa01629..2a60825a427 100644
--- a/app/assets/javascripts/logs/components/environment_logs.vue
+++ b/app/assets/javascripts/logs/components/environment_logs.vue
@@ -214,7 +214,7 @@ export default {
<template #items>
<pre
ref="logTrace"
- class="build-trace"
+ class="build-log"
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js
index 83080589362..ee17e8ecef2 100644
--- a/app/assets/javascripts/logs/stores/state.js
+++ b/app/assets/javascripts/logs/stores/state.js
@@ -31,7 +31,7 @@ export default () => ({
},
/**
- * Logs including trace
+ * Jobs with logs
*/
logs: {
lines: [],
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index b96a2607552..e422d9b1a32 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -1,5 +1,4 @@
/* global $ */
-/* eslint-disable import/order */
import jQuery from 'jquery';
import Cookies from 'js-cookie';
@@ -15,6 +14,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
+import { initHeaderSearchApp } from '~/header_search';
import initAlertHandler from './alert_handler';
import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
@@ -36,7 +36,6 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
-import { initHeaderSearchApp } from '~/header_search';
import 'ee_else_ce/main_ee';
import 'jh_else_ce/main_jh';
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index 665e8ee69f7..69137ce615b 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
@@ -42,7 +42,7 @@ export default {
required: false,
default: false,
},
- oncallSchedules: {
+ userDeletionObstacles: {
type: Object,
required: false,
default: () => ({}),
@@ -61,7 +61,7 @@ export default {
memberPath: this.memberPath.replace(':id', this.memberId),
memberType: this.memberType,
message: this.message,
- oncallSchedules: this.oncallSchedules,
+ userDeletionObstacles: this.userDeletionObstacles,
};
},
},
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 0c20f935d50..44d658c90a0 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -1,5 +1,6 @@
<script>
import { s__, sprintf } from '~/locale';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import ActionButtonGroup from './action_button_group.vue';
import LeaveButton from './leave_button.vue';
import RemoveMemberButton from './remove_member_button.vue';
@@ -49,9 +50,11 @@ export default {
},
);
},
- oncallScheduleUserData() {
- const { user: { name, oncallSchedules: schedules } = {} } = this.member;
- return { name, schedules };
+ userDeletionObstaclesUserData() {
+ return {
+ name: this.member.user?.name,
+ obstacles: parseUserDeletionObstacles(this.member.user),
+ };
},
},
};
@@ -65,7 +68,7 @@ export default {
v-else
:member-id="member.id"
:member-type="member.type"
- :oncall-schedules="oncallScheduleUserData"
+ :user-deletion-obstacles="userDeletionObstaclesUserData"
:message="message"
:title="s__('Member|Remove member')"
/>
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index 44178981136..e39669e17dd 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -3,7 +3,8 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
-import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
+import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils';
import { LEAVE_MODAL_ID } from '../../constants';
export default {
@@ -20,7 +21,7 @@ export default {
csrf,
modalId: LEAVE_MODAL_ID,
modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
- components: { GlModal, GlForm, GlSprintf, OncallSchedulesList },
+ components: { GlModal, GlForm, GlSprintf, UserDeletionObstaclesList },
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -43,11 +44,11 @@ export default {
modalTitle() {
return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
},
- schedules() {
- return this.member.user?.oncallSchedules;
+ obstacles() {
+ return parseUserDeletionObstacles(this.member.user);
},
- isPartOfOnCallSchedules() {
- return this.schedules?.length;
+ hasObstaclesToUserDeletion() {
+ return this.obstacles?.length;
},
},
methods: {
@@ -74,9 +75,9 @@ export default {
</gl-sprintf>
</p>
- <oncall-schedules-list
- v-if="isPartOfOnCallSchedules"
- :schedules="schedules"
+ <user-deletion-obstacles-list
+ v-if="hasObstaclesToUserDeletion"
+ :obstacles="obstacles"
:is-current-user="true"
/>
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index 00b6ebf9a73..b82fb0030ff 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -3,7 +3,7 @@ import { GlFormCheckbox, GlModal } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import csrf from '~/lib/utils/csrf';
import { s__, __ } from '~/locale';
-import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
+import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
export default {
actionCancel: {
@@ -13,7 +13,7 @@ export default {
components: {
GlFormCheckbox,
GlModal,
- OncallSchedulesList,
+ UserDeletionObstaclesList,
},
inject: ['namespace'],
computed: {
@@ -33,8 +33,8 @@ export default {
message(state) {
return state[this.namespace].removeMemberModalData.message;
},
- oncallSchedules(state) {
- return state[this.namespace].removeMemberModalData.oncallSchedules ?? {};
+ userDeletionObstacles(state) {
+ return state[this.namespace].removeMemberModalData.userDeletionObstacles ?? {};
},
removeMemberModalVisible(state) {
return state[this.namespace].removeMemberModalVisible;
@@ -60,11 +60,11 @@ export default {
},
};
},
- showUnassignIssuablesCheckbox() {
+ hasWorkspaceAccess() {
return !this.isAccessRequest && !this.isInvite;
},
- isPartOfOncallSchedules() {
- return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
+ hasObstaclesToUserDeletion() {
+ return this.hasWorkspaceAccess && this.userDeletionObstacles.obstacles?.length;
},
},
methods: {
@@ -95,10 +95,10 @@ export default {
<form ref="form" :action="memberPath" method="post">
<p>{{ message }}</p>
- <oncall-schedules-list
- v-if="isPartOfOncallSchedules"
- :schedules="oncallSchedules.schedules"
- :user-name="oncallSchedules.name"
+ <user-deletion-obstacles-list
+ v-if="hasObstaclesToUserDeletion"
+ :obstacles="userDeletionObstacles.obstacles"
+ :user-name="userDeletionObstacles.name"
/>
<input ref="method" type="hidden" name="_method" value="delete" />
@@ -106,7 +106,7 @@ export default {
<gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships">
{{ __('Also remove direct user membership from subgroups and projects') }}
</gl-form-checkbox>
- <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables">
+ <gl-form-checkbox v-if="hasWorkspaceAccess" name="unassign_issuables">
{{ __('Also unassign this user from related issues and merge requests') }}
</gl-form-checkbox>
</form>
diff --git a/app/assets/javascripts/members/components/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue
deleted file mode 100644
index c91de061b50..00000000000
--- a/app/assets/javascripts/members/components/table/expires_at.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<script>
-import { GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import {
- approximateDuration,
- differenceInSeconds,
- formatDate,
- getDayDifference,
-} from '~/lib/utils/datetime_utility';
-import { DAYS_TO_EXPIRE_SOON } from '../../constants';
-
-export default {
- name: 'ExpiresAt',
- components: { GlSprintf },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- date: {
- type: String,
- required: false,
- default: null,
- },
- },
- computed: {
- noExpirationSet() {
- return this.date === null;
- },
- parsed() {
- return new Date(this.date);
- },
- differenceInSeconds() {
- return differenceInSeconds(new Date(), this.parsed);
- },
- isExpired() {
- return this.differenceInSeconds <= 0;
- },
- inWords() {
- return approximateDuration(this.differenceInSeconds);
- },
- formatted() {
- return formatDate(this.parsed);
- },
- expiresSoon() {
- return getDayDifference(new Date(), this.parsed) < DAYS_TO_EXPIRE_SOON;
- },
- cssClass() {
- return {
- 'gl-text-red-500': this.isExpired,
- 'gl-text-orange-500': this.expiresSoon,
- };
- },
- },
-};
-</script>
-
-<template>
- <span v-if="noExpirationSet">{{ s__('Members|No expiration set') }}</span>
- <span v-else v-gl-tooltip.hover :title="formatted" :class="cssClass">
- <template v-if="isExpired">{{ s__('Members|Expired') }}</template>
- <gl-sprintf v-else :message="s__('Members|in %{time}')">
- <template #time>
- {{ inWords }}
- </template>
- </gl-sprintf>
- </span>
-</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index debc3fc31f6..202f3aa89e1 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -5,12 +5,17 @@ import MembersTableCell from 'ee_else_ce/members/components/table/members_table_
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
-import { FIELDS, ACTIVE_TAB_QUERY_PARAM_NAME } from '../../constants';
+import {
+ FIELDS,
+ ACTIVE_TAB_QUERY_PARAM_NAME,
+ MEMBER_STATE_AWAITING,
+ USER_STATE_BLOCKED_PENDING_APPROVAL,
+ BADGE_LABELS_PENDING_OWNER_APPROVAL,
+} from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import RemoveMemberModal from '../modals/remove_member_modal.vue';
import CreatedAt from './created_at.vue';
import ExpirationDatepicker from './expiration_datepicker.vue';
-import ExpiresAt from './expires_at.vue';
import MemberActionButtons from './member_action_buttons.vue';
import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
@@ -24,7 +29,6 @@ export default {
GlPagination,
MemberAvatar,
CreatedAt,
- ExpiresAt,
MembersTableCell,
MemberSource,
MemberActionButtons,
@@ -131,6 +135,74 @@ export default {
window.location.href,
);
},
+ /**
+ * Returns whether it's a new or existing user
+ *
+ * If memberInviteMetadata doesn't exist, it means we're adding an existing user
+ * to the Group/Project, so `isNewUser` should be false.
+ * If memberInviteMetadata exists but `userState` has content,
+ * the user has registered but is awaiting root approval
+ *
+ * @param {object} memberInviteMetadata - MemberEntity.invite
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @returns {boolean}
+ */
+ isNewUser(memberInviteMetadata) {
+ return memberInviteMetadata && !memberInviteMetadata.userState;
+ },
+ /**
+ * Returns whether the user is awaiting root approval
+ *
+ * This checks User.state exposed via MemberEntity
+ *
+ * @param {object} memberInviteMetadata - MemberEntity.invite
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @returns {boolean}
+ */
+ isUserPendingRootApproval(memberInviteMetadata) {
+ return memberInviteMetadata?.userState === USER_STATE_BLOCKED_PENDING_APPROVAL;
+ },
+ /**
+ * Returns whether the member is awaiting owner approval
+ *
+ * This checks Member.state exposed via MemberEntity
+ *
+ * @param {Number} memberState - Member.state exposed via MemberEntity.state
+ * @see {@link ~/ee/app/models/ee/member.rb}
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @returns {boolean}
+ */
+ isMemberPendingOwnerApproval(memberState) {
+ return memberState === MEMBER_STATE_AWAITING;
+ },
+ isUserAwaiting(memberInviteMetadata, memberState) {
+ return (
+ this.isUserPendingRootApproval(memberInviteMetadata) ||
+ this.isMemberPendingOwnerApproval(memberState)
+ );
+ },
+ shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState) {
+ return (
+ this.isUserAwaiting(memberInviteMetadata, memberState) &&
+ !this.isNewUser(memberInviteMetadata)
+ );
+ },
+ /**
+ * Returns the string to be used in the invite badge
+ *
+ * @param {object} memberInviteMetadata - MemberEntity.invite
+ * @see {@link ~/app/serializers/member_entity.rb}
+ * @param {Number} memberState - Member.state exposed via MemberEntity.state
+ * @see {@link ~/ee/app/models/ee/member.rb}
+ * @returns {string}
+ */
+ inviteBadge(memberInviteMetadata, memberState) {
+ if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) {
+ return BADGE_LABELS_PENDING_OWNER_APPROVAL;
+ }
+
+ return '';
+ },
},
};
</script>
@@ -174,18 +246,17 @@ export default {
<created-at :date="createdAt" :created-by="createdBy" />
</template>
- <template #cell(invited)="{ item: { createdAt, createdBy } }">
+ <template #cell(invited)="{ item: { createdAt, createdBy, invite, state } }">
<created-at :date="createdAt" :created-by="createdBy" />
+ <gl-badge v-if="inviteBadge(invite, state)" data-testid="invited-badge">{{
+ inviteBadge(invite, state)
+ }}</gl-badge>
</template>
<template #cell(requested)="{ item: { createdAt } }">
<created-at :date="createdAt" />
</template>
- <template #cell(expires)="{ item: { expiresAt } }">
- <expires-at :date="expiresAt" />
- </template>
-
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
<role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" />
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 6f465245d20..f5ca881ab0d 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -38,12 +38,6 @@ export const FIELDS = [
tdClass: 'col-meta',
},
{
- key: 'expires',
- label: __('Access expires'),
- thClass: 'col-meta',
- tdClass: 'col-meta',
- },
- {
key: 'maxRole',
label: __('Max role'),
thClass: 'col-max-role',
@@ -95,6 +89,22 @@ export const TAB_QUERY_PARAM_VALUES = {
accessRequest: 'access_requests',
};
+/**
+ * This user state value comes from the User model
+ * see the state machine in app/models/user.rb
+ */
+export const USER_STATE_BLOCKED_PENDING_APPROVAL = 'blocked_pending_approval';
+
+/**
+ * This and following member state constants' values
+ * come from ee/app/models/ee/member.rb
+ */
+export const MEMBER_STATE_CREATED = 0;
+export const MEMBER_STATE_AWAITING = 1;
+export const MEMBER_STATE_ACTIVE = 2;
+
+export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval');
+
export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal';
diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
index a856d38c089..87eeb272659 100644
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -35,7 +35,11 @@ export default {
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="line_content header">
<strong>{{ line.richText }}</strong>
- <button type="button" @click="handleSelected({ file, line })">
+ <button
+ type="button"
+ class="gl-border-1 gl-border-solid"
+ @click="handleSelected({ file, line })"
+ >
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
index 2c89b614c87..2c59e7bfa2f 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -35,7 +35,11 @@ export default {
<td class="diff-line-num header" :class="lineCssClass(line)"></td>
<td class="line_content header" :class="lineCssClass(line)">
<strong>{{ line.richText }}</strong>
- <button type="button" @click="handleSelected({ file, line })">
+ <button
+ type="button"
+ class="gl-border-1 gl-border-solid"
+ @click="handleSelected({ file, line })"
+ >
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index ed32f26583e..244cf1e150a 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import createFlash from '~/flash';
+import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import eventHub from '~/vue_merge_request_widget/event_hub';
import axios from './lib/utils/axios_utils';
@@ -136,10 +137,9 @@ MergeRequest.hideCloseButton = function () {
MergeRequest.toggleDraftStatus = function (title, isReady) {
if (isReady) {
- createFlash({
- message: __('Marked as ready. Merging is now allowed.'),
- type: 'notice',
- });
+ toast(__('Marked as ready. Merging is now allowed.'));
+ } else {
+ toast(__('Marked as draft. Can only be merged when marked as ready.'));
}
const titleEl = document.querySelector('.merge-request .detail-page-description .title');
diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js
index 714cf67e0bd..6e46c5d3c1f 100644
--- a/app/assets/javascripts/mr_popover/index.js
+++ b/app/assets/javascripts/mr_popover/index.js
@@ -48,7 +48,12 @@ export default (elements) => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
const listenerAddedAttr = 'data-mr-listener-added';
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
deleted file mode 100644
index af7a600d1ad..00000000000
--- a/app/assets/javascripts/namespace_select.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import Api from './api';
-import { mergeUrlParams } from './lib/utils/url_utility';
-import { __ } from './locale';
-
-export default class NamespaceSelect {
- constructor(opts) {
- const isFilter = parseBoolean(opts.dropdown.dataset.isFilter);
- const fieldName = opts.dropdown.dataset.fieldName || 'namespace_id';
-
- initDeprecatedJQueryDropdown($(opts.dropdown), {
- filterable: true,
- selectable: true,
- filterRemote: true,
- search: {
- fields: ['path'],
- },
- fieldName,
- toggleLabel(selected) {
- if (selected.id == null) {
- return selected.text;
- }
- return `${selected.kind}: ${selected.full_path}`;
- },
- data(term, dataCallback) {
- return Api.namespaces(term, (namespaces) => {
- if (isFilter) {
- const anyNamespace = {
- text: __('Any namespace'),
- id: null,
- };
- namespaces.unshift(anyNamespace);
- namespaces.splice(1, 0, { type: 'divider' });
- }
- return dataCallback(namespaces);
- });
- },
- text(namespace) {
- if (namespace.id == null) {
- return namespace.text;
- }
- return `${namespace.kind}: ${namespace.full_path}`;
- },
- renderRow: this.renderRow,
- clicked(options) {
- if (!isFilter) {
- const { e } = options;
- e.preventDefault();
- }
- },
- url(namespace) {
- return mergeUrlParams({ [fieldName]: namespace.id }, window.location.href);
- },
- });
- }
-}
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 1384c9c40b3..073b27605bb 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,6 +1,7 @@
<script>
import katex from 'katex';
import marked from 'marked';
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
import { hasContent, markdownConfig } from '~/lib/utils/text_utility';
import Prompt from './prompt.vue';
@@ -138,6 +139,9 @@ export default {
components: {
prompt: Prompt,
},
+ directives: {
+ SafeHtml,
+ },
inject: ['relativeRawPath'],
props: {
cell: {
@@ -150,16 +154,17 @@ export default {
renderer.attachments = this.cell.attachments;
renderer.relativeRawPath = this.relativeRawPath;
- return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig);
+ return marked(this.cell.source.join('').replace(/\\/g, '\\\\'));
},
},
+ markdownConfig,
};
</script>
<template>
<div class="cell text-cell">
<prompt />
- <div class="markdown" v-html="markdown /* eslint-disable-line vue/no-v-html */"></div>
+ <div v-safe-html:[$options.markdownConfig]="markdown" class="markdown"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
index 663a912999d..30ea5d3532e 100644
--- a/app/assets/javascripts/notes/components/comment_type_dropdown.vue
+++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
@@ -96,7 +96,11 @@ export default {
data-track-action="click_button"
@click="$emit('click')"
>
- <gl-dropdown-item is-check-item :is-checked="isNoteTypeComment" @click="setNoteTypeToComment">
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ @click.stop.prevent="setNoteTypeToComment"
+ >
<strong>{{ $options.i18n.submitButton.comment }}</strong>
<p class="gl-m-0">{{ commentDescription }}</p>
</gl-dropdown-item>
@@ -105,7 +109,7 @@ export default {
is-check-item
:is-checked="isNoteTypeDiscussion"
data-qa-selector="discussion_menu_item"
- @click="setNoteTypeToDiscussion"
+ @click.stop.prevent="setNoteTypeToDiscussion"
>
<strong>{{ $options.i18n.submitButton.startThread }}</strong>
<p class="gl-m-0">{{ startDiscussionDescription }}</p>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 0892276ff3b..6fcfa66ea49 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -47,6 +47,11 @@ export default {
required: false,
default: '',
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters(['userCanReply']),
@@ -127,6 +132,7 @@ export default {
:show-reply-button="userCanReply"
:discussion-root="true"
:discussion-resolve-path="discussion.resolve_path"
+ :is-overview-tab="isOverviewTab"
@handleDeleteNote="$emit('deleteNote')"
@startReplying="$emit('startReplying')"
>
@@ -176,6 +182,7 @@ export default {
:line="diffLine"
:discussion-root="index === 0"
:discussion-resolve-path="discussion.resolve_path"
+ :is-overview-tab="isOverviewTab"
@handleDeleteNote="$emit('deleteNote')"
>
<template #avatar-badge>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 44d0c741d5a..e2a2edd7344 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -257,7 +257,7 @@ export default {
<user-access-role-badge
v-if="isAuthor"
v-gl-tooltip
- class="gl-mx-3 d-none d-md-inline-block"
+ class="gl-mr-3 d-none d-md-inline-block"
:title="displayAuthorBadgeText"
>
{{ __('Author') }}
@@ -265,7 +265,7 @@ export default {
<user-access-role-badge
v-if="accessLevel"
v-gl-tooltip
- class="gl-mx-3"
+ class="gl-mr-3"
:title="displayMemberBadgeText"
>
{{ accessLevel }}
@@ -273,7 +273,7 @@ export default {
<user-access-role-badge
v-else-if="isContributor"
v-gl-tooltip
- class="gl-mx-3"
+ class="gl-mr-3"
:title="displayContributorBadgeText"
>
{{ __('Contributor') }}
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 93f71276120..1ce1696e332 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -51,7 +51,7 @@ export default {
},
},
computed: {
- ...mapGetters(['getDiscussion', 'suggestionsCount']),
+ ...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
...mapGetters('diffs', ['suggestionCommitMessage']),
discussion() {
if (!this.note.isDraft) return {};
@@ -74,9 +74,10 @@ export default {
// Please see this issue comment for why these
// are hard-coded to 1:
// https://gitlab.com/gitlab-org/gitlab/-/issues/291027#note_468308022
- const suggestionsCount = 1;
- const filesCount = 1;
- const filePaths = this.file ? [this.file.file_path] : [];
+ const suggestionsCount = this.batchSuggestionsInfo.length || 1;
+ const batchFilePaths = this.getSuggestionsFilePaths();
+ const filePaths = batchFilePaths.length ? batchFilePaths : [this.file.file_path];
+ const filesCount = filePaths.length;
const suggestion = this.suggestionCommitMessage({
file_paths: filePaths.join(', '),
suggestions_count: suggestionsCount,
@@ -131,8 +132,8 @@ export default {
message,
}).then(callback);
},
- applySuggestionBatch({ flashContainer }) {
- return this.submitSuggestionBatch({ flashContainer });
+ applySuggestionBatch({ message, flashContainer }) {
+ return this.submitSuggestionBatch({ message, flashContainer });
},
addSuggestionToBatch(suggestionId) {
const { discussion_id: discussionId, id: noteId } = this.note;
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index a4f06a8d9f5..b05643e5e13 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -348,6 +348,7 @@ export default {
id="note_note"
ref="textarea"
v-model="updatedNoteBody"
+ :disabled="isSubmitting"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 4e686ce8719..0925195d4bb 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,10 +1,16 @@
<script>
-import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlTooltipDirective,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import { mapActions } from 'vuex';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '../../sidebar/components/assignees/user_name_with_status.vue';
export default {
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
components: {
timeAgoTooltip,
GitlabTeamMemberBadge: () =>
@@ -14,6 +20,7 @@ export default {
UserNameWithStatus,
},
directives: {
+ SafeHtml,
GlTooltip: GlTooltipDirective,
},
props: {
@@ -165,10 +172,10 @@ export default {
<span
v-if="authorStatus"
ref="authorStatus"
+ v-safe-html:[$options.safeHtmlConfig]="authorStatus"
v-on="
authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
"
- v-html="authorStatus /* eslint-disable-line vue/no-v-html */"
></span>
<span class="text-nowrap author-username">
<a
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index b99579fb9a7..77f796fe8b0 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -66,6 +66,11 @@ export default {
required: false,
default: '',
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -263,6 +268,7 @@ export default {
:is-expanded="isExpanded"
:line="line"
:should-group-replies="shouldGroupReplies"
+ :is-overview-tab="isOverviewTab"
@startReplying="showReplyForm"
@deleteNote="deleteNoteHandler"
>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 3c6ed0a8aac..e35d8d94289 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -84,6 +84,11 @@ export default {
required: false,
default: '',
},
+ isOverviewTab: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -186,6 +191,14 @@ export default {
return fileResolvedFromAvailableSource || null;
},
+ avatarSize() {
+ // Use a different size if shown on a Merge Request Diff
+ if (this.line && !this.isOverviewTab) {
+ return 24;
+ }
+
+ return 40;
+ },
},
created() {
const line = this.note.position?.line_range?.start || this.line;
@@ -391,7 +404,7 @@ export default {
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
- :img-size="40"
+ :img-size="avatarSize"
lazy
>
<template #avatar-badge>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 29c60b96d8a..58570e76795 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -317,6 +317,7 @@ export default {
:key="discussion.id"
:discussion="discussion"
:render-diff-file="true"
+ is-overview-tab
:help-page-path="helpPagePath"
/>
</template>
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 656591e0c32..7eb10f647a0 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -631,7 +631,7 @@ export const submitSuggestion = (
});
};
-export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContainer }) => {
+export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, flashContainer }) => {
const suggestionIds = state.batchSuggestionsInfo.map(({ suggestionId }) => suggestionId);
const resolveAllDiscussions = () =>
@@ -644,7 +644,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai
commit(types.SET_RESOLVING_DISCUSSION, true);
dispatch('stopPolling');
- return Api.applySuggestionBatch(suggestionIds)
+ return Api.applySuggestionBatch(suggestionIds, message)
.then(() => Promise.all(resolveAllDiscussions()))
.then(() => commit(types.CLEAR_SUGGESTION_BATCH))
.catch((err) => {
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 956221d69ae..a710ac0ccf5 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -283,3 +283,14 @@ export const suggestionsCount = (state, getters) =>
export const hasDrafts = (state, getters, rootState, rootGetters) =>
Boolean(rootGetters['batchComments/hasDrafts']);
+
+export const getSuggestionsFilePaths = (state) => () =>
+ state.batchSuggestionsInfo.reduce((acc, suggestion) => {
+ const discussion = state.discussions.find((d) => d.id === suggestion.discussionId);
+
+ if (acc.indexOf(discussion?.diff_file?.file_path) === -1) {
+ acc.push(discussion.diff_file.file_path);
+ }
+
+ return acc;
+ }, []);
diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js
index 4f875977d78..f5891c9acb5 100644
--- a/app/assets/javascripts/notifications/constants.js
+++ b/app/assets/javascripts/notifications/constants.js
@@ -31,7 +31,7 @@ export const i18n = {
title: __('Custom notification events'),
bodyTitle: __('Notification events'),
bodyMessage: __(
- 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}.',
+ 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}.',
),
},
eventNames: {
diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue
deleted file mode 100644
index 4e99099b0a1..00000000000
--- a/app/assets/javascripts/packages/details/components/additional_metadata.vue
+++ /dev/null
@@ -1,94 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
-import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
-import { PackageType } from '../../shared/constants';
-
-export default {
- i18n: {
- sourceText: s__('PackageRegistry|Source project located at %{link}'),
- licenseText: s__('PackageRegistry|License information located at %{link}'),
- recipeText: s__('PackageRegistry|Recipe: %{recipe}'),
- appGroup: s__('PackageRegistry|App group: %{group}'),
- appName: s__('PackageRegistry|App name: %{name}'),
- },
- components: {
- DetailsRow,
- GlLink,
- GlSprintf,
- },
- props: {
- packageEntity: {
- type: Object,
- required: true,
- },
- },
- computed: {
- showMetadata() {
- const visibilityConditions = {
- [PackageType.NUGET]: this.packageEntity.nuget_metadatum,
- [PackageType.CONAN]: this.packageEntity.conan_metadatum,
- [PackageType.MAVEN]: this.packageEntity.maven_metadatum,
- };
- return visibilityConditions[this.packageEntity.package_type];
- },
- },
-};
-</script>
-
-<template>
- <div v-if="showMetadata">
- <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3>
-
- <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main">
- <template v-if="packageEntity.nuget_metadatum">
- <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source">
- <gl-sprintf :message="$options.i18n.sourceText">
- <template #link>
- <gl-link :href="packageEntity.nuget_metadatum.project_url" target="_blank">{{
- packageEntity.nuget_metadatum.project_url
- }}</gl-link>
- </template>
- </gl-sprintf>
- </details-row>
- <details-row icon="license" padding="gl-p-4" data-testid="nuget-license">
- <gl-sprintf :message="$options.i18n.licenseText">
- <template #link>
- <gl-link :href="packageEntity.nuget_metadatum.license_url" target="_blank">{{
- packageEntity.nuget_metadatum.license_url
- }}</gl-link>
- </template>
- </gl-sprintf>
- </details-row>
- </template>
-
- <details-row
- v-else-if="packageEntity.conan_metadatum"
- icon="information-o"
- padding="gl-p-4"
- data-testid="conan-recipe"
- >
- <gl-sprintf :message="$options.i18n.recipeText">
- <template #recipe>{{ packageEntity.name }}</template>
- </gl-sprintf>
- </details-row>
-
- <template v-else-if="packageEntity.maven_metadatum">
- <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app">
- <gl-sprintf :message="$options.i18n.appName">
- <template #name>
- <strong>{{ packageEntity.maven_metadatum.app_name }}</strong>
- </template>
- </gl-sprintf>
- </details-row>
- <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group">
- <gl-sprintf :message="$options.i18n.appGroup">
- <template #group>
- <strong>{{ packageEntity.maven_metadatum.app_group }}</strong>
- </template>
- </gl-sprintf>
- </details-row>
- </template>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue
deleted file mode 100644
index bf1e5083e12..00000000000
--- a/app/assets/javascripts/packages/details/components/composer_installation.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'ComposerInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['composerHelpPath']),
- ...mapGetters(['composerRegistryInclude', 'composerPackageInclude', 'groupExists']),
- },
- i18n: {
- registryInclude: s__('PackageRegistry|Add composer registry'),
- copyRegistryInclude: s__('PackageRegistry|Copy registry include'),
- packageInclude: s__('PackageRegistry|Install package version'),
- copyPackageInclude: s__('PackageRegistry|Copy require package include'),
- infoLine: s__(
- 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }],
-};
-</script>
-
-<template>
- <div v-if="groupExists" data-testid="root-node">
- <installation-title package-type="composer" :options="$options.installOptions" />
-
- <code-instruction
- :label="$options.i18n.registryInclude"
- :instruction="composerRegistryInclude"
- :copy-text="$options.i18n.copyRegistryInclude"
- :tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- data-testid="registry-include"
- />
-
- <code-instruction
- :label="$options.i18n.packageInclude"
- :instruction="composerPackageInclude"
- :copy-text="$options.i18n.copyPackageInclude"
- :tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- data-testid="package-include"
- />
- <span data-testid="help-text">
- <gl-sprintf :message="$options.i18n.infoLine">
- <template #link="{ content }">
- <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue
deleted file mode 100644
index 1d855f6cf3e..00000000000
--- a/app/assets/javascripts/packages/details/components/conan_installation.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'ConanInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['conanHelpPath']),
- ...mapGetters(['conanInstallationCommand', 'conanSetupCommand']),
- },
- i18n: {
- helpText: s__(
- 'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }],
-};
-</script>
-
-<template>
- <div>
- <installation-title package-type="conan" :options="$options.installOptions" />
-
- <code-instruction
- :label="s__('PackageRegistry|Conan Command')"
- :instruction="conanInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy Conan Command')"
- :tracking-action="$options.trackingActions.COPY_CONAN_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
-
- <code-instruction
- :label="s__('PackageRegistry|Add Conan Remote')"
- :instruction="conanSetupCommand"
- :copy-text="s__('PackageRegistry|Copy Conan Setup Command')"
- :tracking-action="$options.trackingActions.COPY_CONAN_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/dependency_row.vue b/app/assets/javascripts/packages/details/components/dependency_row.vue
deleted file mode 100644
index 1a2202b23c8..00000000000
--- a/app/assets/javascripts/packages/details/components/dependency_row.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-export default {
- name: 'DependencyRow',
- props: {
- dependency: {
- type: Object,
- required: true,
- },
- },
- computed: {
- showVersion() {
- return Boolean(this.dependency.version_pattern);
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-responsive-table-row">
- <div class="table-section section-50">
- <strong class="gl-text-body">{{ dependency.name }}</strong>
- <span v-if="dependency.target_framework" data-testid="target-framework"
- >({{ dependency.target_framework }})</span
- >
- </div>
-
- <div
- v-if="showVersion"
- class="table-section section-50 gl-display-flex gl-md-justify-content-end"
- data-testid="version-pattern"
- >
- <span class="gl-text-body">{{ dependency.version_pattern }}</span>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue
deleted file mode 100644
index ed55d7fe782..00000000000
--- a/app/assets/javascripts/packages/details/components/installation_commands.vue
+++ /dev/null
@@ -1,55 +0,0 @@
-<script>
-import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/components/terraform_installation.vue';
-import { PackageType, TERRAFORM_PACKAGE_TYPE } from '../../shared/constants';
-import ComposerInstallation from './composer_installation.vue';
-import ConanInstallation from './conan_installation.vue';
-import MavenInstallation from './maven_installation.vue';
-import NpmInstallation from './npm_installation.vue';
-import NugetInstallation from './nuget_installation.vue';
-import PypiInstallation from './pypi_installation.vue';
-
-export default {
- name: 'InstallationCommands',
- components: {
- [PackageType.CONAN]: ConanInstallation,
- [PackageType.MAVEN]: MavenInstallation,
- [PackageType.NPM]: NpmInstallation,
- [PackageType.NUGET]: NugetInstallation,
- [PackageType.PYPI]: PypiInstallation,
- [PackageType.COMPOSER]: ComposerInstallation,
- [TERRAFORM_PACKAGE_TYPE]: TerraformInstallation,
- },
- props: {
- packageEntity: {
- type: Object,
- required: true,
- },
- npmPath: {
- type: String,
- required: false,
- default: '',
- },
- npmHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- installationComponent() {
- return this.$options.components[this.packageEntity.package_type];
- },
- },
-};
-</script>
-
-<template>
- <div v-if="installationComponent">
- <component
- :is="installationComponent"
- :name="packageEntity.name"
- :registry-url="npmPath"
- :help-url="npmHelpPath"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/installation_title.vue b/app/assets/javascripts/packages/details/components/installation_title.vue
deleted file mode 100644
index 43133bf7825..00000000000
--- a/app/assets/javascripts/packages/details/components/installation_title.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue';
-
-export default {
- name: 'InstallationTitle',
- components: {
- PersistedDropdownSelection,
- },
- props: {
- packageType: {
- type: String,
- required: true,
- },
- options: {
- type: Array,
- required: true,
- },
- },
- computed: {
- storageKey() {
- return `package_${this.packageType}_installation_instructions`;
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
- <h3 class="gl-font-lg">{{ __('Installation') }}</h3>
- <div>
- <persisted-dropdown-selection
- :storage-key="storageKey"
- :options="options"
- @change="$emit('change', $event)"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue
deleted file mode 100644
index 6974de99344..00000000000
--- a/app/assets/javascripts/packages/details/components/maven_installation.vue
+++ /dev/null
@@ -1,153 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'MavenInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- data() {
- return {
- instructionType: 'maven',
- };
- },
- computed: {
- ...mapState(['mavenHelpPath']),
- ...mapGetters([
- 'mavenInstallationXml',
- 'mavenInstallationCommand',
- 'mavenSetupXml',
- 'gradleGroovyInstalCommand',
- 'gradleGroovyAddSourceCommand',
- 'gradleKotlinInstalCommand',
- 'gradleKotlinAddSourceCommand',
- ]),
- showMaven() {
- return this.instructionType === 'maven';
- },
- showGroovy() {
- return this.instructionType === 'groovy';
- },
- },
- i18n: {
- xmlText: s__(
- `PackageRegistry|Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block.`,
- ),
- setupText: s__(
- `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file.`,
- ),
- helpText: s__(
- 'PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [
- { value: 'maven', label: s__('PackageRegistry|Maven XML') },
- { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') },
- { value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') },
- ],
-};
-</script>
-
-<template>
- <div>
- <installation-title
- package-type="maven"
- :options="$options.installOptions"
- @change="instructionType = $event"
- />
-
- <template v-if="showMaven">
- <p>
- <gl-sprintf :message="$options.i18n.xmlText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
-
- <code-instruction
- :instruction="mavenInstallationXml"
- :copy-text="s__('PackageRegistry|Copy Maven XML')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_XML"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
-
- <code-instruction
- :label="s__('PackageRegistry|Maven Command')"
- :instruction="mavenInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy Maven command')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3>
- <p>
- <gl-sprintf :message="$options.i18n.setupText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
- <code-instruction
- :instruction="mavenSetupXml"
- :copy-text="s__('PackageRegistry|Copy Maven registry XML')"
- :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- <template v-else-if="showGroovy">
- <code-instruction
- class="gl-mb-5"
- :label="s__('PackageRegistry|Gradle Groovy DSL install command')"
- :instruction="gradleGroovyInstalCommand"
- :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')"
- :tracking-action="$options.trackingActions.COPY_GRADLE_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <code-instruction
- :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')"
- :instruction="gradleGroovyAddSourceCommand"
- :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')"
- :tracking-action="$options.trackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
- </template>
- <template v-else>
- <code-instruction
- class="gl-mb-5"
- :label="s__('PackageRegistry|Gradle Kotlin DSL install command')"
- :instruction="gradleKotlinInstalCommand"
- :copy-text="s__('PackageRegistry|Copy Gradle Kotlin DSL install command')"
- :tracking-action="$options.trackingActions.COPY_KOTLIN_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <code-instruction
- :label="s__('PackageRegistry|Add Gradle Kotlin DSL repository command')"
- :instruction="gradleKotlinAddSourceCommand"
- :copy-text="s__('PackageRegistry|Copy add Gradle Kotlin DSL repository command')"
- :tracking-action="$options.trackingActions.COPY_KOTLIN_ADD_TO_SOURCE_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- multiline
- />
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue
deleted file mode 100644
index 6b0fcf5e4fe..00000000000
--- a/app/assets/javascripts/packages/details/components/npm_installation.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { NpmManager, TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'NpmInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- data() {
- return {
- instructionType: 'npm',
- };
- },
- computed: {
- ...mapState(['npmHelpPath']),
- ...mapGetters(['npmInstallationCommand', 'npmSetupCommand']),
- npmCommand() {
- return this.npmInstallationCommand(NpmManager.NPM);
- },
- npmSetup() {
- return this.npmSetupCommand(NpmManager.NPM);
- },
- yarnCommand() {
- return this.npmInstallationCommand(NpmManager.YARN);
- },
- yarnSetupCommand() {
- return this.npmSetupCommand(NpmManager.YARN);
- },
- showNpm() {
- return this.instructionType === 'npm';
- },
- },
- i18n: {
- helpText: s__(
- 'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [
- { value: 'npm', label: s__('PackageRegistry|Show NPM commands') },
- { value: 'yarn', label: s__('PackageRegistry|Show Yarn commands') },
- ],
-};
-</script>
-
-<template>
- <div>
- <installation-title
- package-type="npm"
- :options="$options.installOptions"
- @change="instructionType = $event"
- />
-
- <code-instruction
- v-if="showNpm"
- :instruction="npmCommand"
- :copy-text="s__('PackageRegistry|Copy npm command')"
- :tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <code-instruction
- v-else
- :instruction="yarnCommand"
- :copy-text="s__('PackageRegistry|Copy yarn command')"
- :tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
-
- <code-instruction
- v-if="showNpm"
- :instruction="npmSetup"
- :copy-text="s__('PackageRegistry|Copy npm setup command')"
- :tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <code-instruction
- v-else
- :instruction="yarnSetupCommand"
- :copy-text="s__('PackageRegistry|Copy yarn setup command')"
- :tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue
deleted file mode 100644
index d5e64722f24..00000000000
--- a/app/assets/javascripts/packages/details/components/nuget_installation.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'NugetInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['nugetHelpPath']),
- ...mapGetters(['nugetInstallationCommand', 'nugetSetupCommand']),
- },
- i18n: {
- helpText: s__(
- 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }],
-};
-</script>
-
-<template>
- <div>
- <installation-title package-type="nuget" :options="$options.installOptions" />
-
- <code-instruction
- :label="s__('PackageRegistry|NuGet Command')"
- :instruction="nugetInstallationCommand"
- :copy-text="s__('PackageRegistry|Copy NuGet Command')"
- :tracking-action="$options.trackingActions.COPY_NUGET_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
-
- <code-instruction
- :label="s__('PackageRegistry|Add NuGet Source')"
- :instruction="nugetSetupCommand"
- :copy-text="s__('PackageRegistry|Copy NuGet Setup Command')"
- :tracking-action="$options.trackingActions.COPY_NUGET_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue
deleted file mode 100644
index d02a7b3ec27..00000000000
--- a/app/assets/javascripts/packages/details/components/package_title.vue
+++ /dev/null
@@ -1,113 +0,0 @@
-<script>
-/* eslint-disable vue/v-slot-style */
-import { GlIcon, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui';
-import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { mapState, mapGetters } from 'vuex';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { __ } from '~/locale';
-import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
-import TitleArea from '~/vue_shared/components/registry/title_area.vue';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import PackageTags from '../../shared/components/package_tags.vue';
-
-export default {
- name: 'PackageTitle',
- components: {
- TitleArea,
- GlIcon,
- GlSprintf,
- PackageTags,
- MetadataItem,
- GlBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [timeagoMixin],
- i18n: {
- packageInfo: __('v%{version} published %{timeAgo}'),
- },
- data() {
- return {
- isDesktop: true,
- };
- },
- computed: {
- ...mapState(['packageEntity', 'packageFiles']),
- ...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']),
- hasTagsToDisplay() {
- return Boolean(this.packageEntity.tags && this.packageEntity.tags.length);
- },
- totalSize() {
- return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0));
- },
- },
- mounted() {
- this.isDesktop = GlBreakpointInstance.isDesktop();
- },
- methods: {
- dynamicSlotName(index) {
- return `metadata-tag${index}`;
- },
- },
-};
-</script>
-
-<template>
- <title-area :title="packageEntity.name" :avatar="packageIcon" data-qa-selector="package_title">
- <template #sub-header>
- <gl-icon name="eye" class="gl-mr-3" />
- <gl-sprintf :message="$options.i18n.packageInfo">
- <template #version>
- {{ packageEntity.version }}
- </template>
-
- <template #timeAgo>
- <span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)">
- &nbsp;{{ timeFormatted(packageEntity.created_at) }}
- </span>
- </template>
- </gl-sprintf>
- </template>
-
- <template v-if="packageTypeDisplay" #metadata-type>
- <metadata-item data-testid="package-type" icon="package" :text="packageTypeDisplay" />
- </template>
-
- <template #metadata-size>
- <metadata-item data-testid="package-size" icon="disk" :text="totalSize" />
- </template>
-
- <template v-if="packagePipeline" #metadata-pipeline>
- <metadata-item
- data-testid="pipeline-project"
- icon="review-list"
- :text="packagePipeline.project.name"
- :link="packagePipeline.project.web_url"
- />
- </template>
-
- <template v-if="packagePipeline" #metadata-ref>
- <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" />
- </template>
-
- <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags>
- <package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label />
- </template>
-
- <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
- <template
- v-for="(tag, index) in packageEntity.tags"
- v-else-if="hasTagsToDisplay"
- v-slot:[dynamicSlotName(index)]
- >
- <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm">
- {{ tag.name }}
- </gl-badge>
- </template>
-
- <template #right-actions>
- <slot name="delete-button"></slot>
- </template>
- </title-area>
-</template>
diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue
deleted file mode 100644
index fe4709d5feb..00000000000
--- a/app/assets/javascripts/packages/details/components/pypi_installation.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import InstallationTitle from '~/packages/details/components/installation_title.vue';
-import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
-import { TrackingActions, TrackingLabels } from '../constants';
-
-export default {
- name: 'PyPiInstallation',
- components: {
- InstallationTitle,
- CodeInstruction,
- GlLink,
- GlSprintf,
- },
- computed: {
- ...mapState(['pypiHelpPath']),
- ...mapGetters(['pypiPipCommand', 'pypiSetupCommand']),
- },
- i18n: {
- setupText: s__(
- `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`,
- ),
- helpText: s__(
- 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.',
- ),
- },
- trackingActions: { ...TrackingActions },
- TrackingLabels,
- installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }],
-};
-</script>
-
-<template>
- <div>
- <installation-title package-type="pypi" :options="$options.installOptions" />
-
- <code-instruction
- :label="s__('PackageRegistry|Pip Command')"
- :instruction="pypiPipCommand"
- :copy-text="s__('PackageRegistry|Copy Pip command')"
- data-testid="pip-command"
- :tracking-action="$options.trackingActions.COPY_PIP_INSTALL_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
-
- <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
- <p>
- <gl-sprintf :message="$options.i18n.setupText">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
-
- <code-instruction
- :instruction="pypiSetupCommand"
- :copy-text="s__('PackageRegistry|Copy .pypirc content')"
- data-testid="pypi-setup-content"
- multiline
- :tracking-action="$options.trackingActions.COPY_PYPI_SETUP_COMMAND"
- :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
- />
- <gl-sprintf :message="$options.i18n.helpText">
- <template #link="{ content }">
- <gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
deleted file mode 100644
index cd34b1ad45a..00000000000
--- a/app/assets/javascripts/packages/details/constants.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import { s__ } from '~/locale';
-
-export const TrackingLabels = {
- CODE_INSTRUCTION: 'code_instruction',
- CONAN_INSTALLATION: 'conan_installation',
- MAVEN_INSTALLATION: 'maven_installation',
- NPM_INSTALLATION: 'npm_installation',
- NUGET_INSTALLATION: 'nuget_installation',
- PYPI_INSTALLATION: 'pypi_installation',
- COMPOSER_INSTALLATION: 'composer_installation',
-};
-
-export const TrackingActions = {
- INSTALLATION: 'installation',
- REGISTRY_SETUP: 'registry_setup',
-
- COPY_CONAN_COMMAND: 'copy_conan_command',
- COPY_CONAN_SETUP_COMMAND: 'copy_conan_setup_command',
-
- COPY_MAVEN_XML: 'copy_maven_xml',
- COPY_MAVEN_COMMAND: 'copy_maven_command',
- COPY_MAVEN_SETUP: 'copy_maven_setup_xml',
-
- COPY_NPM_INSTALL_COMMAND: 'copy_npm_install_command',
- COPY_NPM_SETUP_COMMAND: 'copy_npm_setup_command',
-
- COPY_YARN_INSTALL_COMMAND: 'copy_yarn_install_command',
- COPY_YARN_SETUP_COMMAND: 'copy_yarn_setup_command',
-
- COPY_NUGET_INSTALL_COMMAND: 'copy_nuget_install_command',
- COPY_NUGET_SETUP_COMMAND: 'copy_nuget_setup_command',
-
- COPY_PIP_INSTALL_COMMAND: 'copy_pip_install_command',
- COPY_PYPI_SETUP_COMMAND: 'copy_pypi_setup_command',
-
- COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command',
- COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command',
-
- COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command',
- COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command',
-
- COPY_KOTLIN_INSTALL_COMMAND: 'copy_kotlin_install_command',
- COPY_KOTLIN_ADD_TO_SOURCE_COMMAND: 'copy_kotlin_add_to_source_command',
-};
-
-export const NpmManager = {
- NPM: 'npm',
- YARN: 'yarn',
-};
-
-export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
- 'PackageRegistry|Unable to fetch package version information.',
-);
-
-export const HISTORY_PIPELINES_LIMIT = 5;
diff --git a/app/assets/javascripts/packages/details/index.js b/app/assets/javascripts/packages/details/index.js
deleted file mode 100644
index 5b9d58a3860..00000000000
--- a/app/assets/javascripts/packages/details/index.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import PackagesApp from './components/app.vue';
-import createStore from './store';
-
-Vue.use(Translate);
-
-export default () => {
- const el = document.querySelector('#js-vue-packages-detail');
- const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset;
- const packageEntity = JSON.parse(packageJson);
- const canDelete = canDeleteStr === 'true';
-
- const store = createStore({
- packageEntity,
- packageFiles: packageEntity.package_files,
- canDelete,
- ...rest,
- });
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- PackagesApp,
- },
- store,
- render(createElement) {
- return createElement('packages-app');
- },
- });
-};
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
deleted file mode 100644
index ae273e26d6a..00000000000
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ /dev/null
@@ -1,140 +0,0 @@
-import { PackageType } from '../../shared/constants';
-import { getPackageTypeLabel } from '../../shared/utils';
-import { NpmManager } from '../constants';
-
-export const packagePipeline = ({ packageEntity }) => {
- return packageEntity?.pipeline || null;
-};
-
-export const packageTypeDisplay = ({ packageEntity }) => {
- return getPackageTypeLabel(packageEntity.package_type);
-};
-
-export const packageIcon = ({ packageEntity }) => {
- if (packageEntity.package_type === PackageType.NUGET) {
- return packageEntity.nuget_metadatum?.icon_url || null;
- }
-
- return null;
-};
-
-export const conanInstallationCommand = ({ packageEntity }) => {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `conan install ${packageEntity.name} --remote=gitlab`;
-};
-
-export const conanSetupCommand = ({ conanPath }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `conan remote add gitlab ${conanPath}`;
-
-export const mavenInstallationXml = ({ packageEntity = {} }) => {
- const {
- app_group: appGroup = '',
- app_name: appName = '',
- app_version: appVersion = '',
- } = packageEntity.maven_metadatum;
-
- return `<dependency>
- <groupId>${appGroup}</groupId>
- <artifactId>${appName}</artifactId>
- <version>${appVersion}</version>
-</dependency>`;
-};
-
-export const mavenInstallationCommand = ({ packageEntity = {} }) => {
- const {
- app_group: group = '',
- app_name: name = '',
- app_version: version = '',
- } = packageEntity.maven_metadatum;
-
- return `mvn dependency:get -Dartifact=${group}:${name}:${version}`;
-};
-
-export const mavenSetupXml = ({ mavenPath }) => `<repositories>
- <repository>
- <id>gitlab-maven</id>
- <url>${mavenPath}</url>
- </repository>
-</repositories>
-
-<distributionManagement>
- <repository>
- <id>gitlab-maven</id>
- <url>${mavenPath}</url>
- </repository>
-
- <snapshotRepository>
- <id>gitlab-maven</id>
- <url>${mavenPath}</url>
- </snapshotRepository>
-</distributionManagement>`;
-
-export const npmInstallationCommand = ({ packageEntity }) => (type = NpmManager.NPM) => {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- const instruction = type === NpmManager.NPM ? 'npm i' : 'yarn add';
-
- return `${instruction} ${packageEntity.name}`;
-};
-
-export const npmSetupCommand = ({ packageEntity, npmPath }) => (type = NpmManager.NPM) => {
- const scope = packageEntity.name.substring(0, packageEntity.name.indexOf('/'));
-
- if (type === NpmManager.NPM) {
- return `echo ${scope}:registry=${npmPath}/ >> .npmrc`;
- }
-
- return `echo \\"${scope}:registry\\" \\"${npmPath}/\\" >> .yarnrc`;
-};
-
-export const nugetInstallationCommand = ({ packageEntity }) =>
- `nuget install ${packageEntity.name} -Source "GitLab"`;
-
-export const nugetSetupCommand = ({ nugetPath }) =>
- `nuget source Add -Name "GitLab" -Source "${nugetPath}" -UserName <your_username> -Password <your_token>`;
-
-export const pypiPipCommand = ({ pypiPath, packageEntity }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `pip install ${packageEntity.name} --extra-index-url ${pypiPath}`;
-
-export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab]
-repository = ${pypiSetupPath}
-username = __token__
-password = <your personal access token>`;
-
-export const composerRegistryInclude = ({ composerPath, composerConfigRepositoryName }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `composer config repositories.${composerConfigRepositoryName} '{"type": "composer", "url": "${composerPath}"}'`;
-
-export const composerPackageInclude = ({ packageEntity }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `composer req ${[packageEntity.name]}:${packageEntity.version}`;
-
-export const gradleGroovyInstalCommand = ({ packageEntity }) => {
- const {
- app_group: group = '',
- app_name: name = '',
- app_version: version = '',
- } = packageEntity.maven_metadatum;
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `implementation '${group}:${name}:${version}'`;
-};
-
-export const gradleGroovyAddSourceCommand = ({ mavenPath }) =>
- // eslint-disable-next-line @gitlab/require-i18n-strings
- `maven {
- url '${mavenPath}'
-}`;
-
-export const gradleKotlinInstalCommand = ({ packageEntity }) => {
- const {
- app_group: group = '',
- app_name: name = '',
- app_version: version = '',
- } = packageEntity.maven_metadatum;
- return `implementation("${group}:${name}:${version}")`;
-};
-
-export const gradleKotlinAddSourceCommand = ({ mavenPath }) => `maven("${mavenPath}")`;
-
-export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js
deleted file mode 100644
index 27cc95566d3..00000000000
--- a/app/assets/javascripts/packages/details/utils.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { TrackingActions } from './constants';
-
-export const trackInstallationTabChange = {
- methods: {
- trackInstallationTabChange(tabIndex) {
- const action = tabIndex === 0 ? TrackingActions.INSTALLATION : TrackingActions.REGISTRY_SETUP;
- this.track(action, { label: this.trackingLabel });
- },
- },
-};
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index f15c31b85c1..c284b8358b4 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -1,5 +1,4 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const PackageType = {
CONAN: 'conan',
@@ -38,7 +37,7 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- __('PackageRegistry|Something went wrong while deleting the package file.'),
+ 'PackageRegistry|Something went wrong while deleting the package file.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package file deleted successfully',
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
new file mode 100644
index 00000000000..73fb3656af1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import {
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ DEPENDENCY_PROXY_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+
+import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlAlert,
+ GlFormInputGroup,
+ GlSprintf,
+ ClipboardButton,
+ TitleArea,
+ GlSkeletonLoader,
+ },
+ inject: ['groupPath', 'dependencyProxyAvailable'],
+ i18n: {
+ proxyNotAvailableText: __('Dependency Proxy feature is limited to public groups for now.'),
+ proxyDisabledText: __('Dependency Proxy disabled. To enable it, contact the group owner.'),
+ proxyImagePrefix: __('Dependency Proxy image prefix'),
+ copyImagePrefixText: __('Copy prefix'),
+ blobCountAndSize: __('Contains %{count} blobs of images (%{size})'),
+ },
+ data() {
+ return {
+ group: {},
+ };
+ },
+ apollo: {
+ group: {
+ query: getDependencyProxyDetailsQuery,
+ skip() {
+ return !this.dependencyProxyAvailable;
+ },
+ variables() {
+ return { fullPath: this.groupPath };
+ },
+ },
+ },
+ computed: {
+ infoMessages() {
+ return [
+ {
+ text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ link: DEPENDENCY_PROXY_DOCS_PATH,
+ },
+ ];
+ },
+ dependencyProxyEnabled() {
+ return this.group?.dependencyProxySetting?.enabled;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <title-area :title="__('Dependency Proxy')" :info-messages="infoMessages" />
+ <gl-alert
+ v-if="!dependencyProxyAvailable"
+ :dismissible="false"
+ data-testid="proxy-not-available"
+ >
+ {{ $options.i18n.proxyNotAvailableText }}
+ </gl-alert>
+
+ <gl-skeleton-loader v-else-if="$apollo.queries.group.loading" />
+
+ <div v-else-if="dependencyProxyEnabled" data-testid="main-area">
+ <gl-form-group :label="$options.i18n.proxyImagePrefix">
+ <gl-form-input-group
+ readonly
+ :value="group.dependencyProxyImagePrefix"
+ class="gl-layout-w-limited"
+ data-testid="proxy-url"
+ >
+ <template #append>
+ <clipboard-button
+ :text="group.dependencyProxyImagePrefix"
+ :title="$options.i18n.copyImagePrefixText"
+ />
+ </template>
+ </gl-form-input-group>
+ <template #description>
+ <span data-qa-selector="dependency_proxy_count" data-testid="proxy-count">
+ <gl-sprintf :message="$options.i18n.blobCountAndSize">
+ <template #count>{{ group.dependencyProxyBlobCount }}</template>
+ <template #size>{{ group.dependencyProxyTotalSize }}</template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-form-group>
+ </div>
+ <gl-alert v-else :dismissible="false" data-testid="proxy-disabled">
+ {{ $options.i18n.proxyDisabledText }}
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
new file mode 100644
index 00000000000..9058d349bf3
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
@@ -0,0 +1,10 @@
+query getDependencyProxyDetails($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ dependencyProxyBlobCount
+ dependencyProxyTotalSize
+ dependencyProxyImagePrefix
+ dependencyProxySetting {
+ enabled
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
new file mode 100644
index 00000000000..dc73470e07d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import app from '~/packages_and_registries/dependency_proxy/app.vue';
+import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+
+export const initDependencyProxyApp = () => {
+ const el = document.getElementById('js-dependency-proxy');
+ if (!el) {
+ return null;
+ }
+ const { dependencyProxyAvailable, ...dataset } = el.dataset;
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ dependencyProxyAvailable: parseBoolean(dependencyProxyAvailable),
+ ...dataset,
+ },
+ render(createElement) {
+ return createElement(app);
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
index 59da32e6666..6016757c1b9 100644
--- a/app/assets/javascripts/packages/details/components/app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
@@ -1,6 +1,5 @@
<script>
import {
- GlBadge,
GlButton,
GlModal,
GlModalDirective,
@@ -14,36 +13,30 @@ import { mapActions, mapState } from 'vuex';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { objectToQuery } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
+import TerraformTitle from '~/packages_and_registries/infrastructure_registry/details/components/details_title.vue';
+import TerraformInstallation from '~/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue';
import Tracking from '~/tracking';
-import PackageListRow from '../../shared/components/package_list_row.vue';
-import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
-import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants';
-import { packageTypeToTrackCategory } from '../../shared/utils';
-import AdditionalMetadata from './additional_metadata.vue';
-import DependencyRow from './dependency_row.vue';
-import InstallationCommands from './installation_commands.vue';
+import PackageListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import { TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { packageTypeToTrackCategory } from '~/packages/shared/utils';
import PackageFiles from './package_files.vue';
import PackageHistory from './package_history.vue';
export default {
name: 'PackagesApp',
components: {
- GlBadge,
GlButton,
GlEmptyState,
GlModal,
GlTab,
GlTabs,
GlSprintf,
- PackageTitle: () => import('./package_title.vue'),
- TerraformTitle: () =>
- import('~/packages_and_registries/infrastructure_registry/components/details_title.vue'),
+ TerraformTitle,
PackagesListLoader,
PackageListRow,
- DependencyRow,
PackageHistory,
- AdditionalMetadata,
- InstallationCommands,
+ TerraformInstallation,
PackageFiles,
},
directives: {
@@ -51,12 +44,6 @@ export default {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin()],
- inject: {
- titleComponent: {
- default: 'PackageTitle',
- from: 'titleComponent',
- },
- },
trackingActions: { ...TrackingActions },
data() {
return {
@@ -87,15 +74,6 @@ export default {
hasVersions() {
return this.packageEntity.versions?.length > 0;
},
- packageDependencies() {
- return this.packageEntity.dependency_links || [];
- },
- showDependencies() {
- return this.packageEntity.package_type === PackageType.NUGET;
- },
- showFiles() {
- return this.packageEntity?.package_type !== PackageType.COMPOSER;
- },
},
methods: {
...mapActions(['deletePackage', 'fetchPackageVersions', 'deletePackageFile']),
@@ -167,7 +145,7 @@ export default {
/>
<div v-else class="packages-app">
- <component :is="titleComponent">
+ <terraform-title>
<template #delete-button>
<gl-button
v-if="canDelete"
@@ -180,24 +158,16 @@ export default {
{{ __('Delete') }}
</gl-button>
</template>
- </component>
+ </terraform-title>
<gl-tabs>
<gl-tab :title="__('Detail')">
<div data-qa-selector="package_information_content">
<package-history :package-entity="packageEntity" :project-name="projectName" />
-
- <installation-commands
- :package-entity="packageEntity"
- :npm-path="npmPath"
- :npm-help-path="npmHelpPath"
- />
-
- <additional-metadata :package-entity="packageEntity" />
+ <terraform-installation />
</div>
<package-files
- v-if="showFiles"
:package-files="packageFiles"
:can-delete="canDelete"
@download-file="track($options.trackingActions.PULL_PACKAGE)"
@@ -205,27 +175,6 @@ export default {
/>
</gl-tab>
- <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab">
- <template #title>
- <span>{{ __('Dependencies') }}</span>
- <gl-badge size="sm" data-testid="dependencies-badge">{{
- packageDependencies.length
- }}</gl-badge>
- </template>
-
- <template v-if="packageDependencies.length > 0">
- <dependency-row
- v-for="(dep, index) in packageDependencies"
- :key="index"
- :dependency="dep"
- />
- </template>
-
- <p v-else class="gl-mt-3" data-testid="no-dependencies-message">
- {{ s__('PackageRegistry|This NuGet package has no dependencies.') }}
- </p>
- </gl-tab>
-
<gl-tab
:title="__('Other versions')"
title-item-class="js-versions-tab"
@@ -254,7 +203,6 @@ export default {
<gl-modal
ref="deleteModal"
- class="js-delete-modal"
modal-id="delete-modal"
:action-primary="$options.modal.packageDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
index 3e551706ed0..3e551706ed0 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/details_title.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue
diff --git a/app/assets/javascripts/packages/details/components/file_sha.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue
index a25839be7e1..a25839be7e1 100644
--- a/app/assets/javascripts/packages/details/components/file_sha.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/file_sha.vue
diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
index 0563b612d04..ab4cfccd023 100644
--- a/app/assets/javascripts/packages/details/components/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
@@ -3,10 +3,10 @@ import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@
import { last } from 'lodash';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
-import FileSha from '~/packages/details/components/file_sha.vue';
import Tracking from '~/tracking';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import FileSha from './file_sha.vue';
export default {
name: 'PackageFiles',
diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
index 27d2f208a42..e5be98b87f7 100644
--- a/app/assets/javascripts/packages/details/components/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_history.vue
@@ -1,10 +1,9 @@
<script>
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlLink, GlSprintf } from '@gitlab/ui';
import { first } from 'lodash';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
-import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants';
+import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -20,8 +19,6 @@ export default {
combinedUpdateText: s__(
'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}',
),
- archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'),
- archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'),
},
components: {
GlLink,
@@ -57,14 +54,14 @@ export default {
showPipelinesInfo() {
return Boolean(this.firstPipeline?.id);
},
- archiviedLines() {
+ archivedLines() {
return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0);
},
archivedPipelineMessage() {
return n__(
- this.$options.i18n.archivedPipelineMessageSingular,
- this.$options.i18n.archivedPipelineMessagePlural,
- this.archiviedLines,
+ 'PackageRegistry|Package has %{updatesCount} archived update',
+ 'PackageRegistry|Package has %{updatesCount} archived updates',
+ this.archivedLines,
);
},
},
@@ -133,10 +130,10 @@ export default {
</gl-sprintf>
</history-item>
- <history-item v-if="archiviedLines" icon="history" data-testid="archived">
+ <history-item v-if="archivedLines" icon="history" data-testid="archived">
<gl-sprintf :message="archivedPipelineMessage">
- <template #number>
- <strong>{{ archiviedLines }}</strong>
+ <template #updatesCount>
+ <strong>{{ archivedLines }}</strong>
</template>
</gl-sprintf>
</history-item>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
index c62bf7fb722..c62bf7fb722 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/terraform_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/terraform_installation.vue
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js
new file mode 100644
index 00000000000..c0c67faffba
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/constants.js
@@ -0,0 +1,5 @@
+import { s__ } from '~/locale';
+
+export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
+ 'PackageRegistry|Unable to fetch package version information.',
+);
diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
index a03fa8d9d63..a03fa8d9d63 100644
--- a/app/assets/javascripts/packages/details/store/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/actions.js
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js
new file mode 100644
index 00000000000..6a17e7aa6d6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/getters.js
@@ -0,0 +1,3 @@
+export const packagePipeline = ({ packageEntity }) => {
+ return packageEntity?.pipeline || null;
+};
diff --git a/app/assets/javascripts/packages/details/store/index.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
index 15e17bcfaac..15e17bcfaac 100644
--- a/app/assets/javascripts/packages/details/store/index.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/index.js
diff --git a/app/assets/javascripts/packages/details/store/mutation_types.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js
index 590f2d9f970..590f2d9f970 100644
--- a/app/assets/javascripts/packages/details/store/mutation_types.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutation_types.js
diff --git a/app/assets/javascripts/packages/details/store/mutations.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js
index 762fd5a4040..762fd5a4040 100644
--- a/app/assets/javascripts/packages/details/store/mutations.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/store/mutations.js
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js
index 98942b1e578..32fbc9382fd 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details_app_bundle.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import PackagesApp from '~/packages/details/components/app.vue';
-import createStore from '~/packages/details/store';
+import PackagesApp from '~/packages_and_registries/infrastructure_registry/details/components/app.vue';
+import createStore from '~/packages_and_registries/infrastructure_registry/details/store';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
index f0da7db6c91..1360b03856f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
@@ -24,7 +24,13 @@ export default {
<template>
<div>
- <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source">
+ <details-row
+ v-if="packageEntity.metadata.projectUrl"
+ icon="project"
+ padding="gl-p-4"
+ dashed
+ data-testid="nuget-source"
+ >
<gl-sprintf :message="$options.i18n.sourceText">
<template #link>
<gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{
@@ -33,7 +39,12 @@ export default {
</template>
</gl-sprintf>
</details-row>
- <details-row icon="license" padding="gl-p-4" data-testid="nuget-license">
+ <details-row
+ v-if="packageEntity.metadata.licenseUrl"
+ icon="license"
+ padding="gl-p-4"
+ data-testid="nuget-license"
+ >
<gl-sprintf :message="$options.i18n.licenseText">
<template #link>
<gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue
index 47081e23318..2448324549e 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/npm_installation.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlSprintf, GlFormRadioGroup } from '@gitlab/ui';
import { s__ } from '~/locale';
import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
@@ -11,6 +11,8 @@ import {
TRACKING_LABEL_CODE_INSTRUCTION,
NPM_PACKAGE_MANAGER,
YARN_PACKAGE_MANAGER,
+ PROJECT_PACKAGE_ENDPOINT_TYPE,
+ INSTANCE_PACKAGE_ENDPOINT_TYPE,
} from '~/packages_and_registries/package_registry/constants';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
@@ -21,8 +23,9 @@ export default {
CodeInstruction,
GlLink,
GlSprintf,
+ GlFormRadioGroup,
},
- inject: ['npmHelpPath', 'npmPath'],
+ inject: ['npmHelpPath', 'npmPath', 'npmProjectPath'],
props: {
packageEntity: {
type: Object,
@@ -32,6 +35,7 @@ export default {
data() {
return {
instructionType: NPM_PACKAGE_MANAGER,
+ packageEndpointType: INSTANCE_PACKAGE_ENDPOINT_TYPE,
};
},
computed: {
@@ -39,13 +43,13 @@ export default {
return this.npmInstallationCommand(NPM_PACKAGE_MANAGER);
},
npmSetup() {
- return this.npmSetupCommand(NPM_PACKAGE_MANAGER);
+ return this.npmSetupCommand(NPM_PACKAGE_MANAGER, this.packageEndpointType);
},
yarnCommand() {
return this.npmInstallationCommand(YARN_PACKAGE_MANAGER);
},
yarnSetupCommand() {
- return this.npmSetupCommand(YARN_PACKAGE_MANAGER);
+ return this.npmSetupCommand(YARN_PACKAGE_MANAGER, this.packageEndpointType);
},
showNpm() {
return this.instructionType === NPM_PACKAGE_MANAGER;
@@ -58,14 +62,16 @@ export default {
return `${instruction} ${this.packageEntity.name}`;
},
- npmSetupCommand(type) {
+ npmSetupCommand(type, endpointType) {
const scope = this.packageEntity.name.substring(0, this.packageEntity.name.indexOf('/'));
+ const npmPathForEndpoint =
+ endpointType === INSTANCE_PACKAGE_ENDPOINT_TYPE ? this.npmPath : this.npmProjectPath;
if (type === NPM_PACKAGE_MANAGER) {
- return `echo ${scope}:registry=${this.npmPath}/ >> .npmrc`;
+ return `echo ${scope}:registry=${npmPathForEndpoint}/ >> .npmrc`;
}
- return `echo \\"${scope}:registry\\" \\"${this.npmPath}/\\" >> .yarnrc`;
+ return `echo \\"${scope}:registry\\" \\"${npmPathForEndpoint}/\\" >> .yarnrc`;
},
},
packageManagers: {
@@ -87,6 +93,10 @@ export default {
{ value: NPM_PACKAGE_MANAGER, label: s__('PackageRegistry|Show NPM commands') },
{ value: YARN_PACKAGE_MANAGER, label: s__('PackageRegistry|Show Yarn commands') },
],
+ packageEndpointTypeOptions: [
+ { value: INSTANCE_PACKAGE_ENDPOINT_TYPE, text: s__('PackageRegistry|Instance-level') },
+ { value: PROJECT_PACKAGE_ENDPOINT_TYPE, text: s__('PackageRegistry|Project-level') },
+ ],
};
</script>
@@ -116,6 +126,12 @@ export default {
<h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
+ <gl-form-radio-group
+ :options="$options.packageEndpointTypeOptions"
+ :checked="packageEndpointType"
+ @change="packageEndpointType = $event"
+ />
+
<code-instruction
v-if="showNpm"
:instruction="npmSetup"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
index 408bd2e3dfe..af6bd7079ba 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
@@ -1,11 +1,10 @@
<script>
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlLink, GlSprintf } from '@gitlab/ui';
import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
-import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants';
+import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -21,8 +20,6 @@ export default {
combinedUpdateText: s__(
'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}',
),
- archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'),
- archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'),
},
components: {
GlLink,
@@ -58,14 +55,14 @@ export default {
showPipelinesInfo() {
return Boolean(this.firstPipeline?.id);
},
- archiviedLines() {
+ archivedLines() {
return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0);
},
archivedPipelineMessage() {
return n__(
- this.$options.i18n.archivedPipelineMessageSingular,
- this.$options.i18n.archivedPipelineMessagePlural,
- this.archiviedLines,
+ 'PackageRegistry|Package has %{updatesCount} archived update',
+ 'PackageRegistry|Package has %{updatesCount} archived updates',
+ this.archivedLines,
);
},
},
@@ -135,10 +132,10 @@ export default {
</gl-sprintf>
</history-item>
- <history-item v-if="archiviedLines" icon="history" data-testid="archived">
+ <history-item v-if="archivedLines" icon="history" data-testid="archived">
<gl-sprintf :message="archivedPipelineMessage">
- <template #number>
- <strong>{{ archiviedLines }}</strong>
+ <template #updatesCount>
+ <strong>{{ archivedLines }}</strong>
</template>
</gl-sprintf>
</history-item>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
new file mode 100644
index 00000000000..08481ac5655
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
@@ -0,0 +1,134 @@
+<script>
+/*
+ * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs
+ * For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846
+ * This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136
+ */
+// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
+import {
+ PROJECT_RESOURCE_TYPE,
+ GROUP_RESOURCE_TYPE,
+ LIST_QUERY_DEBOUNCE_TIME,
+} from '~/packages_and_registries/package_registry/constants';
+import PackageTitle from './package_title.vue';
+import PackageSearch from './package_search.vue';
+// import PackageList from './packages_list.vue';
+
+export default {
+ components: {
+ // GlEmptyState,
+ // GlLink,
+ // GlSprintf,
+ // PackageList,
+ PackageTitle,
+ PackageSearch,
+ },
+ inject: [
+ 'packageHelpUrl',
+ 'emptyListIllustration',
+ 'emptyListHelpUrl',
+ 'isGroupPage',
+ 'fullPath',
+ ],
+ data() {
+ return {
+ packages: {},
+ sort: '',
+ filters: {},
+ };
+ },
+ apollo: {
+ packages: {
+ query: getPackagesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data[this.graphqlResource].packages;
+ },
+ debounce: LIST_QUERY_DEBOUNCE_TIME,
+ },
+ },
+ computed: {
+ queryVariables() {
+ return {
+ isGroupPage: this.isGroupPage,
+ fullPath: this.fullPath,
+ sort: this.isGroupPage ? undefined : this.sort,
+ groupSort: this.isGroupPage ? this.sort : undefined,
+ packageName: this.filters?.packageName,
+ packageType: this.filters?.packageType,
+ };
+ },
+ graphqlResource() {
+ return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
+ },
+ packagesCount() {
+ return this.packages?.count;
+ },
+ hasFilters() {
+ return this.filters.packageName && this.filters.packageType;
+ },
+ emptyStateTitle() {
+ return this.emptySearch
+ ? this.$options.i18n.emptyPageTitle
+ : this.$options.i18n.noResultsTitle;
+ },
+ },
+ mounted() {
+ this.checkDeleteAlert();
+ },
+ methods: {
+ checkDeleteAlert() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
+ if (showAlert) {
+ // to be refactored to use gl-alert
+ createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' });
+ const cleanUrl = window.location.href.split('?')[0];
+ historyReplaceState(cleanUrl);
+ }
+ },
+ handleSearchUpdate({ sort, filters }) {
+ this.sort = sort;
+ this.filters = { ...filters };
+ },
+ },
+ i18n: {
+ widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
+ emptyPageTitle: s__('PackageRegistry|There are no packages yet'),
+ noResultsTitle: s__('PackageRegistry|Sorry, your filter produced no results'),
+ noResultsText: s__(
+ 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <package-title :help-url="packageHelpUrl" :count="packagesCount" />
+ <package-search @update="handleSearchUpdate" />
+
+ <!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="$options.i18n.noResultsText">
+ <template #noPackagesLink="{ content }">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+ </template>
+ </package-list> -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
new file mode 100644
index 00000000000..195ff7af583
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -0,0 +1,151 @@
+<script>
+import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import {
+ PACKAGE_ERROR_STATUS,
+ PACKAGE_DEFAULT_STATUS,
+} from '~/packages_and_registries/package_registry/constants';
+import { getPackageTypeLabel } from '~/packages/shared/utils';
+import PackagePath from '~/packages/shared/components/package_path.vue';
+import PackageTags from '~/packages/shared/components/package_tags.vue';
+import PublishMethod from '~/packages_and_registries/package_registry/components/list/publish_method.vue';
+import PackageIconAndName from '~/packages/shared/components/package_icon_and_name.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ name: 'PackageListRow',
+ components: {
+ GlButton,
+ GlLink,
+ GlSprintf,
+ GlTruncate,
+ PackageTags,
+ PackagePath,
+ PublishMethod,
+ ListItem,
+ PackageIconAndName,
+ TimeagoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['isGroupPage'],
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ packageType() {
+ return getPackageTypeLabel(this.packageEntity.packageType.toLowerCase());
+ },
+ packageLink() {
+ const { project, id } = this.packageEntity;
+ return `${project?.webUrl}/-/packages/${getIdFromGraphQLId(id)}`;
+ },
+ pipeline() {
+ return this.packageEntity?.pipelines?.nodes[0];
+ },
+ pipelineUser() {
+ return this.pipeline?.user?.name;
+ },
+ showWarningIcon() {
+ return this.packageEntity.status === PACKAGE_ERROR_STATUS;
+ },
+ showTags() {
+ return Boolean(this.packageEntity.tags?.nodes?.length);
+ },
+ disabledRow() {
+ return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
+ },
+ },
+ i18n: {
+ erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
+ },
+};
+</script>
+
+<template>
+ <list-item data-qa-selector="package_row" :disabled="disabledRow">
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
+ <gl-link
+ :href="packageLink"
+ class="gl-text-body gl-min-w-0"
+ data-qa-selector="package_link"
+ :disabled="disabledRow"
+ >
+ <gl-truncate :text="packageEntity.name" />
+ </gl-link>
+
+ <gl-button
+ v-if="showWarningIcon"
+ v-gl-tooltip="{ title: $options.i18n.erroredPackageText }"
+ class="gl-hover-bg-transparent!"
+ icon="warning"
+ category="tertiary"
+ data-testid="warning-icon"
+ :aria-label="__('Warning')"
+ />
+
+ <package-tags
+ v-if="showTags"
+ class="gl-ml-3"
+ :tags="packageEntity.tags.nodes"
+ hide-label
+ :tag-display-limit="1"
+ />
+ </div>
+ </template>
+ <template #left-secondary>
+ <div class="gl-display-flex" data-testid="left-secondary-infos">
+ <span>{{ packageEntity.version }}</span>
+
+ <div v-if="pipelineUser" class="gl-display-none gl-sm-display-flex gl-ml-2">
+ <gl-sprintf :message="s__('PackageRegistry|published by %{author}')">
+ <template #author>{{ pipelineUser }}</template>
+ </gl-sprintf>
+ </div>
+
+ <package-icon-and-name>
+ {{ packageType }}
+ </package-icon-and-name>
+
+ <package-path
+ v-if="isGroupPage"
+ :path="packageEntity.project.fullPath"
+ :disabled="disabledRow"
+ />
+ </div>
+ </template>
+
+ <template #right-primary>
+ <publish-method :pipeline="pipeline" />
+ </template>
+
+ <template #right-secondary>
+ <span>
+ <gl-sprintf :message="__('Created %{timestamp}')">
+ <template #timestamp>
+ <timeago-tooltip :time="packageEntity.createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+
+ <template v-if="!disabledRow" #right-action>
+ <gl-button
+ data-testid="action-delete"
+ icon="remove"
+ category="secondary"
+ variant="danger"
+ :title="s__('PackageRegistry|Remove package')"
+ :aria-label="s__('PackageRegistry|Remove package')"
+ @click="$emit('packageToDelete', packageEntity)"
+ />
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 280d292ce0b..836df59ca58 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -1,10 +1,14 @@
<script>
-import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
import { sortableFields } from '~/packages/list/utils';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
+import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
+import {
+ FILTERED_SEARCH_TERM,
+ FILTERED_SEARCH_TYPE,
+} from '~/packages_and_registries/shared/constants';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
@@ -19,21 +23,71 @@ export default {
},
],
components: { RegistrySearch, UrlSync },
+ inject: ['isGroupPage'],
+ data() {
+ return {
+ filters: [],
+ sorting: {
+ orderBy: 'name',
+ sort: 'desc',
+ },
+ mountRegistrySearch: false,
+ };
+ },
computed: {
- ...mapState({
- isGroupPage: (state) => state.config.isGroupPage,
- sorting: (state) => state.sorting,
- filter: (state) => state.filter,
- }),
sortableFields() {
return sortableFields(this.isGroupPage);
},
+ parsedSorting() {
+ const cleanOrderBy = this.sorting?.orderBy.replace('_at', '');
+ return `${cleanOrderBy}_${this.sorting?.sort}`.toUpperCase();
+ },
+ parsedFilters() {
+ const parsed = {
+ packageName: '',
+ packageType: undefined,
+ };
+
+ return this.filters.reduce((acc, filter) => {
+ if (filter.type === FILTERED_SEARCH_TYPE && filter.value?.data) {
+ return {
+ ...acc,
+ packageType: filter.value.data.toUpperCase(),
+ };
+ }
+
+ if (filter.type === FILTERED_SEARCH_TERM) {
+ return {
+ ...acc,
+ packageName: `${acc.packageName} ${filter.value.data}`.trim(),
+ };
+ }
+
+ return acc;
+ }, parsed);
+ },
+ },
+ mounted() {
+ const queryParams = getQueryParams(window.document.location.search);
+ const { sorting, filters } = extractFilterAndSorting(queryParams);
+ this.updateSorting(sorting);
+ this.updateFilters(filters);
+ this.mountRegistrySearch = true;
+ this.emitUpdate();
},
methods: {
- ...mapActions(['setSorting', 'setFilter']),
+ updateFilters(newValue) {
+ this.filters = newValue;
+ },
updateSorting(newValue) {
- this.setSorting(newValue);
- this.$emit('update');
+ this.sorting = { ...this.sorting, ...newValue };
+ },
+ updateSortingAndEmitUpdate(newValue) {
+ this.updateSorting(newValue);
+ this.emitUpdate();
+ },
+ emitUpdate() {
+ this.$emit('update', { sort: this.parsedSorting, filters: this.parsedFilters });
},
},
};
@@ -43,13 +97,14 @@ export default {
<url-sync>
<template #default="{ updateQuery }">
<registry-search
- :filter="filter"
+ v-if="mountRegistrySearch"
+ :filter="filters"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
- @sorting:changed="updateSorting"
- @filter:changed="setFilter"
- @filter:submit="$emit('update')"
+ @sorting:changed="updateSortingAndEmitUpdate"
+ @filter:changed="updateFilters"
+ @filter:submit="emitUpdate"
@query:changed="updateQuery"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue
deleted file mode 100644
index 75fbdb80192..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue
+++ /dev/null
@@ -1,132 +0,0 @@
-<script>
-import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import createFlash from '~/flash';
-import { historyReplaceState } from '~/lib/utils/common_utils';
-import { s__ } from '~/locale';
-import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
-import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
-import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
-import PackageList from './packages_list.vue';
-
-export default {
- components: {
- GlEmptyState,
- GlLink,
- GlSprintf,
- PackageList,
- PackageTitle: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'),
- PackageSearch: () =>
- import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
- InfrastructureTitle: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
- ),
- InfrastructureSearch: () =>
- import(
- /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
- ),
- },
- inject: {
- titleComponent: {
- from: 'titleComponent',
- default: 'PackageTitle',
- },
- searchComponent: {
- from: 'searchComponent',
- default: 'PackageSearch',
- },
- emptyPageTitle: {
- from: 'emptyPageTitle',
- default: s__('PackageRegistry|There are no packages yet'),
- },
- noResultsText: {
- from: 'noResultsText',
- default: s__(
- 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
- ),
- },
- },
- computed: {
- ...mapState({
- emptyListIllustration: (state) => state.config.emptyListIllustration,
- emptyListHelpUrl: (state) => state.config.emptyListHelpUrl,
- filter: (state) => state.filter,
- selectedType: (state) => state.selectedType,
- packageHelpUrl: (state) => state.config.packageHelpUrl,
- packagesCount: (state) => state.pagination?.total,
- }),
- emptySearch() {
- return (
- this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
- );
- },
-
- emptyStateTitle() {
- return this.emptySearch
- ? this.emptyPageTitle
- : s__('PackageRegistry|Sorry, your filter produced no results');
- },
- },
- mounted() {
- const queryParams = getQueryParams(window.document.location.search);
- const { sorting, filters } = extractFilterAndSorting(queryParams);
- this.setSorting(sorting);
- this.setFilter(filters);
- this.requestPackagesList();
- this.checkDeleteAlert();
- },
- methods: {
- ...mapActions([
- 'requestPackagesList',
- 'requestDeletePackage',
- 'setSelectedType',
- 'setSorting',
- 'setFilter',
- ]),
- onPageChanged(page) {
- return this.requestPackagesList({ page });
- },
- onPackageDeleteRequest(item) {
- return this.requestDeletePackage(item);
- },
- checkDeleteAlert() {
- const urlParams = new URLSearchParams(window.location.search);
- const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
- if (showAlert) {
- // to be refactored to use gl-alert
- createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' });
- const cleanUrl = window.location.href.split('?')[0];
- historyReplaceState(cleanUrl);
- }
- },
- },
- i18n: {
- widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
- },
-};
-</script>
-
-<template>
- <div>
- <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" />
- <component :is="searchComponent" @update="requestPackagesList" />
-
- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
- <template #empty-state>
- <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
- <template #description>
- <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="noResultsText">
- <template #noPackagesLink="{ content }">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </template>
- </gl-empty-state>
- </template>
- </package-list>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue
new file mode 100644
index 00000000000..8ecf433f3ab
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ name: 'PublishMethod',
+ components: {
+ ClipboardButton,
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ hasPipeline() {
+ return Boolean(this.pipeline);
+ },
+ packageShaShort() {
+ return this.pipeline?.sha?.substring(0, 8);
+ },
+ },
+ i18n: {
+ COPY_COMMIT_SHA: __('Copy commit SHA'),
+ MANUALLY_PUBLISHED: s__('PackageRegistry|Manually Published'),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <template v-if="hasPipeline">
+ <gl-icon name="git-merge" class="gl-mr-2" />
+ <span data-testid="pipeline-ref" class="gl-mr-2">{{ pipeline.ref }}</span>
+
+ <gl-icon name="commit" class="gl-mr-2" />
+ <gl-link data-testid="pipeline-sha" :href="pipeline.commitPath" class="gl-mr-2">{{
+ packageShaShort
+ }}</gl-link>
+
+ <clipboard-button
+ :text="pipeline.sha"
+ :title="$options.i18n.COPY_COMMIT_SHA"
+ category="tertiary"
+ size="small"
+ />
+ </template>
+
+ <template v-else>
+ <gl-icon name="upload" class="gl-mr-2" />
+ <span data-testid="manually-published">
+ {{ $options.i18n.MANUALLY_PUBLISHED }}
+ </span>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index f023b4481a0..6a88880fa90 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -1,5 +1,4 @@
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
export const PACKAGE_TYPE_CONAN = 'CONAN';
export const PACKAGE_TYPE_MAVEN = 'MAVEN';
@@ -71,7 +70,7 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- __('PackageRegistry|Something went wrong while deleting the package file.'),
+ 'PackageRegistry|Something went wrong while deleting the package file.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package file deleted successfully',
@@ -87,3 +86,10 @@ export const PACKAGE_PROCESSING_STATUS = 'PROCESSING';
export const NPM_PACKAGE_MANAGER = 'npm';
export const YARN_PACKAGE_MANAGER = 'yarn';
+
+export const PROJECT_PACKAGE_ENDPOINT_TYPE = 'project';
+export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
+
+export const PROJECT_RESOURCE_TYPE = 'project';
+export const GROUP_RESOURCE_TYPE = 'group';
+export const LIST_QUERY_DEBOUNCE_TIME = 50;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
new file mode 100644
index 00000000000..aaf0eb54aff
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql
@@ -0,0 +1,27 @@
+fragment PackageData on Package {
+ id
+ name
+ version
+ packageType
+ createdAt
+ status
+ tags {
+ nodes {
+ name
+ }
+ }
+ pipelines {
+ nodes {
+ sha
+ ref
+ commitPath
+ user {
+ name
+ }
+ }
+ }
+ project {
+ fullPath
+ webUrl
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
new file mode 100644
index 00000000000..74e6de87866
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -0,0 +1,27 @@
+#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+
+query getPackages(
+ $fullPath: ID!
+ $isGroupPage: Boolean!
+ $sort: PackageSort
+ $groupSort: PackageGroupSort
+ $packageName: String
+ $packageType: PackageTypeEnum
+) {
+ project(fullPath: $fullPath) @skip(if: $isGroupPage) {
+ packages(sort: $sort, packageName: $packageName, packageType: $packageType) {
+ count
+ nodes {
+ ...PackageData
+ }
+ }
+ }
+ group(fullPath: $fullPath) @include(if: $isGroupPage) {
+ packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) {
+ count
+ nodes {
+ ...PackageData
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
index 1e01b75aabc..d797a0a5327 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
@@ -1,14 +1,22 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import PackagesListApp from '../components/list/packages_list_app.vue';
+import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
+import PackagesListApp from '../components/list/app.vue';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-list');
+ const isGroupPage = el.dataset.pageType === 'groups';
+
return new Vue({
el,
+ apolloProvider,
+ provide: {
+ ...el.dataset,
+ isGroupPage,
+ },
render(createElement) {
return createElement(PackagesListApp);
},
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
index 5cd8261ac23..9b5a0d221b8 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/bundle.js
@@ -19,6 +19,7 @@ export default () => {
apolloProvider,
provide: {
defaultExpanded: parseBoolean(el.dataset.defaultExpanded),
+ dependencyProxyAvailable: parseBoolean(el.dataset.dependencyProxyAvailable),
groupPath: el.dataset.groupPath,
},
render(createElement) {
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
new file mode 100644
index 00000000000..2dbe36def0e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlToggle, GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import updateDependencyProxySettings from '~/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql';
+import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
+import { updateGroupDependencyProxySettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+
+import {
+ DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ DEPENDENCY_PROXY_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'DependencyProxySettings',
+ components: {
+ GlToggle,
+ GlSprintf,
+ GlLink,
+ SettingsBlock,
+ },
+ i18n: {
+ DEPENDENCY_PROXY_HEADER,
+ DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
+ label: s__('DependencyProxy|Enable Proxy'),
+ },
+ links: {
+ DEPENDENCY_PROXY_DOCS_PATH,
+ },
+ inject: ['defaultExpanded', 'groupPath'],
+ props: {
+ dependencyProxySettings: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ enabled: {
+ get() {
+ return this.dependencyProxySettings.enabled;
+ },
+ set(enabled) {
+ this.updateSettings({ enabled });
+ },
+ },
+ },
+ methods: {
+ async updateSettings(payload) {
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateDependencyProxySettings,
+ variables: {
+ input: {
+ groupPath: this.groupPath,
+ ...payload,
+ },
+ },
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateGroupDependencyProxySettingsOptimisticResponse({
+ ...this.dependencyProxySettings,
+ ...payload,
+ }),
+ });
+
+ if (data.updateDependencyProxySettings?.errors?.length > 0) {
+ throw new Error();
+ } else {
+ this.$emit('success');
+ }
+ } catch {
+ this.$emit('error');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <settings-block
+ :default-expanded="defaultExpanded"
+ data-qa-selector="dependency_proxy_settings_content"
+ >
+ <template #title> {{ $options.i18n.DEPENDENCY_PROXY_HEADER }} </template>
+ <template #description>
+ <span data-testid="description">
+ <gl-sprintf :message="$options.i18n.DEPENDENCY_PROXY_SETTINGS_DESCRIPTION">
+ <template #docLink="{ content }">
+ <gl-link :href="$options.links.DEPENDENCY_PROXY_DOCS_PATH">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #default>
+ <div>
+ <gl-toggle
+ v-model="enabled"
+ :disabled="isLoading"
+ :label="$options.i18n.label"
+ data-qa-selector="dependency_proxy_setting_toggle"
+ />
+ </div>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
index d66a30e7e81..b0088838acc 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
@@ -86,6 +86,7 @@ export default {
:label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
label-position="hidden"
:value="duplicatesAllowed"
+ :disabled="loading"
@change="update(modelNames.allowed, $event)"
/>
<div class="gl-ml-5">
@@ -108,6 +109,7 @@ export default {
>
<gl-form-input
id="maven-duplicated-settings-regex-input"
+ :disabled="loading"
:value="duplicateExceptionRegex"
@change="update(modelNames.exception, $event)"
/>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index ec3be43196c..b45cedcdd66 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -1,108 +1,66 @@
<script>
-import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
-import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
-import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
-import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
-import {
- PACKAGE_SETTINGS_HEADER,
- PACKAGE_SETTINGS_DESCRIPTION,
- PACKAGES_DOCS_PATH,
- ERROR_UPDATING_SETTINGS,
- SUCCESS_UPDATING_SETTINGS,
-} from '~/packages_and_registries/settings/group/constants';
-import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
+import { GlAlert } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
+import DependencyProxySettings from '~/packages_and_registries/settings/group/components/dependency_proxy_settings.vue';
+
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
-import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
-import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
export default {
name: 'GroupSettingsApp',
- i18n: {
- PACKAGE_SETTINGS_HEADER,
- PACKAGE_SETTINGS_DESCRIPTION,
- },
- links: {
- PACKAGES_DOCS_PATH,
- },
components: {
GlAlert,
- GlSprintf,
- GlLink,
- SettingsBlock,
- MavenSettings,
- GenericSettings,
- DuplicatesSettings,
+ PackagesSettings,
+ DependencyProxySettings,
},
- inject: ['defaultExpanded', 'groupPath'],
+ inject: ['groupPath', 'dependencyProxyAvailable'],
apollo: {
- packageSettings: {
+ group: {
query: getGroupPackagesSettingsQuery,
variables() {
return {
fullPath: this.groupPath,
};
},
- update(data) {
- return data.group?.packageSettings;
- },
},
},
data() {
return {
- packageSettings: {},
- errors: {},
+ group: {},
alertMessage: null,
};
},
computed: {
+ packageSettings() {
+ return this.group?.packageSettings || {};
+ },
+ dependencyProxySettings() {
+ return this.group?.dependencyProxySetting || {};
+ },
isLoading() {
- return this.$apollo.queries.packageSettings.loading;
+ return this.$apollo.queries.group.loading;
},
},
methods: {
dismissAlert() {
this.alertMessage = null;
},
- updateSettings(payload) {
- this.errors = {};
- return this.$apollo
- .mutate({
- mutation: updateNamespacePackageSettings,
- variables: {
- input: {
- namespacePath: this.groupPath,
- ...payload,
- },
- },
- update: updateGroupPackageSettings(this.groupPath),
- optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
- ...this.packageSettings,
- ...payload,
- }),
- })
- .then(({ data }) => {
- if (data.updateNamespacePackageSettings?.errors?.length > 0) {
- this.alertMessage = ERROR_UPDATING_SETTINGS;
- } else {
- this.dismissAlert();
- this.$toast.show(SUCCESS_UPDATING_SETTINGS);
- }
- })
- .catch((e) => {
- if (e.graphQLErrors) {
- e.graphQLErrors.forEach((error) => {
- const [
- {
- path: [key],
- message,
- },
- ] = error.extensions.problems;
- this.errors = { ...this.errors, [key]: message };
- });
- }
- this.alertMessage = ERROR_UPDATING_SETTINGS;
- });
+ handleSuccess(amount = 1) {
+ const successMessage = n__(
+ 'Setting saved successfully',
+ 'Settings saved successfully',
+ amount,
+ );
+ this.$toast.show(successMessage);
+ this.dismissAlert();
+ },
+ handleError(amount = 1) {
+ const errorMessage = n__(
+ 'An error occurred while saving the setting',
+ 'An error occurred while saving the settings',
+ amount,
+ );
+ this.alertMessage = errorMessage;
},
},
};
@@ -114,50 +72,19 @@ export default {
{{ alertMessage }}
</gl-alert>
- <settings-block
- :default-expanded="defaultExpanded"
- data-qa-selector="package_registry_settings_content"
- >
- <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
- <template #description>
- <span data-testid="description">
- <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
- <template #link="{ content }">
- <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
- content
- }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </template>
- <template #default>
- <maven-settings data-testid="maven-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- toggle-qa-selector="allow_duplicates_toggle"
- label-qa-selector="allow_duplicates_label"
- @update="updateSettings"
- />
- </template>
- </maven-settings>
- <generic-settings class="gl-mt-6" data-testid="generic-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- @update="updateSettings"
- />
- </template>
- </generic-settings>
- </template>
- </settings-block>
+ <packages-settings
+ :package-settings="packageSettings"
+ :is-loading="isLoading"
+ @success="handleSuccess(2)"
+ @error="handleError(2)"
+ />
+
+ <dependency-proxy-settings
+ v-if="dependencyProxyAvailable"
+ :dependency-proxy-settings="dependencyProxySettings"
+ :is-loading="isLoading"
+ @success="handleSuccess"
+ @error="handleError"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
new file mode 100644
index 00000000000..b7e88945dbd
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
@@ -0,0 +1,139 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
+import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
+import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
+import {
+ PACKAGE_SETTINGS_HEADER,
+ PACKAGE_SETTINGS_DESCRIPTION,
+ PACKAGES_DOCS_PATH,
+} from '~/packages_and_registries/settings/group/constants';
+import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
+import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
+import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+export default {
+ name: 'PackageSettings',
+ i18n: {
+ PACKAGE_SETTINGS_HEADER,
+ PACKAGE_SETTINGS_DESCRIPTION,
+ },
+ links: {
+ PACKAGES_DOCS_PATH,
+ },
+ components: {
+ GlSprintf,
+ GlLink,
+ SettingsBlock,
+ MavenSettings,
+ GenericSettings,
+ DuplicatesSettings,
+ },
+ inject: ['defaultExpanded', 'groupPath'],
+ props: {
+ packageSettings: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ errors: {},
+ };
+ },
+ methods: {
+ async updateSettings(payload) {
+ this.errors = {};
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: updateNamespacePackageSettings,
+ variables: {
+ input: {
+ namespacePath: this.groupPath,
+ ...payload,
+ },
+ },
+ update: updateGroupPackageSettings(this.groupPath),
+ optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
+ ...this.packageSettings,
+ ...payload,
+ }),
+ });
+
+ if (data.updateNamespacePackageSettings?.errors?.length > 0) {
+ throw new Error();
+ } else {
+ this.$emit('success');
+ }
+ } catch (e) {
+ if (e.graphQLErrors) {
+ e.graphQLErrors.forEach((error) => {
+ const [
+ {
+ path: [key],
+ message,
+ },
+ ] = error.extensions.problems;
+ this.errors = { ...this.errors, [key]: message };
+ });
+ }
+ this.$emit('error');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <settings-block
+ :default-expanded="defaultExpanded"
+ data-qa-selector="package_registry_settings_content"
+ >
+ <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
+ <template #description>
+ <span data-testid="description">
+ <gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #default>
+ <maven-settings data-testid="maven-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ toggle-qa-selector="allow_duplicates_toggle"
+ label-qa-selector="allow_duplicates_label"
+ @update="updateSettings"
+ />
+ </template>
+ </maven-settings>
+ <generic-settings class="gl-mt-6" data-testid="generic-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ @update="updateSettings"
+ />
+ </template>
+ </generic-settings>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index d29489a0b33..ee922457993 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -18,9 +18,9 @@ export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
);
-export const SUCCESS_UPDATING_SETTINGS = s__('PackageRegistry|Settings saved successfully');
-export const ERROR_UPDATING_SETTINGS = s__(
- 'PackageRegistry|An error occurred while saving the settings',
+export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
+export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__(
+ 'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
);
// Parameters
@@ -28,3 +28,5 @@ export const ERROR_UPDATING_SETTINGS = s__(
export const PACKAGES_DOCS_PATH = helpPagePath('user/packages');
export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
+
+export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql
new file mode 100644
index 00000000000..d24a645fecb
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_dependency_proxy_settings.mutation.graphql
@@ -0,0 +1,8 @@
+mutation updateDependencyProxySettings($input: UpdateDependencyProxySettingsInput!) {
+ updateDependencyProxySettings(input: $input) {
+ dependencyProxySetting {
+ enabled
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
index a1c01300893..d3edebfbe20 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
@@ -1,5 +1,8 @@
query getGroupPackagesSettings($fullPath: ID!) {
group(fullPath: $fullPath) {
+ dependencyProxySetting {
+ enabled
+ }
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
index fb06f557d66..fe94203f51b 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/cache_update.js
@@ -9,9 +9,16 @@ export const updateGroupPackageSettings = (fullPath) => (client, { data: updated
const sourceData = client.readQuery(queryAndParams);
const data = produce(sourceData, (draftState) => {
- draftState.group.packageSettings = {
- ...updatedData.updateNamespacePackageSettings.packageSettings,
- };
+ if (updatedData.updateNamespacePackageSettings) {
+ draftState.group.packageSettings = {
+ ...updatedData.updateNamespacePackageSettings.packageSettings,
+ };
+ }
+ if (updatedData.updateDependencyProxySettings) {
+ draftState.group.dependencyProxySetting = {
+ ...updatedData.updateDependencyProxySettings.dependencyProxySetting,
+ };
+ }
});
client.writeQuery({
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
index f2c8de85bf8..a30d8ca0b81 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/utils/optimistic_responses.js
@@ -9,3 +9,15 @@ export const updateGroupPackagesSettingsOptimisticResponse = (changes) => ({
},
},
});
+
+export const updateGroupDependencyProxySettingsOptimisticResponse = (changes) => ({
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ updateDependencyProxySettings: {
+ __typename: 'UpdateDependencyProxySettingsPayload',
+ errors: [],
+ dependencyProxySetting: {
+ ...changes,
+ },
+ },
+});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index bf286c84d5f..7be3bba7cae 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -95,7 +95,7 @@ export default {
<gl-sprintf
:message="
__(
- 'Save space and find images in the container Registry. remove unneeded tags and keep only the ones you want. %{linkStart}How does cleanup work?%{linkEnd}',
+ 'Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}',
)
"
>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 165c4aae3cb..4d477fbd05d 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -73,6 +73,7 @@ export const OLDER_THAN_OPTIONS = [
{ key: 'SEVEN_DAYS', variable: 7, default: false },
{ key: 'FOURTEEN_DAYS', variable: 14, default: false },
{ key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'SIXTY_DAYS', variable: 60, default: false },
{ key: 'NINETY_DAYS', variable: 90, default: true },
];
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js
index 55b5816cc5a..7d2971bd8c7 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants.js
@@ -1 +1,3 @@
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
+export const FILTERED_SEARCH_TYPE = 'type';
+export const HISTORY_PIPELINES_LIMIT = 5;
diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
index 4c312a008cb..68849857d0f 100644
--- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
+++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/usage_statistics.js
@@ -1,7 +1,7 @@
import { __ } from '~/locale';
export const HELPER_TEXT_SERVICE_PING_DISABLED = __(
- 'To enable Registration Features, make sure "Enable service ping" is checked.',
+ 'To enable Registration Features, first enable Service Ping.',
);
export const HELPER_TEXT_SERVICE_PING_ENABLED = __(
diff --git a/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
new file mode 100644
index 00000000000..c75c031b0b1
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/projects/components/namespace_select.vue
@@ -0,0 +1,143 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import Api from '~/api';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ dropdownHeader: __('Namespaces'),
+ searchPlaceholder: __('Search for Namespace'),
+ anyNamespace: __('Any namespace'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ props: {
+ showAny: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: __('Namespace'),
+ },
+ fieldName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ namespaceOptions: [],
+ selectedNamespaceId: null,
+ selectedNamespace: null,
+ searchTerm: '',
+ isLoading: false,
+ };
+ },
+ computed: {
+ selectedNamespaceName() {
+ if (this.selectedNamespaceId === null) {
+ return this.placeholder;
+ }
+ return this.selectedNamespace;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.fetchNamespaces(this.searchTerm);
+ },
+ },
+ mounted() {
+ this.fetchNamespaces();
+ },
+ methods: {
+ fetchNamespaces(filter) {
+ this.isLoading = true;
+ this.namespaceOptions = [];
+ return Api.namespaces(filter, (namespaces) => {
+ this.namespaceOptions = namespaces;
+ this.isLoading = false;
+ });
+ },
+ selectNamespace(key) {
+ this.selectedNamespaceId = this.namespaceOptions[key].id;
+ this.selectedNamespace = this.getNamespaceString(this.namespaceOptions[key]);
+ this.$emit('setNamespace', this.selectedNamespaceId);
+ },
+ selectAnyNamespace() {
+ this.selectedNamespaceId = null;
+ this.selectedNamespace = null;
+ this.$emit('setNamespace', null);
+ },
+ getNamespaceString(namespace) {
+ return `${namespace.kind}: ${namespace.full_path}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex">
+ <input
+ v-if="fieldName"
+ :name="fieldName"
+ :value="selectedNamespaceId"
+ type="hidden"
+ data-testid="hidden-input"
+ />
+ <gl-dropdown
+ :text="selectedNamespaceName"
+ :header-text="$options.i18n.dropdownHeader"
+ toggle-class="dropdown-menu-toggle large"
+ data-testid="namespace-dropdown"
+ :right="true"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ class="namespace-search-box"
+ debounce="250"
+ :placeholder="$options.i18n.searchPlaceholder"
+ />
+ </template>
+
+ <template v-if="showAny">
+ <gl-dropdown-item @click="selectAnyNamespace">
+ {{ $options.i18n.anyNamespace }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ </template>
+
+ <gl-loading-icon v-if="isLoading" />
+
+ <gl-dropdown-item
+ v-for="(namespace, key) in namespaceOptions"
+ :key="namespace.id"
+ @click="selectNamespace(key)"
+ >
+ {{ getNamespaceString(namespace) }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
+
+<style scoped>
+/* workaround position: relative imposed by .top-area .nav-controls */
+.namespace-search-box >>> input {
+ position: static;
+}
+</style>
diff --git a/app/assets/javascripts/pages/admin/projects/index.js b/app/assets/javascripts/pages/admin/projects/index.js
index b07ca815f13..3098d06510b 100644
--- a/app/assets/javascripts/pages/admin/projects/index.js
+++ b/app/assets/javascripts/pages/admin/projects/index.js
@@ -1,8 +1,38 @@
-import NamespaceSelect from '~/namespace_select';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import ProjectsList from '~/projects_list';
+import NamespaceSelect from './components/namespace_select.vue';
new ProjectsList(); // eslint-disable-line no-new
-document
- .querySelectorAll('.js-namespace-select')
- .forEach((dropdown) => new NamespaceSelect({ dropdown }));
+function mountNamespaceSelect() {
+ const el = document.querySelector('.js-namespace-select');
+ if (!el) {
+ return false;
+ }
+
+ const { showAny, fieldName, placeholder, updateLocation } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(NamespaceSelect, {
+ props: {
+ showAny: parseBoolean(showAny),
+ fieldName,
+ placeholder,
+ },
+ on: {
+ setNamespace(newNamespace) {
+ if (fieldName && updateLocation) {
+ window.location = mergeUrlParams({ [fieldName]: newNamespace }, window.location.href);
+ }
+ },
+ },
+ });
+ },
+ });
+}
+
+mountNamespaceSelect();
diff --git a/app/assets/javascripts/pages/admin/serverless/domains/index.js b/app/assets/javascripts/pages/admin/serverless/domains/index.js
deleted file mode 100644
index 4fab7a1d9cb..00000000000
--- a/app/assets/javascripts/pages/admin/serverless/domains/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import initSettingsPanels from '~/settings_panels';
-
-// Initialize expandable settings panels
-initSettingsPanels();
-
-const domainCard = document.querySelector('.js-domain-cert-show');
-const domainForm = document.querySelector('.js-domain-cert-inputs');
-const domainReplaceButton = document.querySelector('.js-domain-cert-replace-btn');
-const domainSubmitButton = document.querySelector('.js-serverless-domain-submit');
-
-if (domainReplaceButton && domainCard && domainForm) {
- domainReplaceButton.addEventListener('click', () => {
- domainCard.classList.add('hidden');
- domainForm.classList.remove('hidden');
- domainSubmitButton.removeAttribute('disabled');
- });
-}
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
new file mode 100644
index 00000000000..c4e05bbd092
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import initFilePickers from '~/file_pickers';
+import ZenMode from '~/zen_mode';
+
+new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
+initFilePickers();
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/topics/new/index.js b/app/assets/javascripts/pages/admin/topics/new/index.js
new file mode 100644
index 00000000000..c4e05bbd092
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/new/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import initFilePickers from '~/file_pickers';
+import ZenMode from '~/zen_mode';
+
+new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
+initFilePickers();
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/groups/dependency_proxies/index.js b/app/assets/javascripts/pages/groups/dependency_proxies/index.js
index 77c885d3858..862ba468296 100644
--- a/app/assets/javascripts/pages/groups/dependency_proxies/index.js
+++ b/app/assets/javascripts/pages/groups/dependency_proxies/index.js
@@ -1,13 +1,3 @@
-import $ from 'jquery';
-import initDependencyProxy from '~/dependency_proxy';
+import { initDependencyProxyApp } from '~/packages_and_registries/dependency_proxy/';
-initDependencyProxy();
-
-const form = document.querySelector('form.edit_dependency_proxy_group_setting');
-const toggleInput = $('input.js-project-feature-toggle-input');
-
-if (form && toggleInput) {
- toggleInput.on('trigger-change', () => {
- form.submit();
- });
-}
+initDependencyProxyApp();
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 0137ff87979..01a371920f8 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -11,7 +11,7 @@ import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
-const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list-app'), {
[MEMBER_TYPES.user]: {
diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js
index 1c4a10fd653..95522573b53 100644
--- a/app/assets/javascripts/pages/groups/packages/index/index.js
+++ b/app/assets/javascripts/pages/groups/packages/index/index.js
@@ -1,5 +1,10 @@
-import initPackageList from '~/packages/list/packages_list_app_bundle';
+(async function packageApp() {
+ if (window.gon.features.packageListApollo) {
+ const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
-if (document.getElementById('js-vue-packages-list')) {
- initPackageList();
-}
+ newPackageList.default();
+ } else {
+ const packageList = await import('~/packages/list/packages_list_app_bundle');
+ packageList.default();
+ }
+})();
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
new file mode 100644
index 00000000000..ec3cf4a8a92
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -0,0 +1,176 @@
+<script>
+import { GlButton, GlEmptyState, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+
+import { s__, __ } from '~/locale';
+import createFlash from '~/flash';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { getBulkImportsHistory } from '~/rest_api';
+import ImportStatus from '~/import_entities/components/import_status.vue';
+import PaginationBar from '~/import_entities/components/pagination_bar.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import { DEFAULT_ERROR } from '../utils/error_messages';
+
+const DEFAULT_PER_PAGE = 20;
+const DEFAULT_TH_CLASSES =
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!';
+
+const tableCell = (config) => ({
+ thClass: `${DEFAULT_TH_CLASSES}`,
+ tdClass: (value, key, item) => {
+ return {
+ // eslint-disable-next-line no-underscore-dangle
+ 'gl-border-b-0!': item._showDetails,
+ };
+ },
+ ...config,
+});
+
+export default {
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlLink,
+ GlLoadingIcon,
+ GlTable,
+ PaginationBar,
+ ImportStatus,
+ TimeAgo,
+ },
+
+ data() {
+ return {
+ loading: true,
+ historyItems: [],
+ paginationConfig: {
+ page: 1,
+ perPage: DEFAULT_PER_PAGE,
+ },
+ pageInfo: {},
+ };
+ },
+
+ fields: [
+ tableCell({
+ key: 'source_full_path',
+ label: s__('BulkImport|Source group'),
+ thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`,
+ }),
+ tableCell({
+ key: 'destination_name',
+ label: s__('BulkImport|New group'),
+ thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`,
+ }),
+ tableCell({
+ key: 'created_at',
+ label: __('Date'),
+ }),
+ tableCell({
+ key: 'status',
+ label: __('Status'),
+ tdAttr: { 'data-qa-selector': 'import_status_indicator' },
+ }),
+ ],
+
+ computed: {
+ hasHistoryItems() {
+ return this.historyItems.length > 0;
+ },
+ },
+
+ watch: {
+ paginationConfig: {
+ handler() {
+ this.loadHistoryItems();
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
+
+ methods: {
+ async loadHistoryItems() {
+ try {
+ this.loading = true;
+ const { data: historyItems, headers } = await getBulkImportsHistory({
+ page: this.paginationConfig.page,
+ per_page: this.paginationConfig.perPage,
+ });
+ this.pageInfo = parseIntPagination(normalizeHeaders(headers));
+ this.historyItems = historyItems;
+ } catch (e) {
+ createFlash({ message: DEFAULT_ERROR, captureError: true, error: e });
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ getDestinationUrl({ destination_name: name, destination_namespace: namespace }) {
+ return [namespace, name].filter(Boolean).join('/');
+ },
+
+ getFullDestinationUrl(params) {
+ return joinPaths(gon.relative_url_root || '', this.getDestinationUrl(params));
+ },
+ },
+
+ gitlabLogo: window.gon.gitlab_logo,
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center"
+ >
+ <h1 class="gl-my-0 gl-py-4 gl-font-size-h1">
+ <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
+ {{ s__('BulkImport|Group import history') }}
+ </h1>
+ </div>
+ <gl-loading-icon v-if="loading" size="md" class="gl-mt-5" />
+ <gl-empty-state
+ v-else-if="!hasHistoryItems"
+ :title="s__('BulkImport|No history is available')"
+ :description="s__('BulkImport|Your imported groups will appear here.')"
+ />
+ <template v-else>
+ <gl-table
+ :fields="$options.fields"
+ :items="historyItems"
+ data-qa-selector="import_history_table"
+ class="gl-w-full"
+ >
+ <template #cell(destination_name)="{ item }">
+ <gl-link :href="getFullDestinationUrl(item)" target="_blank">
+ {{ getDestinationUrl(item) }}
+ </gl-link>
+ </template>
+ <template #cell(created_at)="{ value }">
+ <time-ago :time="value" />
+ </template>
+ <template #cell(status)="{ value, item, toggleDetails, detailsShowing }">
+ <import-status :status="value" class="gl-display-inline-block gl-w-13" />
+ <gl-button
+ v-if="item.failures.length"
+ class="gl-ml-3"
+ :selected="detailsShowing"
+ @click="toggleDetails"
+ >{{ __('Details') }}</gl-button
+ >
+ </template>
+ <template #row-details="{ item }">
+ <pre>{{ item.failures }}</pre>
+ </template>
+ </gl-table>
+ <pagination-bar
+ :page-info="pageInfo"
+ :items-count="historyItems.length"
+ class="gl-m-0 gl-mt-3"
+ @set-page="paginationConfig.page = $event"
+ @set-page-size="paginationConfig.perPage = $event"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/index.js
new file mode 100644
index 00000000000..5a67aa99baa
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import BulkImportHistoryApp from './components/bulk_imports_history_app.vue';
+
+function mountImportHistoryApp(mountElement) {
+ if (!mountElement) return undefined;
+
+ return new Vue({
+ el: mountElement,
+ render(createElement) {
+ return createElement(BulkImportHistoryApp);
+ },
+ });
+}
+
+mountImportHistoryApp(document.querySelector('#import-history-mount-element'));
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js b/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js
new file mode 100644
index 00000000000..24669e22ade
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/utils/error_messages.js
@@ -0,0 +1,3 @@
+import { __ } from '~/locale';
+
+export const DEFAULT_ERROR = __('Something went wrong on our end.');
diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js
index 80bc32dd43f..6afb3636998 100644
--- a/app/assets/javascripts/pages/profiles/index.js
+++ b/app/assets/javascripts/pages/profiles/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import '~/profile/gl_crop';
import Profile from '~/profile/profile';
import initSearchSettings from '~/search_settings';
+import initPasswordPrompt from './password_prompt';
// eslint-disable-next-line func-names
$(document).on('input.ssh_key', '#key_key', function () {
@@ -19,3 +20,4 @@ $(document).on('input.ssh_key', '#key_key', function () {
new Profile(); // eslint-disable-line no-new
initSearchSettings();
+initPasswordPrompt();
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/constants.js b/app/assets/javascripts/pages/profiles/password_prompt/constants.js
new file mode 100644
index 00000000000..99b8442c928
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/password_prompt/constants.js
@@ -0,0 +1,9 @@
+import { __, s__ } from '~/locale';
+
+export const I18N_PASSWORD_PROMPT_TITLE = s__('PasswordPrompt|Confirm password to continue');
+export const I18N_PASSWORD_PROMPT_FORM_LABEL = s__(
+ 'PasswordPrompt|Please enter your password to confirm',
+);
+export const I18N_PASSWORD_PROMPT_ERROR_MESSAGE = s__('PasswordPrompt|Password is required');
+export const I18N_PASSWORD_PROMPT_CONFIRM_BUTTON = s__('PasswordPrompt|Confirm password');
+export const I18N_PASSWORD_PROMPT_CANCEL_BUTTON = __('Cancel');
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/index.js b/app/assets/javascripts/pages/profiles/password_prompt/index.js
new file mode 100644
index 00000000000..20645112893
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/password_prompt/index.js
@@ -0,0 +1,58 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import PasswordPromptModal from './password_prompt_modal.vue';
+
+Vue.use(Translate);
+
+const emailFieldSelector = '#user_email';
+const editFormSelector = '.js-password-prompt-form';
+const passwordPromptFieldSelector = '.js-password-prompt-field';
+const passwordPromptBtnSelector = '.js-password-prompt-btn';
+
+const passwordPromptModalId = 'password-prompt-modal';
+
+const getEmailValue = () => document.querySelector(emailFieldSelector).value.trim();
+const passwordPromptButton = document.querySelector(passwordPromptBtnSelector);
+const field = document.querySelector(passwordPromptFieldSelector);
+const form = document.querySelector(editFormSelector);
+
+const handleConfirmPassword = (pw) => {
+ // update the validation_password field
+ field.value = pw;
+ // submit the form
+ form.submit();
+};
+
+export default () => {
+ const passwordPromptModalEl = document.getElementById(passwordPromptModalId);
+
+ if (passwordPromptModalEl && field) {
+ return new Vue({
+ el: passwordPromptModalEl,
+ data() {
+ return {
+ initialEmail: '',
+ };
+ },
+ mounted() {
+ this.initialEmail = getEmailValue();
+ passwordPromptButton.addEventListener('click', this.handleSettingsUpdate);
+ },
+ methods: {
+ handleSettingsUpdate(ev) {
+ const email = getEmailValue();
+ if (email !== this.initialEmail) {
+ ev.preventDefault();
+ this.$root.$emit('bv::show::modal', passwordPromptModalId, passwordPromptBtnSelector);
+ }
+ },
+ },
+ render(createElement) {
+ return createElement(PasswordPromptModal, {
+ props: { handleConfirmPassword },
+ });
+ },
+ });
+ }
+ return null;
+};
diff --git a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
new file mode 100644
index 00000000000..44728ea9cdf
--- /dev/null
+++ b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlModal, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import {
+ I18N_PASSWORD_PROMPT_TITLE,
+ I18N_PASSWORD_PROMPT_FORM_LABEL,
+ I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
+ I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
+ I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
+} from './constants';
+
+export default {
+ components: {
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ handleConfirmPassword: {
+ type: Function,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ passwordCheck: '',
+ };
+ },
+ computed: {
+ isValid() {
+ return Boolean(this.passwordCheck.length);
+ },
+ primaryProps() {
+ return {
+ text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON,
+ attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }],
+ };
+ },
+ },
+ methods: {
+ onConfirmPassword() {
+ this.handleConfirmPassword(this.passwordCheck);
+ },
+ },
+ cancelProps: {
+ text: I18N_PASSWORD_PROMPT_CANCEL_BUTTON,
+ },
+ i18n: {
+ title: I18N_PASSWORD_PROMPT_TITLE,
+ formLabel: I18N_PASSWORD_PROMPT_FORM_LABEL,
+ errorMessage: I18N_PASSWORD_PROMPT_ERROR_MESSAGE,
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ data-testid="password-prompt-modal"
+ modal-id="password-prompt-modal"
+ :title="$options.i18n.title"
+ :action-primary="primaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="onConfirmPassword"
+ >
+ <gl-form @submit.prevent="onConfirmPassword">
+ <gl-form-group
+ :label="$options.i18n.formLabel"
+ label-for="password-prompt-confirmation"
+ :invalid-feedback="$options.i18n.errorMessage"
+ :state="isValid"
+ >
+ <gl-form-input
+ id="password-prompt-confirmation"
+ v-model="passwordCheck"
+ name="password-confirmation"
+ type="password"
+ data-testid="password-prompt-field"
+ />
+ </gl-form-group>
+ </gl-form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/projects/cluster_agents/show/index.js b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js
new file mode 100644
index 00000000000..4ed3e2f7bea
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/cluster_agents/show/index.js
@@ -0,0 +1,3 @@
+import loadClusterAgentVues from '~/clusters/agents';
+
+loadClusterAgentVues();
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 2b5451bd18b..a1ba920b322 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,4 +1,4 @@
-import initClustersListApp from 'ee_else_ce/clusters_list';
+import initClustersListApp from '~/clusters_list';
import PersistentUserCallout from '~/persistent_user_callout';
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue
deleted file mode 100644
index ba8858c985a..00000000000
--- a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue
+++ /dev/null
@@ -1,98 +0,0 @@
-<script>
-import {
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlLoadingIcon,
- GlSearchBoxByType,
-} from '@gitlab/ui';
-import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import Tracking from '~/tracking';
-import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
-import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
-
-export default {
- components: {
- GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlDropdownSectionHeader,
- GlLoadingIcon,
- GlSearchBoxByType,
- },
- mixins: [Tracking.mixin()],
- apollo: {
- currentUser: {
- query: searchNamespacesWhereUserCanCreateProjectsQuery,
- variables() {
- return {
- search: this.search,
- };
- },
- skip() {
- return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
- },
- debounce: DEBOUNCE_DELAY,
- },
- },
- inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel'],
- data() {
- return {
- currentUser: {},
- search: '',
- selectedNamespace: {
- id: this.namespaceId,
- fullPath: this.namespaceFullPath,
- },
- };
- },
- computed: {
- userGroups() {
- return this.currentUser.groups?.nodes || [];
- },
- userNamespace() {
- return this.currentUser.namespace || {};
- },
- },
- methods: {
- handleClick({ id, fullPath }) {
- this.selectedNamespace = {
- id: getIdFromGraphQLId(id),
- fullPath,
- };
- },
- },
-};
-</script>
-
-<template>
- <gl-button-group class="gl-w-full">
- <gl-button label>{{ rootUrl }}</gl-button>
- <gl-dropdown
- class="gl-w-full"
- :text="selectedNamespace.fullPath"
- toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base!"
- data-qa-selector="select_namespace_dropdown"
- @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
- >
- <gl-search-box-by-type v-model.trim="search" />
- <gl-loading-icon v-if="$apollo.queries.currentUser.loading" />
- <template v-else>
- <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
- <gl-dropdown-item v-for="group of userGroups" :key="group.id" @click="handleClick(group)">
- {{ group.fullPath }}
- </gl-dropdown-item>
- <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
- <gl-dropdown-item @click="handleClick(userNamespace)">
- {{ userNamespace.fullPath }}
- </gl-dropdown-item>
- </template>
- </gl-dropdown>
-
- <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" />
- </gl-button-group>
-</template>
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index ed816e3be95..d89b4d0e0a3 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,66 +1,6 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import initProjectVisibilitySelector from '../../../project_visibility';
-import initProjectNew from '../../../projects/project_new';
-import NewProjectCreationApp from './components/app.vue';
-import NewProjectUrlSelect from './components/new_project_url_select.vue';
-
-function initNewProjectCreation() {
- const el = document.querySelector('.js-new-project-creation');
-
- const {
- pushToCreateProjectCommand,
- workingWithProjectsHelpPath,
- newProjectGuidelines,
- hasErrors,
- isCiCdAvailable,
- } = el.dataset;
-
- const props = {
- hasErrors: parseBoolean(hasErrors),
- isCiCdAvailable: parseBoolean(isCiCdAvailable),
- newProjectGuidelines,
- };
-
- const provide = {
- workingWithProjectsHelpPath,
- pushToCreateProjectCommand,
- };
-
- return new Vue({
- el,
- provide,
- render(h) {
- return h(NewProjectCreationApp, { props });
- },
- });
-}
-
-function initNewProjectUrlSelect() {
- const el = document.querySelector('.js-vue-new-project-url-select');
-
- if (!el) {
- return undefined;
- }
-
- Vue.use(VueApollo);
-
- return new Vue({
- el,
- apolloProvider: new VueApollo({
- defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
- }),
- provide: {
- namespaceFullPath: el.dataset.namespaceFullPath,
- namespaceId: el.dataset.namespaceId,
- rootUrl: el.dataset.rootUrl,
- trackLabel: el.dataset.trackLabel,
- },
- render: (createElement) => createElement(NewProjectUrlSelect),
- });
-}
+import { initNewProjectCreation, initNewProjectUrlSelect } from '~/projects/new';
+import initProjectVisibilitySelector from '~/project_visibility';
+import initProjectNew from '~/projects/project_new';
initProjectVisibilitySelector();
initProjectNew.bindEvents();
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
index c94782fdf1b..95522573b53 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -1,3 +1,10 @@
-import initPackageList from '~/packages/list/packages_list_app_bundle';
+(async function packageApp() {
+ if (window.gon.features.packageListApollo) {
+ const newPackageList = await import('~/packages_and_registries/package_registry/pages/list');
-initPackageList();
+ newPackageList.default();
+ } else {
+ const packageList = await import('~/packages/list/packages_list_app_bundle');
+ packageList.default();
+ }
+})();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
index 16c4a6191b2..e92b9b30fa4 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -27,19 +27,22 @@ export const findTimezoneByIdentifier = (tzList = [], identifier = null) => {
};
export default class TimezoneDropdown {
- constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) {
+ constructor({
+ $dropdownEl,
+ $inputEl,
+ onSelectTimezone,
+ displayFormat,
+ allowEmpty = false,
+ } = defaults) {
this.$dropdown = $dropdownEl;
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.$input = $inputEl;
- this.timezoneData = this.$dropdown.data('data');
+ this.timezoneData = this.$dropdown.data('data') || [];
this.onSelectTimezone = onSelectTimezone;
this.displayFormat = displayFormat || defaults.displayFormat;
+ this.allowEmpty = allowEmpty;
- this.initialTimezone =
- findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone;
-
- this.initDefaultTimezone();
this.initDropdown();
}
@@ -52,24 +55,25 @@ export default class TimezoneDropdown {
search: {
fields: ['name'],
},
- clicked: (cfg) => this.updateInputValue(cfg),
+ clicked: (cfg) => this.handleDropdownChange(cfg),
text: (item) => formatTimezone(item),
});
- this.setDropdownToggle(this.displayFormat(this.initialTimezone));
- }
+ const initialTimezone = findTimezoneByIdentifier(this.timezoneData, this.$input.val());
- initDefaultTimezone() {
- if (!this.$input.val()) {
- this.$input.val(defaultTimezone.name);
+ if (initialTimezone !== null) {
+ this.setDropdownValue(initialTimezone);
+ } else if (!this.allowEmpty) {
+ this.setDropdownValue(defaultTimezone);
}
}
- setDropdownToggle(dropdownText) {
- this.$dropdownToggle.text(dropdownText);
+ setDropdownValue(timezone) {
+ this.$dropdownToggle.text(this.displayFormat(timezone));
+ this.$input.val(timezone.name);
}
- updateInputValue({ selectedObj, e }) {
+ handleDropdownChange({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.identifier);
if (this.onSelectTimezone) {
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 0b662c945c6..947bbdacf2c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -26,7 +26,7 @@ initInviteMembersForm();
new UsersSelect(); // eslint-disable-line no-new
-const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
+const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
[MEMBER_TYPES.user]: {
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
diff --git a/app/assets/javascripts/pages/projects/wikis/diff/index.js b/app/assets/javascripts/pages/projects/wikis/diff/index.js
new file mode 100644
index 00000000000..73440db761f
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/diff/index.js
@@ -0,0 +1,3 @@
+import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
+
+initDiffStatsDropdown();
diff --git a/app/assets/javascripts/pages/projects/wikis/edit/index.js b/app/assets/javascripts/pages/projects/wikis/edit/index.js
new file mode 100644
index 00000000000..b2288c2655c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/edit/index.js
@@ -0,0 +1,3 @@
+import { mountApplications } from '~/pages/shared/wikis/edit';
+
+mountApplications();
diff --git a/app/assets/javascripts/pages/projects/wikis/git_access/index.js b/app/assets/javascripts/pages/projects/wikis/git_access/index.js
new file mode 100644
index 00000000000..b1f3006bc1a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/git_access/index.js
@@ -0,0 +1,3 @@
+import initClonePanel from '~/clone_panel';
+
+initClonePanel();
diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js
index 2c1f9e634ab..83fcd348ddf 100644
--- a/app/assets/javascripts/pages/projects/wikis/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -1,5 +1,3 @@
-import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
-import initWikis from '~/pages/shared/wikis';
+import Wikis from '~/pages/shared/wikis/wikis';
-initWikis();
-initDiffStatsDropdown();
+export default new Wikis();
diff --git a/app/assets/javascripts/pages/projects/wikis/show/index.js b/app/assets/javascripts/pages/projects/wikis/show/index.js
new file mode 100644
index 00000000000..c08a10122b6
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/wikis/show/index.js
@@ -0,0 +1,3 @@
+import { mountApplications as mountEditApplications } from '~/pages/shared/wikis/async_edit';
+
+mountEditApplications();
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index 8d2d5d41f6a..ee48543f0d2 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -20,7 +20,7 @@ export default class OAuthRememberMe {
toggleRememberMe(event) {
const rememberMe = $(event.target).is(':checked');
- $('.oauth-login', this.container).each((i, element) => {
+ $('.js-oauth-login', this.container).each((i, element) => {
const $form = $(element).parent('form');
const href = $form.attr('action');
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
index e83c73edfde..1cb7259be64 100644
--- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js
+++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
@@ -9,7 +9,12 @@ export function initInstallRunner(componentId = 'js-install-runner') {
const installRunnerEl = document.getElementById(componentId);
if (installRunnerEl) {
- const defaultClient = createDefaultClient();
+ const defaultClient = createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ );
const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/pages/shared/wikis/async_edit.js b/app/assets/javascripts/pages/shared/wikis/async_edit.js
new file mode 100644
index 00000000000..4536a076568
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/async_edit.js
@@ -0,0 +1,11 @@
+export const mountApplications = async () => {
+ const el = document.querySelector('.js-wiki-edit-page');
+
+ if (el) {
+ const { mountApplications: mountEditApplications } = await import(
+ /* webpackChunkName: 'wiki_edit' */ './edit'
+ );
+
+ mountEditApplications();
+ }
+};
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/edit.js
index 42aefe81325..beeabfde1a6 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/edit.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import Vue from 'vue';
-import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
import Translate from '~/vue_shared/translate';
@@ -9,14 +8,8 @@ import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
import wikiAlert from './components/wiki_alert.vue';
import wikiForm from './components/wiki_form.vue';
-import Wikis from './wikis';
const createModalVueApp = () => {
- new Wikis(); // eslint-disable-line no-new
- new ShortcutsWiki(); // eslint-disable-line no-new
- new ZenMode(); // eslint-disable-line no-new
- new GLForm($('.wiki-form')); // eslint-disable-line no-new
-
const deleteWikiModalWrapperEl = document.getElementById('delete-wiki-modal-wrapper');
if (deleteWikiModalWrapperEl) {
@@ -85,7 +78,10 @@ const createWikiFormApp = () => {
}
};
-export default () => {
+export const mountApplications = () => {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.wiki-form')); // eslint-disable-line no-new
+
createModalVueApp();
createAlertVueApp();
createWikiFormApp();
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 7d0b0c90c8d..8d0105bc681 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,6 +1,7 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Tracking from '~/tracking';
import showToast from '~/vue_shared/plugins/global_toast';
+import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki';
const TRACKING_EVENT_NAME = 'view_wiki_page';
const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1';
@@ -20,6 +21,7 @@ export default class Wikis {
Wikis.trackPageView();
Wikis.showToasts();
+ Wikis.initShortcuts();
}
handleToggleSidebar(e) {
@@ -64,4 +66,8 @@ export default class Wikis {
const toasts = document.querySelectorAll('.js-toast-message');
toasts.forEach((toast) => showToast(toast.dataset.message));
}
+
+ static initShortcuts() {
+ new ShortcutsWiki(); // eslint-disable-line no-new
+ }
}
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index f204f0ebfaa..ed30198244f 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,6 +1,7 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import AddRequest from './add_request.vue';
@@ -131,6 +132,12 @@ export default {
changeCurrentRequest(newRequestId) {
this.currentRequest = newRequestId;
},
+ flamegraphPath(mode) {
+ return mergeUrlParams(
+ { performance_bar: 'flamegraph', stackprof_mode: mode },
+ window.location.href,
+ );
+ },
},
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
@@ -175,6 +182,20 @@ export default {
s__('PerformanceBar|Download')
}}</a>
</div>
+ <div v-if="currentRequest.details" id="peek-flamegraph" class="view">
+ <span class="gl-text-white-200">{{ s__('PerformanceBar|Flamegraph with mode:') }}</span>
+ <a class="gl-text-blue-200" :href="flamegraphPath('wall')">{{
+ s__('PerformanceBar|wall')
+ }}</a>
+ /
+ <a class="gl-text-blue-200" :href="flamegraphPath('cpu')">{{
+ s__('PerformanceBar|cpu')
+ }}</a>
+ /
+ <a class="gl-text-blue-200" :href="flamegraphPath('object')">{{
+ s__('PerformanceBar|object')
+ }}</a>
+ </div>
<a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
s__('PerformanceBar|Stats')
}}</a>
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 8170a1f8443..a7f8704b559 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -9,6 +9,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-registration-enabled-callout',
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
+ '.js-security-newsletter-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index f2a0f474bc4..7b8e97b573e 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -9,15 +9,8 @@ export default {
SourceEditor,
},
mixins: [glFeatureFlagMixin()],
- inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'],
+ inject: ['ciConfigPath'],
inheritAttrs: false,
- props: {
- commitSha: {
- type: String,
- required: false,
- default: '',
- },
- },
methods: {
onCiConfigUpdate(content) {
this.$emit('updateCiConfig', content);
@@ -27,11 +20,7 @@ export default {
const editorInstance = this.$refs.editor.getEditor();
editorInstance.use(new CiSchemaExtension({ instance: editorInstance }));
- editorInstance.registerCiSchema({
- projectPath: this.projectPath,
- projectNamespace: this.projectNamespace,
- ref: this.commitSha || this.defaultBranch,
- });
+ editorInstance.registerCiSchema();
}
},
},
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
new file mode 100644
index 00000000000..75b1398a3c2
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -0,0 +1,49 @@
+<script>
+import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+
+export default {
+ components: {
+ PipelineMiniGraph,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ pipelinePath() {
+ return this.pipeline.detailedStatus?.detailsPath || '';
+ },
+ pipelineStages() {
+ const stages = this.pipeline.stages?.edges;
+ if (!stages) {
+ return [];
+ }
+
+ return stages.map(({ node }) => {
+ const { name, detailedStatus } = node;
+ return {
+ // TODO: fetch dropdown_path from graphql when available
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/342585
+ dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`,
+ name,
+ path: `${this.pipelinePath}#${name}`,
+ status: {
+ details_path: `${this.pipelinePath}#${name}`,
+ has_details: detailedStatus.hasDetails,
+ ...detailedStatus,
+ },
+ title: `${name}: ${detailedStatus.text}`,
+ };
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="pipelineStages.length > 0" class="stage-cell gl-mr-5">
+ <pipeline-mini-graph class="gl-display-inline" :stages="pipelineStages" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index ec240854be5..a1fa2147994 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -10,6 +10,8 @@ import {
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue';
const POLL_INTERVAL = 10000;
export const i18n = {
@@ -30,7 +32,9 @@ export default {
GlLink,
GlLoadingIcon,
GlSprintf,
+ PipelineEditorMiniGraph,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['projectFullPath'],
props: {
commitSha: {
@@ -55,12 +59,15 @@ export default {
};
},
update(data) {
- const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {};
+ const { id, commitPath = '', detailedStatus = {}, stages, status } =
+ data.project?.pipeline || {};
return {
id,
commitPath,
detailedStatus,
+ stages,
+ status,
};
},
result(res) {
@@ -111,9 +118,7 @@ export default {
</script>
<template>
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-white-space-nowrap gl-max-w-full"
- >
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap">
<template v-if="showLoadingState">
<div>
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
@@ -129,19 +134,12 @@ export default {
<template v-else>
<div>
<a :href="status.detailsPath" class="gl-mr-auto">
- <ci-icon :status="status" :size="16" />
+ <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" />
</a>
<span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.pipelineInfo">
<template #id="{ content }">
- <gl-link
- :href="status.detailsPath"
- class="pipeline-id gl-font-weight-normal pipeline-number"
- target="_blank"
- data-testid="pipeline-id"
- >
- {{ content }}{{ pipelineId }}</gl-link
- >
+ <span data-testid="pipeline-id"> {{ content }}{{ pipelineId }} </span>
</template>
<template #status>{{ status.text }}</template>
<template #commit>
@@ -157,8 +155,13 @@ export default {
</gl-sprintf>
</span>
</div>
- <div>
+ <div class="gl-display-flex gl-flex-wrap">
+ <pipeline-editor-mini-graph
+ v-if="glFeatures.pipelineEditorMiniGraph"
+ :pipeline="pipeline"
+ />
<gl-button
+ class="gl-mt-2 gl-md-mt-0"
target="_blank"
category="secondary"
variant="confirm"
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
index fbb66231f16..dcd08c9de8d 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -2,7 +2,6 @@
import { GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -17,17 +16,11 @@ export default {
),
btnText: __('Create new CI/CD pipeline'),
},
- mixins: [glFeatureFlagsMixin()],
inject: {
emptyStateIllustrationPath: {
default: '',
},
},
- computed: {
- showCTAButton() {
- return this.glFeatures.pipelineEditorEmptyStateAction;
- },
- },
methods: {
createEmptyConfigFile() {
this.$emit('createEmptyConfigFile');
@@ -48,12 +41,7 @@ export default {
</template>
</gl-sprintf>
</p>
- <gl-button
- v-if="showCTAButton"
- variant="confirm"
- class="gl-mt-3"
- @click="createEmptyConfigFile"
- >
+ <gl-button variant="confirm" class="gl-mt-3" @click="createEmptyConfigFile">
{{ $options.i18n.btnText }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
index d3a7387ad2d..0c3653a2880 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -11,6 +11,25 @@ query getPipeline($fullPath: ID!, $sha: String!) {
group
text
}
+ stages {
+ edges {
+ node {
+ id
+ name
+ status
+ detailedStatus {
+ detailsPath
+ group
+ hasDetails
+ icon
+ id
+ label
+ text
+ tooltip
+ }
+ }
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 4324c64ab3b..ba567023946 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
@@ -15,7 +14,6 @@ export default {
PipelineEditorHeader,
PipelineEditorTabs,
},
- mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -44,9 +42,6 @@ export default {
showCommitForm() {
return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
},
- showPipelineDrawer() {
- return this.glFeatures.pipelineEditorDrawer;
- },
},
methods: {
setCurrentTab(tabName) {
@@ -77,6 +72,6 @@ export default {
:commit-sha="commitSha"
v-on="$listeners"
/>
- <pipeline-editor-drawer v-if="showPipelineDrawer" />
+ <pipeline-editor-drawer />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index fd40ca0b9c9..0216b2717ed 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -52,7 +52,7 @@ export default {
required: true,
},
cssClassJobName: {
- type: [String, Array],
+ type: [String, Array, Object],
required: false,
default: '',
},
@@ -167,9 +167,13 @@ export default {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
},
jobClasses() {
- return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
- ? `${this.$options.hoverClass} ${this.cssClassJobName}`
- : this.cssClassJobName;
+ return [
+ {
+ [this.$options.hoverClass]:
+ this.relatedDownstreamHovered || this.relatedDownstreamExpanded,
+ },
+ this.cssClassJobName,
+ ];
},
},
errorCaptured(err, _vm, info) {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index 3470c963ade..b778fe28e59 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -5,7 +5,6 @@ import {
GlDropdownItem,
GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
@@ -13,7 +12,6 @@ import { __, s__ } from '~/locale';
export const i18n = {
artifacts: __('Artifacts'),
- downloadArtifact: __('Download %{name} artifact'),
artifactSectionHeader: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
emptyArtifactsMessage: __('No artifacts found'),
@@ -30,7 +28,6 @@ export default {
GlDropdownItem,
GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
},
inject: {
artifactsEndpoint: {
@@ -113,9 +110,7 @@ export default {
class="gl-word-break-word"
data-testid="artifact-item"
>
- <gl-sprintf :message="$options.i18n.downloadArtifact">
- <template #name>{{ artifact.name }}</template>
- </gl-sprintf>
+ {{ artifact.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index 3bd149fc782..ef21673115e 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlModal } from '@gitlab/ui';
+import { GlLink, GlModal, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __, s__, sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -13,6 +13,7 @@ export default {
components: {
GlModal,
GlLink,
+ GlSprintf,
CiIcon,
},
props: {
@@ -33,13 +34,7 @@ export default {
);
},
modalText() {
- return sprintf(
- s__(`Pipeline|You’re about to stop pipeline %{pipelineId}.`),
- {
- pipelineId: `<strong>#${this.pipeline.id}</strong>`,
- },
- false,
- );
+ return s__(`Pipeline|You’re about to stop pipeline #%{pipelineId}.`);
},
hasRef() {
return !isEmpty(this.pipeline.ref);
@@ -71,7 +66,13 @@ export default {
:action-cancel="cancelProps"
@primary="emitSubmit($event)"
>
- <p v-html="modalText /* eslint-disable-line vue/no-v-html */"></p>
+ <p>
+ <gl-sprintf :message="modalText">
+ <template #pipelineId>
+ <strong>{{ pipeline.id }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
<p v-if="pipeline">
<ci-icon
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 36629d9f1f1..1c7c4d7c704 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -3,8 +3,8 @@ import {
GlAlert,
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
@@ -12,7 +12,6 @@ import { __, s__ } from '~/locale';
export const i18n = {
artifacts: __('Artifacts'),
- downloadArtifact: __('Download %{name} artifact'),
artifactSectionHeader: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
noArtifacts: s__('Pipelines|No artifacts available'),
@@ -27,8 +26,8 @@ export default {
GlAlert,
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlLoadingIcon,
- GlSprintf,
},
inject: {
artifactsEndpoint: {
@@ -92,6 +91,10 @@ export default {
text-sr-only
@show.once="fetchArtifacts"
>
+ <gl-dropdown-section-header>{{
+ $options.i18n.artifactSectionHeader
+ }}</gl-dropdown-section-header>
+
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
@@ -108,10 +111,9 @@ export default {
:href="artifact.path"
rel="nofollow"
download
+ class="gl-word-break-word"
>
- <gl-sprintf :message="$options.i18n.downloadArtifact">
- <template #name>{{ artifact.name }}</template>
- </gl-sprintf>
+ {{ artifact.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
index 02baa76f627..d8f15cfde91 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
@@ -2,51 +2,51 @@ import { s__ } from '~/locale';
export const PIPELINE_SOURCES = [
{
- text: s__('Pipeline|Source|Push'),
+ text: s__('PipelineSource|Push'),
value: 'push',
},
{
- text: s__('Pipeline|Source|Web'),
+ text: s__('PipelineSource|Web'),
value: 'web',
},
{
- text: s__('Pipeline|Source|Trigger'),
+ text: s__('PipelineSource|Trigger'),
value: 'trigger',
},
{
- text: s__('Pipeline|Source|Schedule'),
+ text: s__('PipelineSource|Schedule'),
value: 'schedule',
},
{
- text: s__('Pipeline|Source|API'),
+ text: s__('PipelineSource|API'),
value: 'api',
},
{
- text: s__('Pipeline|Source|External'),
+ text: s__('PipelineSource|External'),
value: 'external',
},
{
- text: s__('Pipeline|Source|Pipeline'),
+ text: s__('PipelineSource|Pipeline'),
value: 'pipeline',
},
{
- text: s__('Pipeline|Source|Chat'),
+ text: s__('PipelineSource|Chat'),
value: 'chat',
},
{
- text: s__('Pipeline|Source|Web IDE'),
+ text: s__('PipelineSource|Web IDE'),
value: 'webide',
},
{
- text: s__('Pipeline|Source|Merge Request'),
+ text: s__('PipelineSource|Merge Request'),
value: 'merge_request_event',
},
{
- text: s__('Pipeline|Source|External Pull Request'),
+ text: s__('PipelineSource|External Pull Request'),
value: 'external_pull_request_event',
},
{
- text: s__('Pipeline|Source|Parent Pipeline'),
+ text: s__('PipelineSource|Parent Pipeline'),
value: 'parent_pipeline',
},
];
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index c49ade2bbb8..ff9b47cdcd6 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -21,6 +21,7 @@ export default class Profile {
$inputEl: this.$inputEl,
$dropdownEl: $('.js-timezone-dropdown'),
displayFormat: (selectedItem) => formatTimezone(selectedItem),
+ allowEmpty: true,
});
}
diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue
index 6e9efc50be8..6e9efc50be8 100644
--- a/app/assets/javascripts/pages/projects/new/components/app.vue
+++ b/app/assets/javascripts/projects/new/components/app.vue
diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
index e42d9154866..e42d9154866 100644
--- a/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue
+++ b/app/assets/javascripts/projects/new/components/new_project_push_tip_popover.vue
diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
new file mode 100644
index 00000000000..bf44ff70562
--- /dev/null
+++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue
@@ -0,0 +1,163 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import Tracking from '~/tracking';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ mixins: [Tracking.mixin()],
+ apollo: {
+ currentUser: {
+ query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ skip() {
+ return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ inject: [
+ 'namespaceFullPath',
+ 'namespaceId',
+ 'rootUrl',
+ 'trackLabel',
+ 'userNamespaceFullPath',
+ 'userNamespaceId',
+ ],
+ data() {
+ return {
+ currentUser: {},
+ groupToFilterBy: undefined,
+ search: '',
+ selectedNamespace: this.namespaceId
+ ? {
+ id: this.namespaceId,
+ fullPath: this.namespaceFullPath,
+ }
+ : {
+ id: this.userNamespaceId,
+ fullPath: this.userNamespaceFullPath,
+ },
+ };
+ },
+ computed: {
+ userGroups() {
+ return this.currentUser.groups?.nodes || [];
+ },
+ userNamespace() {
+ return this.currentUser.namespace || {};
+ },
+ filteredGroups() {
+ return this.groupToFilterBy
+ ? this.userGroups.filter((group) =>
+ group.fullPath.startsWith(this.groupToFilterBy.fullPath),
+ )
+ : this.userGroups;
+ },
+ hasGroupMatches() {
+ return this.filteredGroups.length;
+ },
+ hasNamespaceMatches() {
+ return (
+ this.userNamespace.fullPath?.toLowerCase().includes(this.search.toLowerCase()) &&
+ !this.groupToFilterBy
+ );
+ },
+ hasNoMatches() {
+ return !this.hasGroupMatches && !this.hasNamespaceMatches;
+ },
+ },
+ created() {
+ eventHub.$on('select-template', this.handleSelectTemplate);
+ },
+ beforeDestroy() {
+ eventHub.$off('select-template', this.handleSelectTemplate);
+ },
+ methods: {
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ handleSelectTemplate(groupId) {
+ this.groupToFilterBy = this.userGroups.find(
+ (group) => getIdFromGraphQLId(group.id) === groupId,
+ );
+ if (this.groupToFilterBy) {
+ this.setNamespace(this.groupToFilterBy);
+ }
+ },
+ setNamespace({ id, fullPath }) {
+ this.selectedNamespace = {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group class="input-lg">
+ <gl-button class="gl-text-truncate" label :title="rootUrl">{{ rootUrl }}</gl-button>
+ <gl-dropdown
+ :text="selectedNamespace.fullPath"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
+ data-qa-selector="select_namespace_dropdown"
+ @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
+ @shown="focusInput"
+ >
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ data-qa-selector="select_namespace_dropdown_search_field"
+ />
+ <gl-loading-icon v-if="$apollo.queries.currentUser.loading" />
+ <template v-else>
+ <template v-if="hasGroupMatches">
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="group of filteredGroups"
+ :key="group.id"
+ @click="setNamespace(group)"
+ >
+ {{ group.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <template v-if="hasNamespaceMatches">
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="setNamespace(userNamespace)">
+ {{ userNamespace.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-if="hasNoMatches">{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+
+ <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" />
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/projects/new/event_hub.js b/app/assets/javascripts/projects/new/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/projects/new/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js
new file mode 100644
index 00000000000..572d3276e4f
--- /dev/null
+++ b/app/assets/javascripts/projects/new/index.js
@@ -0,0 +1,66 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import NewProjectCreationApp from './components/app.vue';
+import NewProjectUrlSelect from './components/new_project_url_select.vue';
+
+export function initNewProjectCreation() {
+ const el = document.querySelector('.js-new-project-creation');
+
+ const {
+ pushToCreateProjectCommand,
+ workingWithProjectsHelpPath,
+ newProjectGuidelines,
+ hasErrors,
+ isCiCdAvailable,
+ } = el.dataset;
+
+ const props = {
+ hasErrors: parseBoolean(hasErrors),
+ isCiCdAvailable: parseBoolean(isCiCdAvailable),
+ newProjectGuidelines,
+ };
+
+ const provide = {
+ workingWithProjectsHelpPath,
+ pushToCreateProjectCommand,
+ };
+
+ return new Vue({
+ el,
+ provide,
+ render(h) {
+ return h(NewProjectCreationApp, { props });
+ },
+ });
+}
+
+export function initNewProjectUrlSelect() {
+ const elements = document.querySelectorAll('.js-vue-new-project-url-select');
+
+ if (!elements.length) {
+ return;
+ }
+
+ Vue.use(VueApollo);
+
+ elements.forEach(
+ (el) =>
+ new Vue({
+ el,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ }),
+ provide: {
+ namespaceFullPath: el.dataset.namespaceFullPath,
+ namespaceId: el.dataset.namespaceId,
+ rootUrl: el.dataset.rootUrl,
+ trackLabel: el.dataset.trackLabel,
+ userNamespaceFullPath: el.dataset.userNamespaceFullPath,
+ userNamespaceId: el.dataset.userNamespaceId,
+ },
+ render: (createElement) => createElement(NewProjectUrlSelect),
+ }),
+ );
+}
diff --git a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
index e16fe5dde49..e16fe5dde49 100644
--- a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
+++ b/app/assets/javascripts/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ebd20583a1c..b350db0c838 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,5 +1,7 @@
import $ from 'jquery';
+import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
+import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
humanize,
@@ -9,6 +11,23 @@ import {
let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
+const invalidInputClass = 'gl-field-error-outline';
+
+const validateImportCredentials = (url, user, password) => {
+ const endpoint = `${gon.relative_url_root}/import/url/validate`;
+ return axios
+ .post(endpoint, {
+ url,
+ user,
+ password,
+ })
+ .then(({ data }) => data)
+ .catch(() => ({
+ // intentionally reporting success in case of validation error
+ // we do not want to block users from trying import in case of validation exception
+ success: true,
+ }));
+};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify(convertUnicodeToAscii($projectNameInput.val()));
@@ -85,7 +104,10 @@ const bindHowToImport = () => {
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
- const $projectImportUrlWarning = $('.js-import-url-warning');
+ const $projectImportUrlUser = $('#project_import_url_user');
+ const $projectImportUrlPassword = $('#project_import_url_password');
+ const $projectImportUrlError = $('.js-import-url-error');
+ const $projectImportForm = $('.project-import form');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@@ -139,12 +161,15 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- function updateUrlPathWarningVisibility() {
- const url = $projectImportUrl.val();
- const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/;
- const isUrlValid = URL_PATTERN.test(url);
- $projectImportUrlWarning.toggleClass('hide', isUrlValid);
- }
+ const updateUrlPathWarningVisibility = debounce(async () => {
+ const { success: isUrlValid } = await validateImportCredentials(
+ $projectImportUrl.val(),
+ $projectImportUrlUser.val(),
+ $projectImportUrlPassword.val(),
+ );
+ $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
+ $projectImportUrlError.toggleClass('hide', isUrlValid);
+ }, 500);
let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => {
@@ -153,9 +178,22 @@ const bindEvents = () => {
});
$projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
- // defer error message till first input blur
- if (isProjectImportUrlDirty) {
- updateUrlPathWarningVisibility();
+ });
+
+ [$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
+ $f.on('input', () => {
+ if (isProjectImportUrlDirty) {
+ updateUrlPathWarningVisibility();
+ }
+ });
+ });
+
+ $projectImportForm.on('submit', (e) => {
+ const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
+ if ($invalidFields.length > 0) {
+ $invalidFields[0].focus();
+ e.preventDefault();
+ e.stopPropagation();
}
});
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index a5e53ee3927..7fb7a416dca 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -2,8 +2,8 @@
import { escape, find, countBy } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
import { n__, s__, __, sprintf } from '~/locale';
+import { getUsers, getGroups, getDeployKeys } from './api/access_dropdown_api';
import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVELS, ACCESS_LEVEL_NONE } from './constants';
export default class AccessDropdown {
@@ -16,9 +16,6 @@ export default class AccessDropdown {
this.accessLevelsData = accessLevelsData.roles;
this.$dropdown = $dropdown;
this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`);
- this.usersPath = '/-/autocomplete/users.json';
- this.groupsPath = '/-/autocomplete/project_groups.json';
- this.deployKeysPath = '/-/autocomplete/deploy_keys_with_owners.json';
this.defaultLabel = this.$dropdown.data('defaultLabel');
this.setSelectedItems([]);
@@ -318,9 +315,9 @@ export default class AccessDropdown {
getData(query, callback) {
if (this.hasLicense) {
Promise.all([
- this.getDeployKeys(query),
- this.getUsers(query),
- this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(),
+ getDeployKeys(query),
+ getUsers(query),
+ this.groupsData ? Promise.resolve(this.groupsData) : getGroups(),
])
.then(([deployKeysResponse, usersResponse, groupsResponse]) => {
this.groupsData = groupsResponse;
@@ -332,7 +329,7 @@ export default class AccessDropdown {
createFlash({ message: __('Failed to load groups, users and deploy keys.') });
});
} else {
- this.getDeployKeys(query)
+ getDeployKeys(query)
.then((deployKeysResponse) => callback(this.consolidateData(deployKeysResponse.data)))
.catch(() => createFlash({ message: __('Failed to load deploy keys.') }));
}
@@ -473,46 +470,6 @@ export default class AccessDropdown {
return consolidatedData;
}
- getUsers(query) {
- return axios.get(this.buildUrl(gon.relative_url_root, this.usersPath), {
- params: {
- search: query,
- per_page: 20,
- active: true,
- project_id: gon.current_project_id,
- push_code: true,
- },
- });
- }
-
- getGroups() {
- return axios.get(this.buildUrl(gon.relative_url_root, this.groupsPath), {
- params: {
- project_id: gon.current_project_id,
- },
- });
- }
-
- getDeployKeys(query) {
- return axios.get(this.buildUrl(gon.relative_url_root, this.deployKeysPath), {
- params: {
- search: query,
- per_page: 20,
- active: true,
- project_id: gon.current_project_id,
- push_code: true,
- },
- });
- }
-
- buildUrl(urlRoot, url) {
- let newUrl;
- if (urlRoot != null) {
- newUrl = urlRoot.replace(/\/$/, '') + url;
- }
- return newUrl;
- }
-
renderRow(item) {
let criteria = {};
let groupRowEl;
diff --git a/app/assets/javascripts/projects/settings/api/access_dropdown_api.js b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
new file mode 100644
index 00000000000..10f6c28a7bf
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/api/access_dropdown_api.js
@@ -0,0 +1,45 @@
+import axios from '~/lib/utils/axios_utils';
+
+const USERS_PATH = '/-/autocomplete/users.json';
+const GROUPS_PATH = '/-/autocomplete/project_groups.json';
+const DEPLOY_KEYS_PATH = '/-/autocomplete/deploy_keys_with_owners.json';
+
+const buildUrl = (urlRoot, url) => {
+ let newUrl;
+ if (urlRoot != null) {
+ newUrl = urlRoot.replace(/\/$/, '') + url;
+ }
+ return newUrl;
+};
+
+export const getUsers = (query) => {
+ return axios.get(buildUrl(gon.relative_url_root || '', USERS_PATH), {
+ params: {
+ search: query,
+ per_page: 20,
+ active: true,
+ project_id: gon.current_project_id,
+ push_code: true,
+ },
+ });
+};
+
+export const getGroups = () => {
+ return axios.get(buildUrl(gon.relative_url_root || '', GROUPS_PATH), {
+ params: {
+ project_id: gon.current_project_id,
+ },
+ });
+};
+
+export const getDeployKeys = (query) => {
+ return axios.get(buildUrl(gon.relative_url_root || '', DEPLOY_KEYS_PATH), {
+ params: {
+ search: query,
+ per_page: 20,
+ active: true,
+ project_id: gon.current_project_id,
+ push_code: true,
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
new file mode 100644
index 00000000000..9823b0229a0
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -0,0 +1,409 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlAvatar,
+ GlSprintf,
+} from '@gitlab/ui';
+import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
+import createFlash from '~/flash';
+import { __, s__, n__ } from '~/locale';
+import { getUsers, getGroups, getDeployKeys } from '../api/access_dropdown_api';
+import { LEVEL_TYPES, ACCESS_LEVELS } from '../constants';
+
+export const i18n = {
+ selectUsers: s__('ProtectedEnvironment|Select users'),
+ rolesSectionHeader: s__('AccessDropdown|Roles'),
+ groupsSectionHeader: s__('AccessDropdown|Groups'),
+ usersSectionHeader: s__('AccessDropdown|Users'),
+ deployKeysSectionHeader: s__('AccessDropdown|Deploy Keys'),
+ ownedBy: __('Owned by %{image_tag}'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlAvatar,
+ GlSprintf,
+ },
+ props: {
+ accessLevelsData: {
+ type: Array,
+ required: true,
+ },
+ accessLevel: {
+ required: true,
+ type: String,
+ },
+ hasLicense: {
+ required: false,
+ type: Boolean,
+ default: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: i18n.selectUsers,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ preselectedItems: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ initialLoading: false,
+ query: '',
+ users: [],
+ groups: [],
+ roles: [],
+ deployKeys: [],
+ selected: {
+ [LEVEL_TYPES.GROUP]: [],
+ [LEVEL_TYPES.USER]: [],
+ [LEVEL_TYPES.ROLE]: [],
+ [LEVEL_TYPES.DEPLOY_KEY]: [],
+ },
+ };
+ },
+ computed: {
+ preselected() {
+ return groupBy(this.preselectedItems, 'type');
+ },
+ showDeployKeys() {
+ return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length;
+ },
+ toggleLabel() {
+ const counts = Object.entries(this.selected).reduce((acc, [key, value]) => {
+ acc[key] = value.length;
+ return acc;
+ }, {});
+
+ const isOnlyRoleSelected =
+ counts[LEVEL_TYPES.ROLE] === 1 &&
+ [counts[LEVEL_TYPES.USER], counts[LEVEL_TYPES.GROUP], counts[LEVEL_TYPES.DEPLOY_KEY]].every(
+ (count) => count === 0,
+ );
+
+ if (isOnlyRoleSelected) {
+ return this.selected[LEVEL_TYPES.ROLE][0].text;
+ }
+
+ const labelPieces = [];
+
+ if (counts[LEVEL_TYPES.ROLE] > 0) {
+ labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE]));
+ }
+
+ if (counts[LEVEL_TYPES.USER] > 0) {
+ labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER]));
+ }
+
+ if (counts[LEVEL_TYPES.DEPLOY_KEY] > 0) {
+ labelPieces.push(n__('1 deploy key', '%d deploy keys', counts[LEVEL_TYPES.DEPLOY_KEY]));
+ }
+
+ if (counts[LEVEL_TYPES.GROUP] > 0) {
+ labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
+ }
+
+ return labelPieces.join(', ') || this.label;
+ },
+ toggleClass() {
+ return this.toggleLabel === this.label ? 'gl-text-gray-500!' : '';
+ },
+ selection() {
+ return [
+ ...this.getDataForSave(LEVEL_TYPES.ROLE, 'access_level'),
+ ...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id'),
+ ...this.getDataForSave(LEVEL_TYPES.USER, 'user_id'),
+ ...this.getDataForSave(LEVEL_TYPES.DEPLOY_KEY, 'deploy_key_id'),
+ ];
+ },
+ },
+ watch: {
+ query: debounce(function debouncedSearch() {
+ return this.getData();
+ }, 500),
+ },
+ created() {
+ this.getData({ initial: true });
+ },
+ methods: {
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ getData({ initial = false } = {}) {
+ this.initialLoading = initial;
+ this.loading = true;
+
+ if (this.hasLicense) {
+ Promise.all([
+ getDeployKeys(this.query),
+ getUsers(this.query),
+ this.groups.length ? Promise.resolve({ data: this.groups }) : getGroups(),
+ ])
+ .then(([deployKeysResponse, usersResponse, groupsResponse]) => {
+ this.consolidateData(deployKeysResponse.data, usersResponse.data, groupsResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() =>
+ createFlash({ message: __('Failed to load groups, users and deploy keys.') }),
+ )
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ } else {
+ getDeployKeys(this.query)
+ .then((deployKeysResponse) => {
+ this.consolidateData(deployKeysResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() => createFlash({ message: __('Failed to load deploy keys.') }))
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ }
+ },
+ consolidateData(deployKeysResponse, usersResponse = [], groupsResponse = []) {
+ // This re-assignment is intentional as level.type property is being used for comparision,
+ // and accessLevelsData is provided by gon.create_access_levels which doesn't have `type` included.
+ // See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823
+ this.roles = this.accessLevelsData.map((role) => ({ ...role, type: LEVEL_TYPES.ROLE }));
+
+ if (this.hasLicense) {
+ this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
+ this.users = usersResponse.map(({ id, name, username, avatar_url }) => ({
+ id,
+ name,
+ username,
+ avatar_url,
+ type: LEVEL_TYPES.USER,
+ }));
+ }
+
+ this.deployKeys = deployKeysResponse.map((response) => {
+ const {
+ id,
+ fingerprint,
+ title,
+ owner: { avatar_url, name, username },
+ } = response;
+
+ const shortFingerprint = `(${fingerprint.substring(0, 14)}...)`;
+
+ return {
+ id,
+ title: title.concat(' ', shortFingerprint),
+ avatar_url,
+ fullname: name,
+ username,
+ type: LEVEL_TYPES.DEPLOY_KEY,
+ };
+ });
+ },
+ setSelected({ initial } = {}) {
+ if (initial) {
+ // as all available groups && roles are always visible in the dropdown, we set local selected by looking
+ // for intersection in all roles/groups and initial selected (returned from BE).
+ // It is different for the users - not all the users will be returned on the first data load (another set
+ // will be returned on search, only first 20 are displayed initially).
+ // That is why we set ALL initial selected users (returned from BE) as local selected (not looking
+ // for the intersection with all users data) and later if the selected happens to be in the users list
+ // we filter it out from the list so that not to have duplicates
+ // TODO: we'll need to get back to how to handle deploy keys here but they are out of scope
+ // and will be checked when migrating protected branches access dropdown to the current component
+ // related issue - https://gitlab.com/gitlab-org/gitlab/-/issues/284784
+ const selectedRoles = intersectionWith(
+ this.roles,
+ this.preselectedItems,
+ (role, selected) => {
+ return selected.type === LEVEL_TYPES.ROLE && role.id === selected.access_level;
+ },
+ );
+ this.selected[LEVEL_TYPES.ROLE] = selectedRoles;
+
+ const selectedGroups = intersectionWith(
+ this.groups,
+ this.preselectedItems,
+ (group, selected) => {
+ return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id;
+ },
+ );
+ this.selected[LEVEL_TYPES.GROUP] = selectedGroups;
+
+ const selectedDeployKeys = intersectionWith(
+ this.deployKeys,
+ this.preselectedItems,
+ (key, selected) => {
+ return selected.type === LEVEL_TYPES.DEPLOY_KEY && key.id === selected.deploy_key_id;
+ },
+ );
+ this.selected[LEVEL_TYPES.DEPLOY_KEY] = selectedDeployKeys;
+
+ const selectedUsers = this.preselectedItems
+ .filter(({ type }) => type === LEVEL_TYPES.USER)
+ .map(({ user_id, name, username, avatar_url, type }) => ({
+ id: user_id,
+ name,
+ username,
+ avatar_url,
+ type,
+ }));
+
+ this.selected[LEVEL_TYPES.USER] = selectedUsers;
+ }
+
+ this.users = this.users.filter(
+ (user) => !this.selected[LEVEL_TYPES.USER].some((selected) => selected.id === user.id),
+ );
+ this.users.unshift(...this.selected[LEVEL_TYPES.USER]);
+ },
+ getDataForSave(accessType, key) {
+ const selected = this.selected[accessType].map(({ id }) => ({ [key]: id }));
+ const preselected = this.preselected[accessType];
+ const added = differenceBy(selected, preselected, key);
+ const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ }));
+ const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ _destroy: true,
+ }));
+ return [...added, ...removed, ...preserved];
+ },
+ onItemClick(item) {
+ this.toggleSelection(this.selected[item.type], item);
+ this.emitUpdate();
+ },
+ toggleSelection(arr, item) {
+ const itemIndex = arr.findIndex(({ id }) => id === item.id);
+ if (itemIndex > -1) {
+ arr.splice(itemIndex, 1);
+ } else arr.push(item);
+ },
+ isSelected(item) {
+ return this.selected[item.type].some((selected) => selected.id === item.id);
+ },
+ emitUpdate() {
+ this.$emit('select', this.selection);
+ },
+ onHide() {
+ this.$emit('hidden', this.selection);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :disabled="disabled || initialLoading"
+ :text="toggleLabel"
+ class="gl-min-w-20"
+ :toggle-class="toggleClass"
+ aria-labelledby="allowed-users-label"
+ @shown="focusInput"
+ @hidden="onHide"
+ >
+ <template #header>
+ <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
+ </template>
+ <template v-if="roles.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.rolesSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="role in roles"
+ :key="`${role.id}${role.text}`"
+ data-testid="role-dropdown-item"
+ is-check-item
+ :is-checked="isSelected(role)"
+ @click.native.capture.stop="onItemClick(role)"
+ >
+ {{ role.text }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="groups.length || users.length || showDeployKeys" />
+ </template>
+
+ <template v-if="groups.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.groupsSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="`${group.id}${group.name}`"
+ fingerprint
+ data-testid="group-dropdown-item"
+ :avatar-url="group.avatar_url"
+ is-check-item
+ :is-checked="isSelected(group)"
+ @click.native.capture.stop="onItemClick(group)"
+ >
+ {{ group.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="users.length || showDeployKeys" />
+ </template>
+
+ <template v-if="users.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.usersSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="user in users"
+ :key="`${user.id}${user.username}`"
+ data-testid="user-dropdown-item"
+ :avatar-url="user.avatar_url"
+ :secondary-text="user.username"
+ is-check-item
+ :is-checked="isSelected(user)"
+ @click.native.capture.stop="onItemClick(user)"
+ >
+ {{ user.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="showDeployKeys" />
+ </template>
+
+ <template v-if="showDeployKeys">
+ <gl-dropdown-section-header>{{
+ $options.i18n.deployKeysSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="key in deployKeys"
+ :key="`${key.id}${key.fingerprint}`"
+ data-testid="deploy_key-dropdown-item"
+ is-check-item
+ :is-checked="isSelected(key)"
+ class="gl-text-truncate"
+ @click.native.capture.stop="onItemClick(key)"
+ >
+ <div class="gl-text-truncate gl-font-weight-bold">{{ key.title }}</div>
+ <div class="gl-text-gray-700 gl-text-truncate">
+ <gl-sprintf :message="$options.i18n.ownedBy">
+ <template #image_tag>
+ <gl-avatar :src="key.avatar_url" :size="24" />
+ </template> </gl-sprintf
+ >{{ key.fullname }} ({{ key.username }})
+ </div>
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js
new file mode 100644
index 00000000000..11272652b63
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js
@@ -0,0 +1,39 @@
+import * as Sentry from '@sentry/browser';
+import Vue from 'vue';
+import AccessDropdown from './components/access_dropdown.vue';
+
+export const initAccessDropdown = (el, options) => {
+ if (!el) {
+ return false;
+ }
+
+ const { accessLevelsData, accessLevel } = options;
+ const { label, disabled, preselectedItems } = el.dataset;
+ let preselected = [];
+ try {
+ preselected = JSON.parse(preselectedItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+
+ return new Vue({
+ el,
+ render(createElement) {
+ const vm = this;
+ return createElement(AccessDropdown, {
+ props: {
+ accessLevel,
+ accessLevelsData: accessLevelsData.roles,
+ preselectedItems: preselected,
+ label,
+ disabled,
+ },
+ on: {
+ select(selected) {
+ vm.$emit('select', selected);
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
index eecb3573046..befbca48736 100644
--- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
+++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
@@ -1,8 +1,16 @@
<script>
-import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlModalDirective,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
export default {
@@ -13,6 +21,8 @@ export default {
GlFormInput,
GlModal,
ClipboardButton,
+ GlSprintf,
+ GlLink,
},
directives: {
'gl-modal': GlModalDirective,
@@ -44,16 +54,6 @@ export default {
data() {
return {
authorizationKey: this.initialAuthorizationKey,
- sectionDescription: sprintf(
- __(
- 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.',
- ),
- {
- linkStart: `<a href="${this.learnMoreUrl}" target="_blank" rel="noopener noreferrer">`,
- linkEnd: '</a>',
- },
- false,
- ),
};
},
methods: {
@@ -84,7 +84,17 @@ export default {
</p>
</div>
<div class="col-lg-9">
- <p v-html="sectionDescription /* eslint-disable-line vue/no-v-html */"></p>
+ <gl-sprintf
+ :message="
+ __(
+ 'To receive alerts from manually configured Prometheus services, add the following URL and Authorization key to your Prometheus webhook config file. Learn more about %{linkStart}configuring Prometheus%{linkEnd} to send alerts to GitLab.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="learnMoreUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
<gl-form-group :label="__('URL')" label-for="notify-url" label-class="label-bold">
<div class="input-group">
<gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" />
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index 45eb2ce51e4..0556fd298aa 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -1,5 +1,12 @@
<script>
-import { GlFormCheckbox, GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui';
+import {
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlSprintf,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { n__ } from '~/locale';
@@ -11,22 +18,22 @@ import {
REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL,
CREATED_AT_LABEL,
- REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP,
NOT_AVAILABLE_TEXT,
NOT_AVAILABLE_SIZE,
+ MORE_ACTIONS_TEXT,
} from '../../constants/index';
-import DeleteButton from '../delete_button.vue';
export default {
components: {
GlSprintf,
GlFormCheckbox,
GlIcon,
- DeleteButton,
+ GlDropdown,
+ GlDropdownItem,
ListItem,
ClipboardButton,
TimeAgoTooltip,
@@ -60,11 +67,11 @@ export default {
REMOVE_TAG_BUTTON_TITLE,
DIGEST_LABEL,
CREATED_AT_LABEL,
- REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP,
+ MORE_ACTIONS_TEXT,
},
computed: {
formattedSize() {
@@ -173,15 +180,27 @@ export default {
</span>
</template>
<template #right-action>
- <delete-button
+ <gl-dropdown
:disabled="isDeleteDisabled"
- :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
- :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
- :tooltip-disabled="tag.canDelete"
- data-qa-selector="tag_delete_button"
- data-testid="single-delete-button"
- @delete="$emit('delete')"
- />
+ icon="ellipsis_v"
+ :text="$options.i18n.MORE_ACTIONS_TEXT"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ right
+ :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
+ data-testid="additional-actions"
+ data-qa-selector="more_actions_menu"
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-testid="single-delete-button"
+ data-qa-selector="tag_delete_button"
+ @click="$emit('delete')"
+ >
+ {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template v-if="!isInvalidTag" #details-published>
diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js
index dc71ef8450b..f7beec2c935 100644
--- a/app/assets/javascripts/registry/explorer/constants/common.js
+++ b/app/assets/javascripts/registry/explorer/constants/common.js
@@ -1,3 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
+export const MORE_ACTIONS_TEXT = __('More actions');
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 0836260b71e..19e1a75fb2f 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -30,7 +30,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__(
'ContainerRegistry|Configuration digest: %{digest}',
);
-export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
+export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Delete tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
@@ -61,10 +61,6 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
);
-export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__(
- 'ContainerRegistry|Deletion disabled due to missing or insufficient permissions.',
-);
-
export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest',
);
diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js
index f59b9d7a9f5..d21a154d1b8 100644
--- a/app/assets/javascripts/registry/explorer/constants/list.js
+++ b/app/assets/javascripts/registry/explorer/constants/list.js
@@ -5,7 +5,7 @@ import { s__, __ } from '~/locale';
export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
- `ContainerRegistry|We are having trouble connecting to the Registry, which could be due to an issue with your project name or path. %{docLinkStart}More information%{docLinkEnd}`,
+ `ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
);
export const LIST_INTRO_TEXT = s__(
`ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
index 1f82fd7f238..246a6768593 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -36,6 +36,8 @@ export default () => {
isAdmin,
showCleanupPolicyOnAlert,
showUnfinishedTagCleanupCallout,
+ connectionError,
+ invalidPathError,
...config
} = el.dataset;
@@ -67,6 +69,8 @@ export default () => {
isAdmin: parseBoolean(isAdmin),
showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert),
showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout),
+ connectionError: parseBoolean(connectionError),
+ invalidPathError: parseBoolean(invalidPathError),
},
/* eslint-disable @gitlab/require-i18n-strings */
dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`,
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 3c8790fa6e5..73b957f42f2 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -171,6 +171,9 @@ export default {
showDeleteAlert() {
return this.deleteAlertType && this.itemToDelete?.path;
},
+ showConnectionError() {
+ return this.config.connectionError || this.config.invalidPathError;
+ },
deleteImageAlertMessage() {
return this.deleteAlertType === 'success'
? DELETE_IMAGE_SUCCESS_MESSAGE
@@ -292,7 +295,7 @@ export default {
/>
<gl-empty-state
- v-if="config.characterError"
+ v-if="showConnectionError"
:title="$options.i18n.CONNECTION_ERROR_TITLE"
:svg-path="config.containersErrorImage"
>
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index 02929062cee..f936c03c5d3 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -74,6 +74,16 @@ export default {
required: false,
default: false,
},
+ autoCompleteEpics: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ autoCompleteIssues: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -177,7 +187,7 @@ export default {
:path-id-separator="pathIdSeparator"
:input-value="inputValue"
:auto-complete-sources="transformedAutocompleteSources"
- :auto-complete-options="{ issues: true, epics: true }"
+ :auto-complete-options="{ issues: autoCompleteIssues, epics: autoCompleteEpics }"
:issuable-type="issuableType"
@pendingIssuableRemoveRequest="onPendingIssuableRemoveRequest"
@formCancel="onFormCancel"
@@ -187,15 +197,15 @@ export default {
<p v-if="hasError" class="gl-field-error">
{{ addRelatedErrorMessage }}
</p>
- <div class="add-issuable-form-actions clearfix">
+ <div class="gl-mt-5 gl-clearfix">
<gl-button
ref="addButton"
category="primary"
- variant="success"
+ variant="confirm"
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="js-add-issuable-form-add-button float-left"
+ class="float-left"
data-qa-selector="add_issue_button"
>
{{ __('Add') }}
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index 9665ed173b9..abbd612d3ec 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -48,7 +48,7 @@ export default {
<template>
<div
:class="{
- 'issue-token': isCondensed,
+ 'issue-token gl-display-inline-flex gl-align-items-stretch gl-max-w-full gl-line-height-24 gl-white-space-nowrap': isCondensed,
'flex-row issuable-info-container': !isCondensed,
}"
>
@@ -57,7 +57,7 @@ export default {
ref="link"
v-gl-tooltip
:class="{
- 'issue-token-link': isCondensed,
+ 'issue-token-link gl-display-inline-flex gl-min-w-0 gl-text-gray-500': isCondensed,
'issuable-main-info': !isCondensed,
}"
:href="computedPath"
@@ -69,19 +69,19 @@ export default {
v-if="hasTitle"
ref="title"
:class="{
- 'issue-token-title issue-token-end': isCondensed,
+ 'issue-token-title issue-token-end gl-overflow-hidden gl-display-flex gl-align-items-baseline gl-text-gray-500 gl-pl-3': isCondensed,
'issue-title block-truncated': !isCondensed,
- 'issue-token-title-standalone': !canRemove,
+ 'gl-rounded-top-right-small gl-rounded-bottom-right-small gl-pr-3': !canRemove,
}"
class="js-issue-token-title"
>
- <span class="issue-token-title-text">{{ title }}</span>
+ <span class="gl-text-truncate">{{ title }}</span>
</component>
<component
:is="innerComponentType"
ref="reference"
:class="{
- 'issue-token-reference': isCondensed,
+ 'issue-token-reference gl-display-flex gl-align-items-center gl-rounded-top-left-small gl-rounded-bottom-left-small gl-px-3': isCondensed,
'issuable-info': !isCondensed,
}"
>
@@ -103,7 +103,7 @@ export default {
ref="removeButton"
v-gl-tooltip
:class="{
- 'issue-token-remove-button': isCondensed,
+ 'issue-token-remove-button gl-display-flex gl-align-items-center gl-px-3 gl-border-0 gl-rounded-top-right-small gl-rounded-bottom-right-small gl-text-gray-500': isCondensed,
'btn btn-default': !isCondensed,
}"
:title="removeButtonLabel"
@@ -111,7 +111,6 @@ export default {
:disabled="removeDisabled"
data-testid="removeBtn"
type="button"
- class="js-issue-token-remove-button"
@click="onRemoveRequest"
>
<gl-icon name="close" />
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 46b97370d66..270d4632a54 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -107,9 +107,6 @@ export default {
onAutoCompleteToggled(isOpen) {
this.isAutoCompleteOpen = isOpen;
},
- onInputWrapperClick() {
- this.$refs.input.focus();
- },
onInput() {
const { value } = this.$refs.input;
const caretPos = this.$refs.input.selectionStart;
@@ -185,26 +182,23 @@ export default {
<div
ref="issuableFormWrapper"
:class="{ focus: isInputFocused }"
- class="add-issuable-form-input-wrapper form-control gl-field-error-outline"
+ class="add-issuable-form-input-wrapper form-control gl-field-error-outline gl-h-auto gl-p-3 gl-pb-2"
role="button"
@click="onIssuableFormWrapperClick"
>
- <ul class="add-issuable-form-input-token-list">
- <!--
- We need to ensure this key changes any time the pendingReferences array is updated
- else two consecutive pending ref strings in an array with the same name will collide
- and cause odd behavior when one is removed.
- -->
+ <ul
+ class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-list-style-none gl-m-0 gl-p-0"
+ >
<li
v-for="(reference, index) in references"
- :key="`related-issues-token-${reference}`"
- class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item"
+ :key="reference"
+ class="gl-max-w-full gl-mb-2 gl-mr-2"
>
<issue-token
:id-key="index"
:display-reference="reference.text || reference"
- :can-remove="true"
- :is-condensed="true"
+ can-remove
+ is-condensed
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
@pendingIssuableRemoveRequest="
@@ -214,14 +208,15 @@ export default {
"
/>
</li>
- <li class="add-issuable-form-input-list-item">
+ <li class="gl-mb-2 gl-flex-grow-1">
<input
:id="inputId"
ref="input"
:value="inputValue"
:placeholder="inputPlaceholder"
+ :aria-label="inputPlaceholder"
type="text"
- class="js-add-issuable-form-input add-issuable-form-input"
+ class="gl-w-full gl-border-none gl-outline-0"
data-qa-selector="add_issue_field"
autocomplete="off"
@input="onInput"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index c042f0eef5f..94535e1b8c9 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -123,7 +123,7 @@ export default {
</script>
<template>
- <div id="related-issues" class="related-issues-block">
+ <div id="related-issues" class="related-issues-block gl-mt-5">
<div class="card card-slim gl-overflow-hidden">
<div
:class="{ 'panel-empty-heading border-bottom-0': !hasBody }"
@@ -162,7 +162,6 @@ export default {
icon="plus"
:aria-label="__('Add a related issue')"
:class="qaClass"
- class="js-issue-count-badge-add-button"
@click="$emit('toggleAddRelatedIssuesForm', $event)"
/>
</div>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 8f486fb1b07..a21e294a34a 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -97,11 +97,7 @@ export default {
class="related-issues-token-body bordered-box bg-white"
:class="{ 'sortable-container': canReorder }"
>
- <div
- v-if="isFetching"
- class="related-issues-loading-icon"
- data-qa-selector="related_issues_loading_placeholder"
- >
+ <div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder">
<gl-loading-icon
ref="loadingIcon"
size="sm"
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index 6fb1d1ed365..05858c7469d 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -81,13 +81,13 @@ export default {
{{ __('Related merge requests') }}
</span>
<div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
- <div class="mr-count-badge gl-display-inline-flex">
- <div class="mr-count-badge-count">
- <svg class="s16 mr-1 text-secondary">
- <gl-icon name="merge-request" class="mr-1 text-secondary" />
- </svg>
- <span class="js-items-count">{{ totalCount }}</span>
- </div>
+ <div
+ class="mr-count-badge gl-display-inline-flex gl-align-items-center gl-py-2 gl-px-3"
+ >
+ <svg class="s16 mr-1 text-secondary">
+ <gl-icon name="merge-request" class="mr-1 text-secondary" />
+ </svg>
+ <span class="js-items-count">{{ totalCount }}</span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 3201ca1f443..b2bd405574f 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -1,4 +1,5 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import { isEmpty } from 'lodash';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -21,6 +22,9 @@ export default {
ReleaseBlockHeader,
ReleaseBlockMilestoneInfo,
},
+ directives: {
+ SafeHtml,
+ },
mixins: [glFeatureFlagsMixin()],
props: {
release: {
@@ -79,6 +83,7 @@ export default {
$(this.$refs['gfm-content']).renderGFM();
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
@@ -102,10 +107,7 @@ export default {
<evidence-block v-if="hasEvidence" :release="release" />
<div ref="gfm-content" class="card-text gl-mt-3">
- <div
- class="md"
- v-html="release.descriptionHtml /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div v-safe-html:[$options.safeHtmlConfig]="release.descriptionHtml" class="md"></div>
</div>
</div>
diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js
index 7272880197a..686f9e294b7 100644
--- a/app/assets/javascripts/releases/mount_show.js
+++ b/app/assets/javascripts/releases/mount_show.js
@@ -6,7 +6,12 @@ import ReleaseShowApp from './components/app_show.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
export default () => {
diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
index 736c8668a34..59bd54eab60 100644
--- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
+++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
@@ -33,17 +33,20 @@ export default {
issueName() {
return `${this.severityLabel} - ${this.issue.name}`;
},
+ issueSeverity() {
+ return this.issue.severity.toLowerCase();
+ },
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
severityClass() {
- return SEVERITY_CLASSES[this.issue.severity] || SEVERITY_CLASSES.unknown;
+ return SEVERITY_CLASSES[this.issueSeverity] || SEVERITY_CLASSES.unknown;
},
severityIcon() {
- return SEVERITY_ICONS[this.issue.severity] || SEVERITY_ICONS.unknown;
+ return SEVERITY_ICONS[this.issueSeverity] || SEVERITY_ICONS.unknown;
},
severityLabel() {
- return this.$options.severityText[this.issue.severity] || this.$options.severityText.unknown;
+ return this.$options.severityText[this.issueSeverity] || this.$options.severityText.unknown;
},
},
severityText: {
diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
index 0e18d0992cd..599e8d35708 100644
--- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
+++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue
@@ -55,10 +55,12 @@ export default {
...mapActions(['fetchReports', 'setPaths']),
},
loadingText: sprintf(s__('ciReport|Loading %{reportName} report'), {
- reportName: 'codeclimate',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ reportName: 'Code quality',
}),
errorText: sprintf(s__('ciReport|Failed to load %{reportName} report'), {
- reportName: 'codeclimate',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ reportName: 'Code quality',
}),
};
</script>
diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js
index 3fb8c5be351..4712f8cbefe 100644
--- a/app/assets/javascripts/reports/codequality_report/store/getters.js
+++ b/app/assets/javascripts/reports/codequality_report/store/getters.js
@@ -1,5 +1,5 @@
import { spriteIcon } from '~/lib/utils/common_utils';
-import { sprintf, __, s__, n__ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '../../constants';
export const hasCodequalityIssues = (state) =>
@@ -18,27 +18,23 @@ export const codequalityStatus = (state) => {
export const codequalityText = (state) => {
const { newIssues, resolvedIssues } = state;
- const text = [];
-
+ let text;
if (!newIssues.length && !resolvedIssues.length) {
- text.push(s__('ciReport|No changes to code quality'));
- } else {
- text.push(s__('ciReport|Code quality'));
-
- if (resolvedIssues.length) {
- text.push(n__(' improved on %d point', ' improved on %d points', resolvedIssues.length));
- }
-
- if (newIssues.length && resolvedIssues.length) {
- text.push(__(' and'));
- }
-
- if (newIssues.length) {
- text.push(n__(' degraded on %d point', ' degraded on %d points', newIssues.length));
- }
+ text = s__('ciReport|No changes to code quality');
+ } else if (newIssues.length && resolvedIssues.length) {
+ text = sprintf(
+ s__(`ciReport|Code quality scanning detected %{issueCount} changes in merged results`),
+ {
+ issueCount: newIssues.length + resolvedIssues.length,
+ },
+ );
+ } else if (resolvedIssues.length) {
+ text = s__(`ciReport|Code quality improved`);
+ } else if (newIssues.length) {
+ text = s__(`ciReport|Code quality degraded`);
}
- return text.join('');
+ return text;
};
export const codequalityPopover = (state) => {
diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
index a794f5f0577..417297df43c 100644
--- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
+++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
@@ -1,14 +1,16 @@
-export const parseCodeclimateMetrics = (issues = [], path = '') => {
+export const parseCodeclimateMetrics = (issues = [], blobPath = '') => {
return issues.map((issue) => {
+ // the `file_path` attribute from the artifact is returned as `file` by GraphQL
+ const issuePath = issue.file_path || issue.path;
const parsedIssue = {
name: issue.description,
- path: issue.file_path,
- urlPath: `${path}/${issue.file_path}#L${issue.line}`,
+ path: issuePath,
+ urlPath: `${blobPath}/${issuePath}#L${issue.line}`,
...issue,
};
if (issue?.location?.path) {
- let parseCodeQualityUrl = `${path}/${issue.location.path}`;
+ let parseCodeQualityUrl = `${blobPath}/${issue.location.path}`;
parsedIssue.path = issue.location.path;
if (issue?.location?.lines?.begin) {
diff --git a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
index 82806793401..be49a03a9a5 100644
--- a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue
@@ -3,7 +3,6 @@ import { GlButton, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import api from '~/api';
import { sprintf, s__ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GroupedIssuesList from '../components/grouped_issues_list.vue';
import { componentNames } from '../components/issue_body';
import ReportSection from '../components/report_section.vue';
@@ -28,7 +27,6 @@ export default {
GlButton,
GlIcon,
},
- mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
@@ -82,9 +80,7 @@ export default {
methods: {
...mapActions(['setPaths', 'fetchReports', 'closeModal']),
handleToggleEvent() {
- if (this.glFeatures.usageDataITestingSummaryWidgetTotal) {
- api.trackRedisHllUserEvent(this.$options.expandEvent);
- }
+ api.trackRedisHllUserEvent(this.$options.expandEvent);
},
reportText(report) {
const { name, summary } = report || {};
diff --git a/app/assets/javascripts/repository/commits_service.js b/app/assets/javascripts/repository/commits_service.js
new file mode 100644
index 00000000000..504efaea8cc
--- /dev/null
+++ b/app/assets/javascripts/repository/commits_service.js
@@ -0,0 +1,65 @@
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { normalizeData } from 'ee_else_ce/repository/utils/commit';
+import createFlash from '~/flash';
+import { COMMIT_BATCH_SIZE, I18N_COMMIT_DATA_FETCH_ERROR } from './constants';
+
+let requestedOffsets = [];
+let fetchedBatches = [];
+
+export const isRequested = (offset) => requestedOffsets.includes(offset);
+
+export const resetRequestedCommits = () => {
+ requestedOffsets = [];
+ fetchedBatches = [];
+};
+
+const addRequestedOffset = (offset) => {
+ if (isRequested(offset) || offset < 0) {
+ return;
+ }
+
+ requestedOffsets.push(offset);
+};
+
+const removeLeadingSlash = (path) => path.replace(/^\//, '');
+
+const fetchData = (projectPath, path, ref, offset) => {
+ if (fetchedBatches.includes(offset) || offset < 0) {
+ return [];
+ }
+
+ fetchedBatches.push(offset);
+
+ const url = joinPaths(
+ gon.relative_url_root || '/',
+ projectPath,
+ '/-/refs/',
+ ref,
+ '/logs_tree/',
+ encodeURIComponent(removeLeadingSlash(path)),
+ );
+
+ return axios
+ .get(url, { params: { format: 'json', offset } })
+ .then(({ data }) => normalizeData(data, path))
+ .catch(() => createFlash({ message: I18N_COMMIT_DATA_FETCH_ERROR }));
+};
+
+export const loadCommits = async (projectPath, path, ref, offset) => {
+ if (isRequested(offset)) {
+ return [];
+ }
+
+ // We fetch in batches of 25, so this ensures we don't refetch
+ Array.from(Array(COMMIT_BATCH_SIZE)).forEach((_, i) => {
+ addRequestedOffset(offset - i);
+ addRequestedOffset(offset + i);
+ });
+
+ // Since a user could scroll either up or down, we want to support lazy loading in both directions
+ const commitsBatchUp = await fetchData(projectPath, path, ref, offset - COMMIT_BATCH_SIZE);
+ const commitsBatchDown = await fetchData(projectPath, path, ref, offset);
+
+ return commitsBatchUp.concat(commitsBatchDown);
+};
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 1d79818cbe8..7ad9fb56972 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -8,10 +8,12 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import { redirectTo } from '~/lib/utils/url_utility';
import getRefMixin from '../mixins/get_ref';
import blobInfoQuery from '../queries/blob_info.query.graphql';
import BlobButtonGroup from './blob_button_group.vue';
import BlobEdit from './blob_edit.vue';
+import ForkSuggestion from './fork_suggestion.vue';
import { loadViewer, viewerProps } from './blob_viewers';
export default {
@@ -21,6 +23,7 @@ export default {
BlobButtonGroup,
BlobContent,
GlLoadingIcon,
+ ForkSuggestion,
},
mixins: [getRefMixin],
inject: {
@@ -42,9 +45,6 @@ export default {
this.switchViewer(
this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
);
- if (this.hasRichViewer && !this.blobViewer) {
- this.loadLegacyViewer();
- }
},
error() {
this.displayError();
@@ -68,7 +68,9 @@ export default {
},
data() {
return {
+ forkTarget: null,
legacyRichViewer: null,
+ legacySimpleViewer: null,
isBinary: false,
isLoadingLegacyViewer: false,
activeViewerType: SIMPLE_BLOB_VIEWER,
@@ -76,6 +78,8 @@ export default {
userPermissions: {
pushCode: false,
downloadCode: false,
+ createMergeRequestIn: false,
+ forkProject: false,
},
pathLocks: {
nodes: [],
@@ -94,12 +98,14 @@ export default {
path: '',
editBlobPath: '',
ideEditPath: '',
+ forkAndEditPath: '',
+ ideForkAndEditPath: '',
storedExternally: false,
+ canModifyBlob: false,
rawPath: '',
externalStorageUrl: '',
replacePath: '',
deletePath: '',
- forkPath: '',
simpleViewer: {},
richViewer: null,
webPath: '',
@@ -115,7 +121,7 @@ export default {
return isLoggedIn();
},
isLoading() {
- return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer;
+ return this.$apollo.queries.project.loading;
},
isBinaryFileType() {
return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text';
@@ -151,24 +157,66 @@ export default {
isLocked() {
return this.project.pathLocks.nodes.some((node) => node.path === this.path);
},
+ showForkSuggestion() {
+ const { createMergeRequestIn, forkProject } = this.project.userPermissions;
+ const { canModifyBlob } = this.blobInfo;
+
+ return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
+ },
+ forkPath() {
+ return this.forkTarget === 'ide'
+ ? this.blobInfo.ideForkAndEditPath
+ : this.blobInfo.forkAndEditPath;
+ },
},
methods: {
- loadLegacyViewer() {
+ loadLegacyViewer(type) {
+ if (this.legacyViewerLoaded(type)) {
+ return;
+ }
+
this.isLoadingLegacyViewer = true;
axios
- .get(`${this.blobInfo.webPath}?format=json&viewer=rich`)
+ .get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(({ data: { html, binary } }) => {
- this.legacyRichViewer = html;
+ if (type === 'simple') {
+ this.legacySimpleViewer = html;
+ } else {
+ this.legacyRichViewer = html;
+ }
+
this.isBinary = binary;
this.isLoadingLegacyViewer = false;
})
.catch(() => this.displayError());
},
+ legacyViewerLoaded(type) {
+ return (
+ (type === SIMPLE_BLOB_VIEWER && this.legacySimpleViewer) ||
+ (type === RICH_BLOB_VIEWER && this.legacyRichViewer)
+ );
+ },
displayError() {
createFlash({ message: __('An error occurred while loading the file. Please try again.') });
},
switchViewer(newViewer) {
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
+
+ if (!this.blobViewer) {
+ this.loadLegacyViewer(this.activeViewerType);
+ }
+ },
+ editBlob(target) {
+ if (this.showForkSuggestion) {
+ this.setForkTarget(target);
+ return;
+ }
+
+ const { ideEditPath, editBlobPath } = this.blobInfo;
+ redirectTo(target === 'ide' ? ideEditPath : editBlobPath);
+ },
+ setForkTarget(target) {
+ this.forkTarget = target;
},
},
};
@@ -191,6 +239,8 @@ export default {
:show-edit-button="!isBinaryFileType"
:edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath"
+ :needs-to-fork="showForkSuggestion"
+ @edit="editBlob"
/>
<blob-button-group
v-if="isLoggedIn"
@@ -206,14 +256,20 @@ export default {
/>
</template>
</blob-header>
+ <fork-suggestion
+ v-if="forkTarget && showForkSuggestion"
+ :fork-path="forkPath"
+ @cancel="setForkTarget(null)"
+ />
<blob-content
v-if="!blobViewer"
:rich-viewer="legacyRichViewer"
:blob="blobInfo"
- :content="blobInfo.rawTextBlob"
+ :content="legacySimpleViewer"
:is-raw-content="true"
:active-viewer="viewer"
- :loading="false"
+ :hide-line-numbers="true"
+ :loading="isLoadingLegacyViewer"
/>
<component :is="blobViewer" v-else v-bind="viewerProps" class="blob-viewer" />
</div>
diff --git a/app/assets/javascripts/repository/components/blob_edit.vue b/app/assets/javascripts/repository/components/blob_edit.vue
index 30ed4cd57f1..fd377ba1b81 100644
--- a/app/assets/javascripts/repository/components/blob_edit.vue
+++ b/app/assets/javascripts/repository/components/blob_edit.vue
@@ -27,6 +27,16 @@ export default {
type: String,
required: true,
},
+ needsToFork: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ onEdit(target) {
+ this.$emit('edit', target);
+ },
},
};
</script>
@@ -38,7 +48,9 @@ export default {
class="gl-mr-3"
:edit-url="editPath"
:web-ide-url="webIdePath"
+ :needs-to-fork="needsToFork"
:is-blob="true"
+ @edit="onEdit"
/>
<div v-else>
<gl-button
@@ -46,8 +58,8 @@ export default {
class="gl-mr-2"
category="primary"
variant="confirm"
- :href="editPath"
data-testid="edit"
+ @click="onEdit('simple')"
>
{{ $options.i18n.edit }}
</gl-button>
@@ -56,8 +68,8 @@ export default {
class="gl-mr-3"
category="primary"
variant="confirm"
- :href="webIdePath"
data-testid="web-ide"
+ @click="onEdit('ide')"
>
{{ $options.i18n.webIde }}
</gl-button>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 3b4f4eb51fe..c5209d97abb 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -3,11 +3,15 @@ export const loadViewer = (type) => {
case 'empty':
return () => import(/* webpackChunkName: 'blob_empty_viewer' */ './empty_viewer.vue');
case 'text':
- return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue');
+ return gon.features.refactorTextViewer
+ ? () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue')
+ : null;
case 'download':
return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
case 'image':
return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
+ case 'video':
+ return () => import(/* webpackChunkName: 'blob_video_viewer' */ './video_viewer.vue');
default:
return null;
}
@@ -29,5 +33,8 @@ export const viewerProps = (type, blob) => {
url: blob.rawPath,
alt: blob.name,
},
+ video: {
+ url: blob.rawPath,
+ },
}[type];
};
diff --git a/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
new file mode 100644
index 00000000000..dec0c4802ca
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/video_viewer.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-text-center gl-p-7 gl-bg-gray-50">
+ <video :src="url" controls data-testid="video" class="gl-max-w-full"></video>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index db84e2b5912..d3717f10ec7 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -9,11 +9,13 @@ import {
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue';
+import NewDirectoryModal from './new_directory_modal.vue';
const ROW_TYPES = {
header: 'header',
@@ -21,6 +23,7 @@ const ROW_TYPES = {
};
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
+const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory';
export default {
components: {
@@ -30,6 +33,7 @@ export default {
GlDropdownItem,
GlIcon,
UploadBlobModal,
+ NewDirectoryModal,
},
apollo: {
projectShortPath: {
@@ -54,7 +58,7 @@ export default {
directives: {
GlModal: GlModalDirective,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagsMixin()],
props: {
currentPath: {
type: String,
@@ -121,8 +125,14 @@ export default {
required: false,
default: '',
},
+ newDirPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
+ newDirectoryModalId: NEW_DIRECTORY_MODAL_ID,
data() {
return {
projectShortPath: '',
@@ -160,6 +170,13 @@ export default {
showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
+ showNewDirectoryModal() {
+ return (
+ this.glFeatures.newDirModal &&
+ this.canEditTree &&
+ !this.$apollo.queries.userPermissions.loading
+ );
+ },
dropdownItems() {
const items = [];
@@ -185,15 +202,26 @@ export default {
text: __('Upload file'),
modalId: UPLOAD_BLOB_MODAL_ID,
},
- {
+ );
+
+ if (this.glFeatures.newDirModal) {
+ items.push({
+ attrs: {
+ href: '#modal-create-new-dir',
+ },
+ text: __('New directory'),
+ modalId: NEW_DIRECTORY_MODAL_ID,
+ });
+ } else {
+ items.push({
attrs: {
href: '#modal-create-new-dir',
'data-target': '#modal-create-new-dir',
'data-toggle': 'modal',
},
text: __('New directory'),
- },
- );
+ });
+ }
} else if (this.canCreateMrFromFork) {
items.push(
{
@@ -306,5 +334,14 @@ export default {
:can-push-code="canPushCode"
:path="uploadPath"
/>
+ <new-directory-modal
+ v-if="showNewDirectoryModal"
+ :can-push-code="canPushCode"
+ :modal-id="$options.newDirectoryModalId"
+ :commit-message="__('Add new directory')"
+ :target-branch="selectedBranch"
+ :original-branch="originalBranch"
+ :path="newDirPath"
+ />
</nav>
</template>
diff --git a/app/assets/javascripts/repository/components/fork_suggestion.vue b/app/assets/javascripts/repository/components/fork_suggestion.vue
new file mode 100644
index 00000000000..c266bea319b
--- /dev/null
+++ b/app/assets/javascripts/repository/components/fork_suggestion.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ message: __(
+ 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.',
+ ),
+ fork: __('Fork'),
+ cancel: __('Cancel'),
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ forkPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-end gl-align-items-center gl-bg-gray-10 gl-px-5 gl-py-2 gl-border-1 gl-border-b-solid gl-border-gray-100"
+ >
+ <span class="gl-mr-6" data-testid="message">{{ $options.i18n.message }}</span>
+
+ <gl-button
+ class="gl-mr-3"
+ category="secondary"
+ variant="confirm"
+ :href="forkPath"
+ data-testid="fork"
+ >
+ {{ $options.i18n.fork }}
+ </gl-button>
+
+ <gl-button data-testid="cancel" @click="$emit('cancel')">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue
new file mode 100644
index 00000000000..6c5797bf5b2
--- /dev/null
+++ b/app/assets/javascripts/repository/components/new_directory_modal.vue
@@ -0,0 +1,183 @@
+<script>
+import {
+ GlAlert,
+ GlForm,
+ GlModal,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import {
+ SECONDARY_OPTIONS_TEXT,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ NEW_BRANCH_IN_FORK,
+} from '../constants';
+
+const MODAL_TITLE = __('Create New Directory');
+const PRIMARY_OPTIONS_TEXT = __('Create directory');
+const DIR_LABEL = __('Directory name');
+const ERROR_MESSAGE = __('Error creating new directory. Please try again.');
+
+export default {
+ components: {
+ GlAlert,
+ GlModal,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlToggle,
+ },
+ i18n: {
+ DIR_LABEL,
+ COMMIT_LABEL,
+ TARGET_BRANCH_LABEL,
+ TOGGLE_CREATE_MR_LABEL,
+ NEW_BRANCH_IN_FORK,
+ PRIMARY_OPTIONS_TEXT,
+ ERROR_MESSAGE,
+ },
+ props: {
+ modalTitle: {
+ type: String,
+ default: MODAL_TITLE,
+ required: false,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ primaryBtnText: {
+ type: String,
+ default: PRIMARY_OPTIONS_TEXT,
+ required: false,
+ },
+ commitMessage: {
+ type: String,
+ required: true,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ originalBranch: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ canPushCode: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dir: null,
+ commit: this.commitMessage,
+ target: this.targetBranch,
+ createNewMr: true,
+ loading: false,
+ };
+ },
+ computed: {
+ primaryOptions() {
+ return {
+ text: this.primaryBtnText,
+ attributes: [
+ {
+ variant: 'confirm',
+ loading: this.loading,
+ disabled: !this.formCompleted || this.loading,
+ },
+ ],
+ };
+ },
+ cancelOptions() {
+ return {
+ text: SECONDARY_OPTIONS_TEXT,
+ attributes: [
+ {
+ disabled: this.loading,
+ },
+ ],
+ };
+ },
+ showCreateNewMrToggle() {
+ return this.canPushCode;
+ },
+ formCompleted() {
+ return this.dir && this.commit && this.target;
+ },
+ },
+ methods: {
+ submitForm() {
+ this.loading = true;
+
+ const formData = new FormData();
+ formData.append('dir_name', this.dir);
+ formData.append('commit_message', this.commit);
+ formData.append('branch_name', this.target);
+ formData.append('original_branch', this.originalBranch);
+
+ if (this.createNewMr) {
+ formData.append('create_merge_request', this.createNewMr);
+ }
+
+ return axios
+ .post(this.path, formData)
+ .then((response) => {
+ visitUrl(response.data.filePath);
+ })
+ .catch(() => {
+ this.loading = false;
+ createFlash({ message: ERROR_MESSAGE });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form>
+ <gl-modal
+ :modal-id="modalId"
+ :title="modalTitle"
+ :action-primary="primaryOptions"
+ :action-cancel="cancelOptions"
+ @primary.prevent="submitForm"
+ >
+ <gl-form-group :label="$options.i18n.DIR_LABEL" label-for="dir_name">
+ <gl-form-input v-model="dir" :disabled="loading" name="dir_name" />
+ </gl-form-group>
+ <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
+ <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
+ </gl-form-group>
+ <gl-form-group
+ v-if="canPushCode"
+ :label="$options.i18n.TARGET_BRANCH_LABEL"
+ label-for="branch_name"
+ >
+ <gl-form-input v-model="target" :disabled="loading" name="branch_name" />
+ </gl-form-group>
+ <gl-toggle
+ v-if="showCreateNewMrToggle"
+ v-model="createNewMr"
+ :disabled="loading"
+ :label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
+ />
+ <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
+ {{ $options.i18n.NEW_BRANCH_IN_FORK }}
+ </gl-alert>
+ </gl-modal>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 54e67c5ab5c..c6e461b10e0 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlIcon, GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { handleLocationHash } from '~/lib/utils/common_utils';
@@ -22,6 +22,9 @@ export default {
GlLink,
GlLoadingIcon,
},
+ directives: {
+ SafeHtml,
+ },
props: {
blob: {
type: Object,
@@ -59,11 +62,7 @@ export default {
</div>
<div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about">
<gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" />
- <div
- v-else-if="readme"
- ref="readme"
- v-html="readme.html /* eslint-disable-line vue/no-v-html */"
- ></div>
+ <div v-else-if="readme" ref="readme" v-safe-html="readme.html"></div>
</div>
</article>
</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 10a30bd44b1..0a2ed753e38 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,5 +1,6 @@
<script>
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql';
@@ -15,13 +16,18 @@ export default {
ParentRow,
GlButton,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
apollo: {
projectPath: {
query: projectPathQuery,
},
},
props: {
+ commits: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
path: {
type: String,
required: true,
@@ -48,6 +54,7 @@ export default {
data() {
return {
projectPath: '',
+ rowNumbers: {},
};
},
computed: {
@@ -73,10 +80,38 @@ export default {
return ['', '/'].indexOf(this.path) === -1;
},
},
+ watch: {
+ $route: function routeChange() {
+ this.$options.totalRowsLoaded = -1;
+ },
+ },
+ totalRowsLoaded: -1,
methods: {
showMore() {
this.$emit('showMore');
},
+ generateRowNumber(path, id, index) {
+ const key = `${path}-${id}-${index}`;
+ if (!this.glFeatures.lazyLoadCommits) {
+ return 0;
+ }
+
+ if (!this.rowNumbers[key] && this.rowNumbers[key] !== 0) {
+ this.$options.totalRowsLoaded += 1;
+ this.rowNumbers[key] = this.$options.totalRowsLoaded;
+ }
+
+ return this.rowNumbers[key];
+ },
+ getCommit(fileName, type) {
+ if (!this.glFeatures.lazyLoadCommits) {
+ return {};
+ }
+
+ return this.commits.find(
+ (commitEntry) => commitEntry.fileName === fileName && commitEntry.type === type,
+ );
+ },
},
};
</script>
@@ -87,6 +122,7 @@ export default {
<table
:aria-label="tableCaption"
class="table tree-table"
+ :class="{ 'gl-table-layout-fixed': !showParentRow }"
aria-live="polite"
data-qa-selector="file_tree_table"
>
@@ -115,12 +151,17 @@ export default {
:lfs-oid="entry.lfsOid"
:loading-path="loadingPath"
:total-entries="totalEntries"
+ :row-number="generateRowNumber(entry.flatPath, entry.id, index)"
+ :commit-info="getCommit(entry.name, entry.type)"
+ v-on="$listeners"
/>
</template>
<template v-if="isLoading">
<tr v-for="i in 5" :key="i" aria-hidden="true">
<td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
- <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
+ <td class="gl-display-none gl-sm-display-block">
+ <gl-skeleton-loading :lines="1" class="h-auto" />
+ </td>
<td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td>
</tr>
</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 009dd19b4a5..5010d60f374 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -7,6 +7,8 @@ import {
GlLoadingIcon,
GlIcon,
GlHoverLoadDirective,
+ GlSafeHtmlDirective,
+ GlIntersectionObserver,
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import filesQuery from 'shared_queries/repository/files.query.graphql';
@@ -29,10 +31,12 @@ export default {
GlIcon,
TimeagoTooltip,
FileIcon,
+ GlIntersectionObserver,
},
directives: {
GlTooltip: GlTooltipDirective,
GlHoverLoad: GlHoverLoadDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
apollo: {
commit: {
@@ -46,10 +50,23 @@ export default {
maxOffset: this.totalEntries,
};
},
+ skip() {
+ return this.glFeatures.lazyLoadCommits;
+ },
},
},
mixins: [getRefMixin, glFeatureFlagMixin()],
props: {
+ commitInfo: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ rowNumber: {
+ type: Number,
+ required: false,
+ default: null,
+ },
totalEntries: {
type: Number,
required: true,
@@ -111,9 +128,13 @@ export default {
data() {
return {
commit: null,
+ hasRowAppeared: false,
};
},
computed: {
+ commitData() {
+ return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit;
+ },
refactorBlobViewerEnabled() {
return this.glFeatures.refactorBlobViewer;
},
@@ -146,7 +167,10 @@ export default {
return this.sha.slice(0, 8);
},
hasLockLabel() {
- return this.commit && this.commit.lockLabel;
+ return this.commitData && this.commitData.lockLabel;
+ },
+ showSkeletonLoader() {
+ return !this.commitData && this.hasRowAppeared;
},
},
methods: {
@@ -177,7 +201,21 @@ export default {
apolloQuery(query, variables) {
this.$apollo.query({ query, variables });
},
+ rowAppeared() {
+ this.hasRowAppeared = true;
+
+ if (this.glFeatures.lazyLoadCommits) {
+ this.$emit('row-appear', {
+ rowNumber: this.rowNumber,
+ hasCommit: Boolean(this.commitInfo),
+ });
+ }
+ },
+ rowDisappeared() {
+ this.hasRowAppeared = false;
+ },
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -219,7 +257,7 @@ export default {
<gl-icon
v-if="hasLockLabel"
v-gl-tooltip
- :title="commit.lockLabel"
+ :title="commitData.lockLabel"
name="lock"
:size="12"
class="ml-1"
@@ -227,17 +265,19 @@ export default {
</td>
<td class="d-none d-sm-table-cell tree-commit cursor-default">
<gl-link
- v-if="commit"
- :href="commit.commitPath"
- :title="commit.message"
+ v-if="commitData"
+ v-safe-html:[$options.safeHtmlConfig]="commitData.titleHtml"
+ :href="commitData.commitPath"
+ :title="commitData.message"
class="str-truncated-100 tree-commit-link"
- v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */"
/>
- <gl-skeleton-loading v-else :lines="1" class="h-auto" />
+ <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared">
+ <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="h-auto" />
+ </gl-intersection-observer>
</td>
<td class="tree-time-ago text-right cursor-default">
- <timeago-tooltip v-if="commit" :time="commit.committedDate" />
- <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" />
+ <timeago-tooltip v-if="commitData" :time="commitData.committedDate" />
+ <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="ml-auto h-auto w-50" />
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 5a8ead9ae8f..16dfe3cfb14 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -8,6 +8,7 @@ import { TREE_PAGE_SIZE, TREE_INITIAL_FETCH_COUNT, TREE_PAGE_LIMIT } from '../co
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import { readmeFile } from '../utils/readme';
+import { loadCommits, isRequested, resetRequestedCommits } from '../commits_service';
import FilePreview from './preview/index.vue';
import FileTable from './table/index.vue';
@@ -36,6 +37,7 @@ export default {
},
data() {
return {
+ commits: [],
projectPath: '',
nextPageCursor: '',
pagesLoaded: 1,
@@ -81,12 +83,16 @@ export default {
this.entries.submodules = [];
this.entries.blobs = [];
this.nextPageCursor = '';
+ resetRequestedCommits();
this.fetchFiles();
},
},
mounted() {
// We need to wait for `ref` and `projectPath` to be set
- this.$nextTick(() => this.fetchFiles());
+ this.$nextTick(() => {
+ resetRequestedCommits();
+ this.fetchFiles();
+ });
},
methods: {
fetchFiles() {
@@ -152,6 +158,18 @@ export default {
.concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
.find(({ hasNextPage }) => hasNextPage);
},
+ loadCommitData({ rowNumber = 0, hasCommit } = {}) {
+ if (!this.glFeatures.lazyLoadCommits || hasCommit || isRequested(rowNumber)) {
+ return;
+ }
+
+ loadCommits(this.projectPath, this.path, this.ref, rowNumber)
+ .then(this.setCommitData)
+ .catch(() => {});
+ },
+ setCommitData(data) {
+ this.commits = this.commits.concat(data);
+ },
handleShowMore() {
this.clickedShowMore = true;
this.pagesLoaded += 1;
@@ -169,7 +187,9 @@ export default {
:is-loading="isLoadingFiles"
:loading-path="loadingPath"
:has-more="hasShowMore"
+ :commits="commits"
@showMore="handleShowMore"
+ @row-appear="loadCommitData"
/>
<file-preview v-if="readme" :blob="readme" />
</div>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index df5a5ea6163..0199b893453 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -220,6 +220,7 @@ export default {
class="gl-h-200! gl-mb-4"
single-file-selection
:valid-file-mimetypes="$options.validFileMimetypes"
+ :is-file-valid="() => true"
@change="setFile"
>
<div
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 93032bf17e2..152fabbd7cc 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -4,12 +4,19 @@ export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page
export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request
export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make
+export const COMMIT_BATCH_SIZE = 25; // we request commit data in batches of 25
+
export const SECONDARY_OPTIONS_TEXT = __('Cancel');
export const COMMIT_LABEL = __('Commit message');
export const TARGET_BRANCH_LABEL = __('Target branch');
export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
+export const NEW_BRANCH_IN_FORK = __(
+ 'A new branch will be created in your fork and a new merge request will be started.',
+);
export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52;
export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width';
+
+export const I18N_COMMIT_DATA_FETCH_ERROR = __('An error occurred while fetching commit data.');
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 60a1a0443f7..45e026ad695 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -120,6 +120,7 @@ export default function setupVueRepositoryList() {
forkNewDirectoryPath,
forkUploadBlobPath,
uploadPath,
+ newDirPath,
},
});
},
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 45f07f7dc58..8e0b5e21ca3 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -4,6 +4,8 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
userPermissions {
pushCode
downloadCode
+ createMergeRequestIn
+ forkProject
}
pathLocks {
nodes {
@@ -23,6 +25,9 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
path
editBlobPath
ideEditPath
+ forkAndEditPath
+ ideForkAndEditPath
+ canModifyBlob
storedExternally
rawPath
replacePath
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index 6637d03a7a4..0a675e14eb5 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -1,7 +1,7 @@
import { escapeRegExp } from 'lodash';
import Vue from 'vue';
import VueRouter from 'vue-router';
-import { joinPaths } from '../lib/utils/url_utility';
+import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility';
import BlobPage from './pages/blob.vue';
import IndexPage from './pages/index.vue';
import TreePage from './pages/tree.vue';
@@ -24,7 +24,7 @@ export default function createRouter(base, baseRef) {
}),
};
- return new VueRouter({
+ const router = new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [
@@ -59,4 +59,21 @@ export default function createRouter(base, baseRef) {
},
],
});
+
+ router.afterEach((to) => {
+ const needsClosingSlash = !to.name.includes('blobPath');
+ window.gl.webIDEPath = webIDEUrl(
+ joinPaths(
+ '/',
+ base,
+ 'edit',
+ decodeURI(baseRef),
+ '-',
+ to.params.path || '',
+ needsClosingSlash && '/',
+ ),
+ );
+ });
+
+ return router;
}
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 61fe89f4f7e..29642b6633f 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -2,6 +2,7 @@ export * from './api/groups_api';
export * from './api/projects_api';
export * from './api/user_api';
export * from './api/markdown_api';
+export * from './api/bulk_imports_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 23254fcc2eb..381421cdc23 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -111,7 +111,7 @@ Sidebar.prototype.toggleTodo = function (e) {
};
Sidebar.prototype.sidebarCollapseClicked = function (e) {
- if ($(e.currentTarget).hasClass('dont-change-state')) {
+ if ($(e.currentTarget).hasClass('js-dont-change-state')) {
return;
}
const sidebar = e.data;
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index fedd2519958..c8513a0b803 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
@@ -6,8 +7,8 @@ import { formatNumber, sprintf, __ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
+import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
-import RunnerTypeHelp from '../components/runner_type_help.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
@@ -23,10 +24,11 @@ import { captureException } from '../sentry_utils';
export default {
name: 'AdminRunnersApp',
components: {
+ GlLink,
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
- RunnerTypeHelp,
+ RunnerName,
RunnerPagination,
},
props: {
@@ -124,17 +126,10 @@ export default {
</script>
<template>
<div>
- <div class="row">
- <div class="col-sm-6">
- <runner-type-help />
- </div>
- <div class="col-sm-6">
- <runner-manual-setup-help
- :registration-token="registrationToken"
- :type="$options.INSTANCE_TYPE"
- />
- </div>
- </div>
+ <runner-manual-setup-help
+ :registration-token="registrationToken"
+ :type="$options.INSTANCE_TYPE"
+ />
<runner-filtered-search-bar
v-model="search"
@@ -150,7 +145,13 @@ export default {
{{ __('No runners found') }}
</div>
<template v-else>
- <runner-list :runners="runners.items" :loading="runnersLoading" />
+ <runner-list :runners="runners.items" :loading="runnersLoading">
+ <template #runner-name="{ runner }">
+ <gl-link :href="runner.adminUrl">
+ <runner-name :runner="runner" />
+ </gl-link>
+ </template>
+ </runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index 863f0ab995f..e26bdbf1aea 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerUpdateMutation from '~/runner/graphql/runner_update.mutation.graphql';
@@ -37,13 +36,6 @@ export default {
};
},
computed: {
- runnerNumericalId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerUrl() {
- // TODO implement using webUrl from the API
- return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
- },
isActive() {
return this.runner.active;
},
@@ -119,7 +111,7 @@ export default {
},
},
awaitRefetchQueries: true,
- refetchQueries: ['getRunners'],
+ refetchQueries: ['getRunners', 'getGroupRunners'],
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
@@ -147,12 +139,20 @@ export default {
<template>
<gl-button-group>
+ <!--
+ This button appears for administratos: those with
+ access to the adminUrl. More advanced permissions policies
+ will allow more granular permissions.
+
+ See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
+ -->
<gl-button
+ v-if="runner.adminUrl"
v-gl-tooltip.hover.viewport
+ :href="runner.adminUrl"
:title="$options.i18n.I18N_EDIT"
:aria-label="$options.i18n.I18N_EDIT"
icon="pencil"
- :href="runnerUrl"
data-testid="edit-runner"
/>
<gl-button
diff --git a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
index 797a3359147..886b5cb29fc 100644
--- a/app/assets/javascripts/runner/components/cells/runner_name_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -1,12 +1,11 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import RunnerName from '../runner_name.vue';
export default {
components: {
- GlLink,
TooltipOnTruncate,
+ RunnerName,
},
props: {
runner: {
@@ -15,26 +14,18 @@ export default {
},
},
computed: {
- runnerNumericalId() {
- return getIdFromGraphQLId(this.runner.id);
- },
- runnerUrl() {
- // TODO implement using webUrl from the API
- return `${gon.gitlab_url || ''}/admin/runners/${this.runnerNumericalId}`;
- },
description() {
return this.runner.description;
},
- shortSha() {
- return this.runner.shortSha;
- },
},
};
</script>
<template>
<div>
- <gl-link :href="runnerUrl"> #{{ runnerNumericalId }} ({{ shortSha }})</gl-link>
+ <slot :runner="runner" name="runner-name">
+ <runner-name :runner="runner" />
+ </slot>
<tooltip-on-truncate class="gl-display-block" :title="description" truncate-target="child">
<div class="gl-text-truncate">
{{ description }}
diff --git a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
index f186a8daf72..c8cb0bf6088 100644
--- a/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_type_cell.vue
@@ -1,11 +1,18 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective } from '@gitlab/ui';
import RunnerTypeBadge from '../runner_type_badge.vue';
+import RunnerStateLockedBadge from '../runner_state_locked_badge.vue';
+import RunnerStatePausedBadge from '../runner_state_paused_badge.vue';
+import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
export default {
components: {
- GlBadge,
RunnerTypeBadge,
+ RunnerStateLockedBadge,
+ RunnerStatePausedBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
runner: {
@@ -24,19 +31,17 @@ export default {
return !this.runner.active;
},
},
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_PAUSED_RUNNER_DESCRIPTION,
+ },
};
</script>
<template>
<div>
<runner-type-badge :type="runnerType" size="sm" />
-
- <gl-badge v-if="locked" variant="warning" size="sm">
- {{ s__('Runners|locked') }}
- </gl-badge>
-
- <gl-badge v-if="paused" variant="danger" size="sm">
- {{ s__('Runners|paused') }}
- </gl-badge>
+ <runner-state-locked-badge v-if="locked" size="sm" />
+ <runner-state-paused-badge v-if="paused" size="sm" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 69a1f106ca8..3f6ea389288 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -5,7 +5,7 @@ import { formatNumber, __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { RUNNER_JOB_COUNT_LIMIT } from '../constants';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
-import RunnerNameCell from './cells/runner_name_cell.vue';
+import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerTypeCell from './cells/runner_type_cell.vue';
import RunnerTags from './runner_tags.vue';
@@ -35,7 +35,7 @@ export default {
GlSkeletonLoader,
TimeAgo,
RunnerActionsCell,
- RunnerNameCell,
+ RunnerSummaryCell,
RunnerTags,
RunnerTypeCell,
},
@@ -77,7 +77,7 @@ export default {
},
fields: [
tableField({ key: 'type', label: __('Type/State') }),
- tableField({ key: 'name', label: s__('Runners|Runner'), width: 30 }),
+ tableField({ key: 'summary', label: s__('Runners|Runner'), width: 30 }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'ipAddress', label: __('IP Address') }),
tableField({ key: 'projectCount', label: __('Projects'), width: 5 }),
@@ -107,8 +107,12 @@ export default {
<runner-type-cell :runner="item" />
</template>
- <template #cell(name)="{ item }">
- <runner-name-cell :runner="item" />
+ <template #cell(summary)="{ item, index }">
+ <runner-summary-cell :runner="item">
+ <template #runner-name="{ runner }">
+ <slot name="runner-name" :runner="runner" :index="index"></slot>
+ </template>
+ </runner-summary-cell>
</template>
<template #cell(version)="{ item: { version } }">
diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/runner/components/runner_name.vue
new file mode 100644
index 00000000000..8e495125e03
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_name.vue
@@ -0,0 +1,18 @@
+<script>
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+export default {
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ getIdFromGraphQLId,
+ },
+};
+</script>
+<template>
+ <span>#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_state_locked_badge.vue b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue
new file mode 100644
index 00000000000..458526010bc
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_state_locked_badge.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ },
+};
+</script>
+<template>
+ <gl-badge
+ v-gl-tooltip="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
+ variant="warning"
+ v-bind="$attrs"
+ >
+ {{ s__('Runners|locked') }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_state_paused_badge.vue b/app/assets/javascripts/runner/components/runner_state_paused_badge.vue
new file mode 100644
index 00000000000..d1e6fa05e4d
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_state_paused_badge.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
+import { I18N_PAUSED_RUNNER_DESCRIPTION } from '../constants';
+
+export default {
+ components: {
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ i18n: {
+ I18N_PAUSED_RUNNER_DESCRIPTION,
+ },
+};
+</script>
+<template>
+ <gl-badge
+ v-gl-tooltip="$options.i18n.I18N_PAUSED_RUNNER_DESCRIPTION"
+ variant="danger"
+ v-bind="$attrs"
+ >
+ {{ s__('Runners|paused') }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
index c2f43daa899..1a61b80184b 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -1,20 +1,30 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import {
+ INSTANCE_TYPE,
+ GROUP_TYPE,
+ PROJECT_TYPE,
+ I18N_INSTANCE_RUNNER_DESCRIPTION,
+ I18N_GROUP_RUNNER_DESCRIPTION,
+ I18N_PROJECT_RUNNER_DESCRIPTION,
+} from '../constants';
const BADGE_DATA = {
[INSTANCE_TYPE]: {
variant: 'success',
text: s__('Runners|shared'),
+ tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION,
},
[GROUP_TYPE]: {
variant: 'success',
text: s__('Runners|group'),
+ tooltip: I18N_GROUP_RUNNER_DESCRIPTION,
},
[PROJECT_TYPE]: {
variant: 'info',
text: s__('Runners|specific'),
+ tooltip: I18N_PROJECT_RUNNER_DESCRIPTION,
},
};
@@ -22,6 +32,9 @@ export default {
components: {
GlBadge,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
type: {
type: String,
@@ -40,7 +53,7 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" :variant="badge.variant" v-bind="$attrs">
+ <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs">
{{ badge.text }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_type_help.vue b/app/assets/javascripts/runner/components/runner_type_help.vue
deleted file mode 100644
index 70456b3ab65..00000000000
--- a/app/assets/javascripts/runner/components/runner_type_help.vue
+++ /dev/null
@@ -1,60 +0,0 @@
-<script>
-import { GlBadge } from '@gitlab/ui';
-import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
-import RunnerTypeBadge from './runner_type_badge.vue';
-
-export default {
- components: {
- GlBadge,
- RunnerTypeBadge,
- },
- runnerTypes: {
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- },
-};
-</script>
-
-<template>
- <div class="bs-callout">
- <p>{{ __('Runners are processes that pick up and execute CI/CD jobs for GitLab.') }}</p>
- <p>
- {{
- __(
- 'You can register runners as separate users, on separate servers, and on your local machine. Register as many runners as you want.',
- )
- }}
- </p>
-
- <div>
- <span> {{ __('Runners can be:') }}</span>
- <ul>
- <li>
- <runner-type-badge :type="$options.runnerTypes.INSTANCE_TYPE" size="sm" />
- - {{ __('Runs jobs from all unassigned projects.') }}
- </li>
- <li>
- <runner-type-badge :type="$options.runnerTypes.GROUP_TYPE" size="sm" />
- - {{ __('Runs jobs from all unassigned projects in its group.') }}
- </li>
- <li>
- <runner-type-badge :type="$options.runnerTypes.PROJECT_TYPE" size="sm" />
- - {{ __('Runs jobs from assigned projects.') }}
- </li>
- <li>
- <gl-badge variant="warning" size="sm">
- {{ s__('Runners|locked') }}
- </gl-badge>
- - {{ __('Cannot be assigned to other projects.') }}
- </li>
- <li>
- <gl-badge variant="danger" size="sm">
- {{ s__('Runners|paused') }}
- </gl-badge>
- - {{ __('Not available to run jobs.') }}
- </li>
- </ul>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 46e55b322c7..a2fb9d9efd8 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -7,6 +7,14 @@ export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
+export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects');
+export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
+ 'Runners|Available to all projects and subgroups in the group',
+);
+export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
+export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
+export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
+
export const RUNNER_TAG_BADGE_VARIANT = 'info';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
index a601ee8d611..3e5109b1ac4 100644
--- a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
@@ -24,8 +24,11 @@ query getGroupRunners(
search: $search
sort: $sort
) {
- nodes {
- ...RunnerNode
+ edges {
+ webUrl
+ node {
+ ...RunnerNode
+ }
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/runner/graphql/get_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
index 9f837197558..51a91b9eb96 100644
--- a/app/assets/javascripts/runner/graphql/get_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/get_runners.query.graphql
@@ -25,6 +25,7 @@ query getRunners(
) {
nodes {
...RunnerNode
+ adminUrl
}
pageInfo {
...PageInfo
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index 42e1a9e1de9..4bb28796dfa 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,13 +1,16 @@
<script>
+import { GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale';
+
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
+import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue';
-import RunnerTypeHelp from '../components/runner_type_help.vue';
+
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import {
@@ -27,10 +30,11 @@ import { captureException } from '../sentry_utils';
export default {
name: 'GroupRunnersApp',
components: {
+ GlLink,
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
- RunnerTypeHelp,
+ RunnerName,
RunnerPagination,
},
props: {
@@ -51,6 +55,7 @@ export default {
return {
search: fromUrlQueryToSearch(),
runners: {
+ webUrls: [],
items: [],
pageInfo: {},
},
@@ -68,8 +73,10 @@ export default {
},
update(data) {
const { runners } = data?.group || {};
+
return {
- items: runners?.nodes || [],
+ webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [],
+ items: runners?.edges.map(({ node }) => node) || [],
pageInfo: runners?.pageInfo || {},
};
},
@@ -137,17 +144,7 @@ export default {
<template>
<div>
- <div class="row">
- <div class="col-sm-6">
- <runner-type-help />
- </div>
- <div class="col-sm-6">
- <runner-manual-setup-help
- :registration-token="registrationToken"
- :type="$options.GROUP_TYPE"
- />
- </div>
- </div>
+ <runner-manual-setup-help :registration-token="registrationToken" :type="$options.GROUP_TYPE" />
<runner-filtered-search-bar
v-model="search"
@@ -163,7 +160,13 @@ export default {
{{ __('No runners found') }}
</div>
<template v-else>
- <runner-list :runners="runners.items" :loading="runnersLoading" />
+ <runner-list :runners="runners.items" :loading="runnersLoading">
+ <template #runner-name="{ runner, index }">
+ <gl-link :href="runners.webUrls[index]">
+ <runner-name :runner="runner" />
+ </gl-link>
+ </template>
+ </runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template>
</div>
diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue
index 116967a62c8..3e23b8a3435 100644
--- a/app/assets/javascripts/search_settings/components/search_settings.vue
+++ b/app/assets/javascripts/search_settings/components/search_settings.vue
@@ -1,7 +1,13 @@
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
-import { uniq } from 'lodash';
-import { EXCLUDED_NODES, HIDE_CLASS, HIGHLIGHT_CLASS, TYPING_DELAY } from '../constants';
+import { uniq, escapeRegExp } from 'lodash';
+import {
+ EXCLUDED_NODES,
+ HIDE_CLASS,
+ HIGHLIGHT_CLASS,
+ NONE_PADDING_CLASS,
+ TYPING_DELAY,
+} from '../constants';
const origExpansions = new Map();
@@ -37,9 +43,13 @@ const resetSections = ({ sectionSelector }) => {
};
const clearHighlights = () => {
- document
- .querySelectorAll(`.${HIGHLIGHT_CLASS}`)
- .forEach((element) => element.classList.remove(HIGHLIGHT_CLASS));
+ document.querySelectorAll(`.${HIGHLIGHT_CLASS}`).forEach((element) => {
+ const { parentNode } = element;
+ const textNode = document.createTextNode(element.textContent);
+ parentNode.replaceChild(textNode, element);
+
+ parentNode.normalize();
+ });
};
const hideSectionsExcept = (sectionSelector, visibleSections) => {
@@ -50,17 +60,41 @@ const hideSectionsExcept = (sectionSelector, visibleSections) => {
});
};
-const highlightElements = (elements = []) => {
- elements.forEach((element) => element.classList.add(HIGHLIGHT_CLASS));
+const transformMatchElement = (element, searchTerm) => {
+ const textStr = element.textContent;
+ const escapedSearchTerm = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
+
+ const textList = textStr.split(escapedSearchTerm);
+ const replaceFragment = document.createDocumentFragment();
+ textList.forEach((text) => {
+ let addElement = document.createTextNode(text);
+ if (escapedSearchTerm.test(text)) {
+ addElement = document.createElement('mark');
+ addElement.className = `${HIGHLIGHT_CLASS} ${NONE_PADDING_CLASS}`;
+ addElement.textContent = text;
+ escapedSearchTerm.lastIndex = 0;
+ }
+ replaceFragment.appendChild(addElement);
+ });
+
+ return replaceFragment;
+};
+
+const highlightElements = (elements = [], searchTerm) => {
+ elements.forEach((element) => {
+ const replaceFragment = transformMatchElement(element, searchTerm);
+ element.innerHTML = '';
+ element.appendChild(replaceFragment);
+ });
};
-const displayResults = ({ sectionSelector, expandSection }, matches) => {
+const displayResults = ({ sectionSelector, expandSection, searchTerm }, matches) => {
const elements = matches.map((match) => match.parentElement);
const sections = uniq(elements.map((element) => findSettingsSection(sectionSelector, element)));
hideSectionsExcept(sectionSelector, sections);
sections.forEach(expandSection);
- highlightElements(elements);
+ highlightElements(elements, searchTerm);
};
const clearResults = (params) => {
@@ -116,21 +150,21 @@ export default {
},
methods: {
search(value) {
+ this.searchTerm = value;
const displayOptions = {
sectionSelector: this.sectionSelector,
expandSection: this.expandSection,
collapseSection: this.collapseSection,
isExpanded: this.isExpandedFn,
+ searchTerm: this.searchTerm,
};
- this.searchTerm = value;
-
clearResults(displayOptions);
if (value.length) {
saveExpansionState(document.querySelectorAll(this.sectionSelector), displayOptions);
- displayResults(displayOptions, search(this.searchRoot, value));
+ displayResults(displayOptions, search(this.searchRoot, this.searchTerm));
} else {
restoreExpansionState(displayOptions);
}
diff --git a/app/assets/javascripts/search_settings/constants.js b/app/assets/javascripts/search_settings/constants.js
index 9452d149122..a49351dc7b0 100644
--- a/app/assets/javascripts/search_settings/constants.js
+++ b/app/assets/javascripts/search_settings/constants.js
@@ -7,5 +7,8 @@ export const HIDE_CLASS = 'gl-display-none';
// used to highlight the text that matches the * search term
export const HIGHLIGHT_CLASS = 'gl-bg-orange-100';
+// used to remove padding for text that matches the * search term
+export const NONE_PADDING_CLASS = 'gl-p-0';
+
// How many seconds to wait until the user * stops typing
export const TYPING_DELAY = 400;
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
index 0ecfdf420db..86afdbfeb8c 100644
--- a/app/assets/javascripts/security_configuration/components/feature_card.vue
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -128,6 +128,7 @@ export default {
variant="confirm"
category="primary"
class="gl-mt-5"
+ :data-qa-selector="`${feature.type}_mr_button`"
/>
<gl-button
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index b7080bb05b8..c2ca87af9ce 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -82,8 +82,12 @@ export default {
</div>
</assignee-avatar-link>
<div v-else>
- <div class="user-list">
- <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
+ <div class="gl-display-flex gl-flex-wrap">
+ <div
+ v-for="user in uncollapsedUsers"
+ :key="user.id"
+ class="user-item gl-display-inline-block"
+ >
<assignee-avatar-link :user="user" :issuable-type="issuableType" />
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index 9fdf941579d..d5647619ea3 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -36,6 +36,7 @@ export default {
'allowLabelEdit',
'allowScopedLabels',
'iid',
+ 'fullPath',
'initiallySelectedLabels',
'issuableType',
'labelsFetchPath',
@@ -53,30 +54,32 @@ export default {
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
- getUpdateVariables(dropdownLabels) {
- const currentLabelIds = this.selectedLabels.map((label) => label.id);
- const dropdownLabelIds = dropdownLabels.map((label) => label.id);
- const userAddedLabelIds = this.glFeatures.labelsWidget
- ? difference(dropdownLabelIds, currentLabelIds)
- : dropdownLabels.filter((label) => label.set).map((label) => label.id);
- const userRemovedLabelIds = this.glFeatures.labelsWidget
- ? difference(currentLabelIds, dropdownLabelIds)
- : dropdownLabels.filter((label) => !label.set).map((label) => label.id);
+ getUpdateVariables(labels) {
+ let labelIds = [];
- const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
+ if (this.glFeatures.labelsWidget) {
+ labelIds = labels.map(({ id }) => toLabelGid(id));
+ } else {
+ const currentLabelIds = this.selectedLabels.map((label) => label.id);
+ const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id);
+ const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id);
+
+ labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map(
+ toLabelGid,
+ );
+ }
switch (this.issuableType) {
case IssuableType.Issue:
return {
- addLabelIds: userAddedLabelIds,
iid: this.iid,
projectPath: this.projectPath,
- removeLabelIds: userRemovedLabelIds,
+ labelIds,
};
case IssuableType.MergeRequest:
return {
iid: this.iid,
- labelIds: labelIds.map(toLabelGid),
+ labelIds,
operationMode: MutationOperationMode.Replace,
projectPath: this.projectPath,
};
@@ -143,6 +146,8 @@ export default {
<labels-select-widget
v-if="glFeatures.labelsWidget"
class="block labels js-labels-block"
+ :iid="iid"
+ :full-path="fullPath"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
:footer-create-label-title="__('Create project label')"
@@ -152,8 +157,8 @@ export default {
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.variant"
+ :issuable-type="issuableType"
data-qa-selector="labels_block"
- @onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index ad4bfe5b665..4a255a3b916 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -104,11 +104,11 @@ export default {
<gl-loading-icon v-if="loading" size="sm" :inline="true" />
{{ participantLabel }}
</div>
- <div class="participants-list hide-collapsed">
+ <div class="hide-collapsed gl-display-flex gl-flex-wrap">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author"
+ class="participants-author gl-display-inline-block gl-pr-3 gl-pb-3"
>
<a :href="participant.web_url || participant.webUrl" class="author-link">
<user-avatar-image
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index 87780888c2f..361a082def6 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -77,7 +77,7 @@ export default {
>
<!-- use d-flex so that slot can be appropriately styled -->
<span class="gl-display-flex gl-align-items-center">
- <reviewer-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
+ <reviewer-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot :user="user"></slot>
</span>
</gl-link>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index c6fef86c6ff..2922008cfb2 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -91,7 +91,10 @@ export default {
data-testid="reviewer"
>
<reviewer-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType">
- <div class="gl-ml-3">@{{ user.username }}</div>
+ <div class="gl-ml-3 gl-line-height-normal gl-display-grid">
+ <span>{{ user.name }}</span>
+ <span>@{{ user.username }}</span>
+ </div>
</reviewer-avatar-link>
<gl-icon
v-if="user.approved"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
index 3705d725a15..7b4be659330 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -83,13 +83,15 @@ export default {
:value="timeRemainingPercent"
:variant="progressBarVariant"
/>
- <div class="compare-display-container">
- <div class="compare-display float-left">
- <span class="compare-label">{{ s__('TimeTracking|Spent') }}</span>
+ <div
+ class="compare-display-container gl-display-flex gl-justify-content-space-between gl-mt-2"
+ >
+ <div class="gl-float-left">
+ <span class="gl-text-gray-400">{{ s__('TimeTracking|Spent') }}</span>
<span class="compare-value spent">{{ timeSpentHumanReadable }}</span>
</div>
- <div class="compare-display estimated float-right">
- <span class="compare-label">{{ s__('TimeTrackingEstimated|Est') }}</span>
+ <div class="estimated gl-float-right">
+ <span class="gl-text-gray-400">{{ s__('TimeTrackingEstimated|Est') }}</span>
<span class="compare-value">{{ timeEstimateHumanReadable }}</span>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
index db2197ec65e..4564a48fa2d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
@@ -1,30 +1,31 @@
<script>
-import { sprintf, s__ } from '~/locale';
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+const timeSpent = s__('TimeTracking|%{spentStart}Spent: %{spentEnd}');
export default {
name: 'TimeTrackingSpentOnlyPane',
+ timeSpent,
+ components: {
+ GlSprintf,
+ },
props: {
timeSpentHumanReadable: {
type: String,
required: true,
},
},
- computed: {
- timeSpent() {
- return sprintf(
- s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'),
- {
- startTag: '<span class="gl-font-weight-bold">',
- endTag: '</span>',
- timeSpentHumanReadable: this.timeSpentHumanReadable,
- },
- false,
- );
- },
- },
};
</script>
<template>
- <div data-testid="spentOnlyPane" v-html="timeSpent /* eslint-disable-line vue/no-v-html */"></div>
+ <div data-testid="spentOnlyPane">
+ <gl-sprintf :message="$options.timeSpent">
+ <template #spent="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span
+ >{{ timeSpentHumanReadable }}
+ </template>
+ </gl-sprintf>
+ </div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index f7e76cc2b7f..d5782e4b371 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -41,7 +41,7 @@ export default {
computed: {
buttonClasses() {
return this.collapsed
- ? 'btn-blank btn-todo sidebar-collapsed-icon dont-change-state'
+ ? 'btn-blank btn-todo sidebar-collapsed-icon js-dont-change-state'
: 'gl-button btn btn-default btn-todo issuable-header-btn float-right';
},
buttonLabel() {
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index fd43fb80b7f..e593973da82 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -31,6 +31,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs
import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
+import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql';
+import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql';
+import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
+import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
@@ -105,6 +109,17 @@ export const referenceQueries = {
},
};
+export const labelsQueries = {
+ [IssuableType.Issue]: {
+ issuableQuery: issueLabelsQuery,
+ workspaceQuery: projectLabelsQuery,
+ },
+ [IssuableType.Epic]: {
+ issuableQuery: epicLabelsQuery,
+ workspaceQuery: groupLabelsQuery,
+ },
+};
+
export const dateTypes = {
start: 'startDate',
due: 'dueDate',
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 10ab80f4ec2..9f5a2f4ebb0 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -241,6 +241,7 @@ function mountMilestoneSelect() {
export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels');
+ const { fullPath } = getSidebarOptions();
if (!el) {
return false;
@@ -251,6 +252,7 @@ export function mountSidebarLabels() {
apolloProvider,
provide: {
...el.dataset,
+ fullPath,
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
index 62d95a650da..737a131ce7c 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -1,10 +1,14 @@
<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
export default {
components: {
MarkdownFieldView,
},
+ directives: {
+ SafeHtml,
+ },
props: {
description: {
type: String,
@@ -12,13 +16,14 @@ export default {
default: '',
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
<markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content">
<div
+ v-safe-html:[$options.safeHtmlConfig]="description"
class="md js-snippet-description"
- v-html="description /* eslint-disable-line vue/no-v-html */"
></div>
</markdown-field-view>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 466b273cae4..a5c98a7ad90 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -11,15 +11,26 @@ import {
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
+import { isEmpty } from 'lodash';
import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql';
import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
+import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash, { FLASH_TYPES } from '~/flash';
import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
+export const i18n = {
+ snippetSpamSuccess: sprintf(
+ s__('Snippets|%{spammable_titlecase} was submitted to Akismet successfully.'),
+ { spammable_titlecase: __('Snippet') },
+ ),
+ snippetSpamFailure: s__('Snippets|Error with Akismet. Please check the logs for more info.'),
+};
+
export default {
components: {
GlAvatar,
@@ -54,7 +65,7 @@ export default {
},
},
},
- inject: ['reportAbusePath'],
+ inject: ['reportAbusePath', 'canReportSpam'],
props: {
snippet: {
type: Object,
@@ -63,7 +74,8 @@ export default {
},
data() {
return {
- isDeleting: false,
+ isLoading: false,
+ isSubmittingSpam: false,
errorMessage: '',
canCreateSnippet: false,
};
@@ -105,10 +117,11 @@ export default {
category: 'secondary',
},
{
- condition: this.reportAbusePath,
+ condition: this.canReportSpam && !isEmpty(this.reportAbusePath),
text: __('Submit as spam'),
- href: this.reportAbusePath,
+ click: this.submitAsSpam,
title: __('Submit as spam'),
+ loading: this.isSubmittingSpam,
},
];
},
@@ -157,7 +170,7 @@ export default {
this.$refs.deleteModal.show();
},
deleteSnippet() {
- this.isDeleting = true;
+ this.isLoading = true;
this.$apollo
.mutate({
mutation: DeleteSnippetMutation,
@@ -167,17 +180,34 @@ export default {
if (data?.destroySnippet?.errors.length) {
throw new Error(data?.destroySnippet?.errors[0]);
}
- this.isDeleting = false;
this.errorMessage = undefined;
this.closeDeleteModal();
this.redirectToSnippets();
})
.catch((err) => {
- this.isDeleting = false;
+ this.isLoading = false;
this.errorMessage = err.message;
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ async submitAsSpam() {
+ try {
+ this.isSubmittingSpam = true;
+ await axios.post(this.reportAbusePath);
+ createFlash({
+ message: this.$options.i18n.snippetSpamSuccess,
+ type: FLASH_TYPES.SUCCESS,
});
+ } catch (error) {
+ createFlash({ message: this.$options.i18n.snippetSpamFailure });
+ } finally {
+ this.isSubmittingSpam = false;
+ }
},
},
+ i18n,
};
</script>
<template>
@@ -189,9 +219,7 @@ export default {
:title="snippetVisibilityLevelDescription"
data-container="body"
>
- <span class="sr-only">
- {{ s__(`VisibilityLevel|${visibility}`) }}
- </span>
+ <span class="sr-only">{{ s__(`VisibilityLevel|${visibility}`) }}</span>
<gl-icon :name="visibilityLevelIcon" :size="14" />
</div>
<div class="creator" data-testid="authored-message">
@@ -233,6 +261,7 @@ export default {
>
<gl-button
:disabled="action.disabled"
+ :loading="action.loading"
:variant="action.variant"
:category="action.category"
:class="action.cssClass"
@@ -240,9 +269,8 @@ export default {
data-qa-selector="snippet_action_button"
:data-qa-action="action.text"
@click="action.click ? action.click() : undefined"
+ >{{ action.text }}</gl-button
>
- {{ action.text }}
- </gl-button>
</div>
</template>
</div>
@@ -266,14 +294,14 @@ export default {
<gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
<template #modal-title>{{ __('Delete snippet?') }}</template>
- <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
- errorMessage
- }}</gl-alert>
+ <gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">
+ {{ errorMessage }}
+ </gl-alert>
<gl-sprintf :message="__('Are you sure you want to delete %{name}?')">
- <template #name
- ><strong>{{ snippet.title }}</strong></template
- >
+ <template #name>
+ <strong>{{ snippet.title }}</strong>
+ </template>
</gl-sprintf>
<template #modal-footer>
@@ -281,11 +309,11 @@ export default {
<gl-button
variant="danger"
category="primary"
- :disabled="isDeleting"
+ :disabled="isLoading"
data-qa-selector="delete_snippet_button"
@click="deleteSnippet"
>
- <gl-loading-icon v-if="isDeleting" size="sm" inline />
+ <gl-loading-icon v-if="isLoading" size="sm" inline />
{{ __('Delete snippet') }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index dec8dcec179..8e7368ef804 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -27,6 +27,7 @@ export default function appFactory(el, Component) {
visibilityLevels = '[]',
selectedLevel,
multipleLevelsRestricted,
+ canReportSpam,
reportAbusePath,
...restDataset
} = el.dataset;
@@ -39,6 +40,7 @@ export default function appFactory(el, Component) {
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
reportAbusePath,
+ canReportSpam,
},
render(createElement) {
return createElement(Component, {
diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js
index 6a29883290a..8d29a65d705 100644
--- a/app/assets/javascripts/token_access/index.js
+++ b/app/assets/javascripts/token_access/index.js
@@ -6,7 +6,7 @@ import TokenAccess from './components/token_access.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export const initTokenAccess = (containerId = 'js-ci-token-access-app') => {
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 062a3404355..2593fbe6ed1 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -22,9 +22,8 @@ export const DEFAULT_SNOWPLOW_OPTIONS = {
export const ACTION_ATTR_SELECTOR = '[data-track-action]';
export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]';
-export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]';
-export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]';
-
export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls';
export const REFERRER_TTL = 24 * 60 * 60 * 1000;
+
+export const GOOGLE_ANALYTICS_ID_COOKIE_NAME = '_ga';
diff --git a/app/assets/javascripts/tracking/get_standard_context.js b/app/assets/javascripts/tracking/get_standard_context.js
index c318029323d..6014f1ba3ee 100644
--- a/app/assets/javascripts/tracking/get_standard_context.js
+++ b/app/assets/javascripts/tracking/get_standard_context.js
@@ -1,4 +1,5 @@
-import { SNOWPLOW_JS_SOURCE } from './constants';
+import { getCookie } from '~/lib/utils/common_utils';
+import { SNOWPLOW_JS_SOURCE, GOOGLE_ANALYTICS_ID_COOKIE_NAME } from './constants';
export default function getStandardContext({ extra = {} } = {}) {
const { schema, data = {} } = { ...window.gl?.snowplowStandardContext };
@@ -8,6 +9,7 @@ export default function getStandardContext({ extra = {} } = {}) {
data: {
...data,
source: SNOWPLOW_JS_SOURCE,
+ google_analytics_id: getCookie(GOOGLE_ANALYTICS_ID_COOKIE_NAME) ?? '',
extra: extra || data.extra,
},
};
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
index 657e0a79911..c26abc261ed 100644
--- a/app/assets/javascripts/tracking/tracking.js
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -1,4 +1,4 @@
-import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants';
+import { LOAD_ACTION_ATTR_SELECTOR } from './constants';
import { dispatchSnowplowEvent } from './dispatch_snowplow_event';
import getStandardContext from './get_standard_context';
import {
@@ -105,9 +105,7 @@ export default class Tracking {
return [];
}
- const loadEvents = parent.querySelectorAll(
- `${LOAD_ACTION_ATTR_SELECTOR}, ${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR}`,
- );
+ const loadEvents = parent.querySelectorAll(LOAD_ACTION_ATTR_SELECTOR);
loadEvents.forEach((element) => {
const { action, data } = createEventPayload(element);
@@ -179,9 +177,12 @@ export default class Tracking {
}
const referrers = getReferrersCache();
- const pageLinks = Object.seal({ url: '', referrer: '', originalUrl: window.location.href });
+ const pageLinks = Object.seal({
+ url: pageUrl,
+ referrer: '',
+ originalUrl: window.location.href,
+ });
- pageLinks.url = `${pageUrl}${window.location.hash}`;
window.snowplow('setCustomUrl', pageLinks.url);
if (document.referrer) {
diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js
index 3507872b511..cc0d7e7a44a 100644
--- a/app/assets/javascripts/tracking/utils.js
+++ b/app/assets/javascripts/tracking/utils.js
@@ -4,8 +4,6 @@ import { getExperimentData } from '~/experimentation/utils';
import {
ACTION_ATTR_SELECTOR,
LOAD_ACTION_ATTR_SELECTOR,
- DEPRECATED_EVENT_ATTR_SELECTOR,
- DEPRECATED_LOAD_EVENT_ATTR_SELECTOR,
URLS_CACHE_STORAGE_KEY,
REFERRER_TTL,
} from './constants';
@@ -27,7 +25,6 @@ export const addExperimentContext = (opts) => {
export const createEventPayload = (el, { suffix = '' } = {}) => {
const {
trackAction,
- trackEvent,
trackValue,
trackExtra,
trackExperiment,
@@ -36,7 +33,7 @@ export const createEventPayload = (el, { suffix = '' } = {}) => {
trackProperty,
} = el?.dataset || {};
- const action = (trackAction || trackEvent) + (suffix || '');
+ const action = `${trackAction}${suffix || ''}`;
let value = trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) {
@@ -74,8 +71,7 @@ export const createEventPayload = (el, { suffix = '' } = {}) => {
export const eventHandler = (e, func, opts = {}) => {
const actionSelector = `${ACTION_ATTR_SELECTOR}:not(${LOAD_ACTION_ATTR_SELECTOR})`;
- const deprecatedEventSelector = `${DEPRECATED_EVENT_ATTR_SELECTOR}:not(${DEPRECATED_LOAD_EVENT_ATTR_SELECTOR})`;
- const el = e.target.closest(`${actionSelector}, ${deprecatedEventSelector}`);
+ const el = e.target.closest(actionSelector);
if (!el) {
return;
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 7a7518bcf83..4544373d8aa 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -41,6 +41,7 @@ const populateUserInfo = (user) => {
workInformation: userData.work_information,
websiteUrl: userData.website_url,
pronouns: userData.pronouns,
+ localTime: userData.local_time,
loaded: true,
});
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index ea73ab416de..0c4a5ee35d9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -35,13 +35,17 @@ export default {
}
if (!this.rulesLeft.length) {
- return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft);
+ return n__(
+ 'Requires %d approval from eligible users.',
+ 'Requires %d approvals from eligible users.',
+ this.approvalsLeft,
+ );
}
return sprintf(
n__(
- 'Requires approval from %{names}.',
- 'Requires %{count} more approvals from %{names}.',
+ 'Requires %{count} approval from %{names}.',
+ 'Requires %{count} approvals from %{names}.',
this.approvalsLeft,
),
{
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
new file mode 100644
index 00000000000..023367a794e
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { sprintf, __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ widget: {
+ type: String,
+ required: true,
+ },
+ tertiaryButtons: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ dropdownLabel() {
+ return sprintf(__('%{widget} options'), { widget: this.widget });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown
+ v-if="tertiaryButtons"
+ :text="dropdownLabel"
+ icon="ellipsis_v"
+ no-caret
+ category="tertiary"
+ right
+ lazy
+ text-sr-only
+ size="small"
+ toggle-class="gl-p-2!"
+ class="gl-display-block gl-md-display-none!"
+ >
+ <gl-dropdown-item
+ v-for="(btn, index) in tertiaryButtons"
+ :key="index"
+ :href="btn.href"
+ :target="btn.target"
+ >
+ {{ btn.text }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <template v-if="tertiaryButtons.length">
+ <gl-button
+ v-for="(btn, index) in tertiaryButtons"
+ :key="index"
+ :href="btn.href"
+ :target="btn.target"
+ :class="{ 'gl-mr-3': index > 1 }"
+ category="tertiary"
+ variant="confirm"
+ size="small"
+ class="gl-display-none gl-md-display-block"
+ >
+ {{ btn.text }}
+ </gl-button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 0ac98f6c982..298f7c7ad8c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -1,7 +1,18 @@
<script>
-import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlLoadingIcon,
+ GlLink,
+ GlBadge,
+ GlSafeHtmlDirective,
+ GlTooltipDirective,
+ GlIntersectionObserver,
+} from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
-import StatusIcon from '../mr_widget_status_icon.vue';
+import { EXTENSION_ICON_CLASS } from '../../constants';
+import StatusIcon from './status_icon.vue';
+import Actions from './actions.vue';
export const LOADING_STATES = {
collapsedLoading: 'collapsedLoading',
@@ -13,14 +24,16 @@ export default {
components: {
GlButton,
GlLoadingIcon,
- GlIcon,
GlLink,
GlBadge,
+ GlIntersectionObserver,
SmartVirtualList,
StatusIcon,
+ Actions,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
+ GlTooltip: GlTooltipDirective,
},
data() {
return {
@@ -28,9 +41,16 @@ export default {
collapsedData: null,
fullData: null,
isCollapsed: true,
+ showFade: false,
};
},
computed: {
+ widgetLabel() {
+ return this.$options.i18n?.label || this.$options.name;
+ },
+ widgetLoadingText() {
+ return this.$options.i18n?.loading || __('Loading...');
+ },
isLoadingSummary() {
return this.loadingState === LOADING_STATES.collapsedLoading;
},
@@ -44,17 +64,22 @@ export default {
return true;
},
+ collapseButtonLabel() {
+ return sprintf(
+ this.isCollapsed
+ ? s__('mrWidget|Show %{widget} details')
+ : s__('mrWidget|Hide %{widget} details'),
+ { widget: this.widgetLabel },
+ );
+ },
statusIconName() {
- if (this.isLoadingSummary) {
- return 'loading';
- }
-
- if (this.loadingState === LOADING_STATES.collapsedError) {
- return 'warning';
- }
+ if (this.isLoadingSummary) return null;
return this.statusIcon(this.collapsedData);
},
+ tertiaryActionsButtons() {
+ return this.tertiaryButtons ? this.tertiaryButtons() : undefined;
+ },
},
watch: {
isCollapsed(newVal) {
@@ -95,32 +120,59 @@ export default {
throw e;
});
},
+ appear(index) {
+ if (index === this.fullData.length - 1) {
+ this.showFade = false;
+ }
+ },
+ disappear(index) {
+ if (index === this.fullData.length - 1) {
+ this.showFade = true;
+ }
+ },
},
+ EXTENSION_ICON_CLASS,
};
</script>
<template>
- <section class="media-section mr-widget-border-top">
+ <section class="media-section" data-testid="widget-extension">
<div class="media gl-p-5">
- <status-icon :status="statusIconName" class="align-self-center" />
- <div class="media-body d-flex flex-align-self-center align-items-center">
- <div class="code-text">
- <template v-if="isLoadingSummary">
- {{ __('Loading...') }}
- </template>
+ <status-icon
+ :name="$options.label || $options.name"
+ :is-loading="isLoadingSummary"
+ :icon-name="statusIconName"
+ />
+ <div class="media-body gl-display-flex gl-flex-direction-row!">
+ <div class="gl-flex-grow-1">
+ <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template>
<div v-else v-safe-html="summary(collapsedData)"></div>
</div>
- <gl-button
- v-if="isCollapsible"
- size="small"
- class="float-right align-self-center"
- @click="toggleCollapsed"
- >
- {{ isCollapsed ? __('Expand') : __('Collapse') }}
- </gl-button>
+ <actions
+ :widget="$options.label || $options.name"
+ :tertiary-buttons="tertiaryActionsButtons"
+ />
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6">
+ <gl-button
+ v-if="isCollapsible"
+ v-gl-tooltip
+ :title="collapseButtonLabel"
+ :aria-expanded="`${!isCollapsed}`"
+ :aria-label="collapseButtonLabel"
+ :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ category="tertiary"
+ data-testid="toggle-button"
+ size="small"
+ @click="toggleCollapsed"
+ />
+ </div>
</div>
</div>
- <div v-if="!isCollapsed" class="mr-widget-grouped-section">
+ <div
+ v-if="!isCollapsed"
+ class="mr-widget-grouped-section gl-relative"
+ data-testid="widget-extension-collapsed-section"
+ >
<div v-if="isLoadingExpanded" class="report-block-container">
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
</div>
@@ -131,27 +183,38 @@ export default {
:size="32"
wtag="ul"
wclass="report-block-list"
- class="report-block-container"
+ class="report-block-container gl-px-5 gl-py-0"
>
- <li v-for="data in fullData" :key="data.id" class="d-flex align-items-center">
- <div v-if="data.icon" :class="data.icon.class" class="d-flex">
- <gl-icon :name="data.icon.name" :size="24" />
- </div>
- <div
- class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex"
+ <li
+ v-for="(data, index) in fullData"
+ :key="data.id"
+ :class="{
+ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1,
+ }"
+ class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7"
+ data-testid="extension-list-item"
+ >
+ <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" />
+ <gl-intersection-observer
+ :options="{ rootMargin: '100px', thresholds: 0.1 }"
+ class="gl-flex-wrap gl-align-self-center gl-display-flex"
+ @appear="appear(index)"
+ @disappear="disappear(index)"
>
- <div class="gl-mr-4">
- {{ data.text }}
- </div>
+ <div v-safe-html="data.text" class="gl-mr-4"></div>
<div v-if="data.link">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
<gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'">
{{ data.badge.text }}
</gl-badge>
- </div>
+ </gl-intersection-observer>
</li>
</smart-virtual-list>
+ <div
+ :class="{ show: showFade }"
+ class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7"
+ ></div>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
index 529160de6a7..b9dfd3bd41e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
@@ -1,4 +1,5 @@
-import { extensions } from './index';
+import { __ } from '~/locale';
+import { registeredExtensions } from './index';
export default {
props: {
@@ -8,20 +9,46 @@ export default {
},
},
render(h) {
+ const { extensions } = registeredExtensions;
+
+ if (extensions.length === 0) return null;
+
return h(
'div',
- {},
- extensions.map((extension) =>
- h(extension, {
- props: extensions[0].props.reduce(
- (acc, key) => ({
- ...acc,
- [key]: this.mr[key],
- }),
- {},
- ),
- }),
- ),
+ {
+ attrs: {
+ role: 'region',
+ 'aria-label': __('Merge request reports'),
+ },
+ },
+ [
+ h(
+ 'ul',
+ {
+ class: 'gl-p-0 gl-m-0 gl-list-style-none',
+ },
+ [
+ ...extensions.map((extension, index) =>
+ h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [
+ h(
+ { ...extension },
+ {
+ props: {
+ ...extension.props.reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: this.mr[key],
+ }),
+ {},
+ ),
+ },
+ },
+ ),
+ ]),
+ ),
+ ],
+ ),
+ ],
);
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index 9796bb44939..4ca0b660696 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -1,15 +1,17 @@
+import Vue from 'vue';
import ExtensionBase from './base.vue';
// Holds all the currently registered extensions
-export const extensions = [];
+export const registeredExtensions = Vue.observable({ extensions: [] });
export const registerExtension = (extension) => {
// Pushes into the extenions array a dynamically created Vue component
// that gets exteneded from `base.vue`
- extensions.push({
+ registeredExtensions.extensions.push({
extends: ExtensionBase,
name: extension.name,
props: extension.props,
+ i18n: extension.i18n,
computed: {
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
new file mode 100644
index 00000000000..01d8de132e7
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlIcon,
+ },
+ props: {
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ iconName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
+ },
+ computed: {
+ iconAriaLabel() {
+ return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`;
+ },
+ },
+ EXTENSION_ICON_NAMES,
+ EXTENSION_ICON_CLASS,
+};
+</script>
+
+<template>
+ <div
+ :class="[
+ $options.EXTENSION_ICON_CLASS[iconName],
+ { 'mr-widget-extension-icon': !isLoading && size === 16 },
+ { 'gl-p-2': isLoading || size === 16 },
+ ]"
+ class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
+ >
+ <gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" />
+ <gl-icon
+ v-else
+ :name="$options.EXTENSION_ICON_NAMES[iconName]"
+ :size="size"
+ :aria-label="iconAriaLabel"
+ class="gl-display-block"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 966262944ad..5c67b9c7ab5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -10,7 +10,7 @@ import {
GlSafeHtmlDirective as SafeHtml,
GlSprintf,
} from '@gitlab/ui';
-import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
+import { constructWebIDEPath } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
@@ -58,15 +58,7 @@ export default {
});
},
webIdePath() {
- return mergeUrlParams(
- {
- target_project:
- this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath
- ? this.mr.targetProjectFullPath
- : '',
- },
- webIDEUrl(`/${this.mr.sourceProjectFullPath}/merge_requests/${this.mr.iid}`),
- );
+ return constructWebIDEPath(this.mr);
},
isFork() {
return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath;
@@ -79,7 +71,7 @@ export default {
};
</script>
<template>
- <div class="d-flex mr-source-target gl-mb-3">
+ <div class="gl-display-flex mr-source-target">
<mr-widget-icon name="git-merge" />
<div class="git-merge-container d-flex">
<div class="normal">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
index 7532eabee8a..68cff1368af 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -1,6 +1,7 @@
<script>
/* eslint-disable @gitlab/require-i18n-strings */
import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import { escapeShellString } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -75,20 +76,31 @@ export default {
},
computed: {
mergeInfo1() {
+ const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`);
+
return this.isFork
- ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD`
- : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`;
+ ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD`
+ : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`;
},
mergeInfo2() {
return this.isFork
- ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"`
- : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceBranch}"`;
+ ? `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedForkBranch}`
+ : `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedSourceBranch}`;
},
mergeInfo3() {
return this.canMerge
- ? `git push origin "${this.targetBranch}"`
+ ? `git push origin ${this.escapedTargetBranch}`
: __('Note that pushing to GitLab requires write access to this repository.');
},
+ escapedForkBranch() {
+ return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`);
+ },
+ escapedTargetBranch() {
+ return escapeShellString(this.targetBranch);
+ },
+ escapedSourceBranch() {
+ return escapeShellString(this.sourceBranch);
+ },
},
};
</script>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index a8272002f16..a05e8747a43 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -88,6 +88,9 @@ export default {
return this.mr.preferredAutoMergeStrategy;
},
+ ciStatus() {
+ return this.isPostMerge ? this.mr?.mergePipeline?.details?.status?.text : this.mr.ciStatus;
+ },
},
};
</script>
@@ -97,7 +100,7 @@ export default {
:pipeline="pipeline"
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:builds-with-coverage="mr.buildsWithCoverage"
- :ci-status="mr.ciStatus"
+ :ci-status="ciStatus"
:has-ci="mr.hasCI"
:pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds"
:source-branch="branch"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index a55dba92e16..3ca193514f1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -1,12 +1,15 @@
<script>
-/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
-import { __, n__, sprintf, s__ } from '~/locale';
+import { __, n__, s__ } from '~/locale';
+
+const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit');
export default {
+ mergeCommitCount,
components: {
GlButton,
+ GlSprintf,
},
props: {
isSquashEnabled: {
@@ -37,7 +40,7 @@ export default {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
commitsCountMessage() {
- return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount);
+ return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount);
},
modifyLinkMessage() {
if (this.isFastForwardEnabled) return __('Modify commit message');
@@ -47,22 +50,15 @@ export default {
ariaLabel() {
return this.expanded ? __('Collapse') : __('Expand');
},
+ targetBranchEscaped() {
+ return escape(this.targetBranch);
+ },
message() {
- const message = this.isFastForwardEnabled
+ return this.isFastForwardEnabled
? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
: s__(
'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
);
-
- return sprintf(
- message,
- {
- commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
- mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
- targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`,
- },
- false,
- );
},
},
methods: {
@@ -89,10 +85,19 @@ export default {
/>
<span v-if="expanded">{{ __('Collapse') }}</span>
<span v-else>
- <span
- class="vertical-align-middle"
- v-html="message /* eslint-disable-line vue/no-v-html */"
- ></span>
+ <span class="vertical-align-middle">
+ <gl-sprintf :message="message">
+ <template #commitCount>
+ <strong class="commits-count-message">{{ commitsCountMessage }}</strong>
+ </template>
+ <template #mergeCommitCount>
+ <strong>{{ $options.mergeCommitCount }}</strong>
+ </template>
+ <template #targetBranch>
+ <span class="label-branch">{{ targetBranchEscaped }}</span>
+ </template>
+ </gl-sprintf>
+ </span>
<gl-button variant="link" class="modify-message-button">
{{ modifyLinkMessage }}
</gl-button>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 7df65e995a5..7d4bd4cf1bf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -29,6 +29,7 @@ import {
WARNING,
MT_MERGE_STRATEGY,
PIPELINE_FAILED_STATE,
+ STATE_MACHINE,
} from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -47,6 +48,9 @@ const MERGE_FAILED_STATUS = 'failed';
const MERGE_SUCCESS_STATUS = 'success';
const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error';
+const { transitions } = STATE_MACHINE;
+const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions;
+
export default {
name: 'ReadyToMerge',
apollo: {
@@ -99,8 +103,8 @@ export default {
GlDropdownItem,
GlFormCheckbox,
GlSkeletonLoader,
- MergeTrainHelperText: () =>
- import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'),
+ MergeTrainHelperIcon: () =>
+ import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'),
MergeImmediatelyConfirmationDialog: () =>
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
@@ -234,7 +238,7 @@ export default {
return CONFIRM;
},
iconClass() {
- if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) {
+ if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) {
return PIPELINE_RUNNING_STATE;
}
@@ -361,6 +365,11 @@ export default {
}
this.isMakingRequest = true;
+
+ if (!useAutoMerge) {
+ this.mr.transitionStateMachine({ transition: MERGE });
+ }
+
this.service
.merge(options)
.then((res) => res.data)
@@ -371,10 +380,12 @@ export default {
if (AUTO_MERGE_STRATEGIES.includes(data.status)) {
eventHub.$emit('MRWidgetUpdateRequested');
+ this.mr.transitionStateMachine({ transition: AUTO_MERGE });
} else if (data.status === MERGE_SUCCESS_STATUS) {
this.initiateMergePolling();
} else if (hasError) {
eventHub.$emit('FailedToMerge', data.merge_error);
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
}
if (this.glFeatures.mergeRequestWidgetGraphql) {
@@ -383,6 +394,7 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
createFlash({
message: __('Something went wrong. Please try again.'),
});
@@ -417,6 +429,7 @@ export default {
eventHub.$emit('FetchActionsContent');
MergeRequest.hideCloseButton();
MergeRequest.decreaseCounter();
+ this.mr.transitionStateMachine({ transition: MERGED });
stopPolling();
refreshUserMergeRequestCounts();
@@ -428,6 +441,7 @@ export default {
}
} else if (data.merge_error) {
eventHub.$emit('FailedToMerge', data.merge_error);
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
stopPolling();
} else {
// MR is not merged yet, continue polling until the state becomes 'merged'
@@ -438,6 +452,7 @@ export default {
createFlash({
message: __('Something went wrong while merging this merge request. Please try again.'),
});
+ this.mr.transitionStateMachine({ transition: MERGE_FAILURE });
stopPolling();
});
},
@@ -489,7 +504,7 @@ export default {
</div>
</div>
<template v-else>
- <div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }">
+ <div class="mr-widget-body media">
<status-icon :status="iconClass" />
<div class="media-body">
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center">
@@ -560,6 +575,13 @@ export default {
:is-disabled="isSquashReadOnly"
class="gl-mx-3"
/>
+
+ <merge-train-helper-icon
+ v-if="shouldRenderMergeTrainHelperIcon"
+ :merge-train-when-pipeline-succeeds-docs-path="
+ mr.mergeTrainWhenPipelineSucceedsDocsPath
+ "
+ />
</div>
<template v-else>
<div class="bold js-resolve-mr-widget-items-message gl-ml-3">
@@ -590,13 +612,6 @@ export default {
</div>
</div>
</div>
- <merge-train-helper-text
- v-if="shouldRenderMergeTrainHelperText"
- :pipeline-id="pipelineId"
- :pipeline-link="pipeline.path"
- :merge-train-length="stateData.mergeTrainsCount"
- :merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath"
- />
<template v-if="shouldShowMergeControls">
<div
v-if="!shouldShowMergeEdit"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 393c599c7e8..790870ee4c6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui';
import { produce } from 'immer';
import $ from 'jquery';
import createFlash from '~/flash';
+import toast from '~/vue_shared/plugins/global_toast';
import { __ } from '~/locale';
import MergeRequest from '~/merge_request';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -123,10 +124,7 @@ export default {
},
},
}) => {
- createFlash({
- message: __('Marked as ready. Merging is now allowed.'),
- type: 'notice',
- });
+ toast(__('Marked as ready. Merging is now allowed.'));
$('.merge-request .detail-page-description .title').text(title);
},
)
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index f5710f46b7e..b88e83ccb0f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -1,4 +1,5 @@
import { s__ } from '~/locale';
+import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps';
export const SUCCESS = 'success';
export const WARNING = 'warning';
@@ -52,3 +53,99 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [
emoji: 'heart_eyes',
},
];
+
+const STATE_MACHINE = {
+ states: {
+ IDLE: 'IDLE',
+ MERGING: 'MERGING',
+ AUTO_MERGE: 'AUTO_MERGE',
+ },
+ transitions: {
+ MERGE: 'start-merge',
+ AUTO_MERGE: 'start-auto-merge',
+ MERGE_FAILURE: 'merge-failed',
+ MERGED: 'merge-done',
+ },
+};
+const { states, transitions } = STATE_MACHINE;
+
+STATE_MACHINE.definition = {
+ initial: states.IDLE,
+ states: {
+ [states.IDLE]: {
+ on: {
+ [transitions.MERGE]: states.MERGING,
+ [transitions.AUTO_MERGE]: states.AUTO_MERGE,
+ },
+ },
+ [states.MERGING]: {
+ on: {
+ [transitions.MERGED]: states.IDLE,
+ [transitions.MERGE_FAILURE]: states.IDLE,
+ },
+ },
+ [states.AUTO_MERGE]: {
+ on: {
+ [transitions.MERGED]: states.IDLE,
+ [transitions.MERGE_FAILURE]: states.IDLE,
+ },
+ },
+ },
+};
+
+export const stateToTransitionMap = {
+ [stateKey.merging]: transitions.MERGE,
+ [stateKey.merged]: transitions.MERGED,
+ [stateKey.autoMergeEnabled]: transitions.AUTO_MERGE,
+};
+export const stateToComponentMap = {
+ [states.MERGING]: classStateMap[stateKey.merging],
+ [states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled],
+};
+
+export const EXTENSION_ICONS = {
+ failed: 'failed',
+ warning: 'warning',
+ success: 'success',
+ neutral: 'neutral',
+ error: 'error',
+ notice: 'notice',
+ severityCritical: 'severityCritical',
+ severityHigh: 'severityHigh',
+ severityMedium: 'severityMedium',
+ severityLow: 'severityLow',
+ severityInfo: 'severityInfo',
+ severityUnknown: 'severityUnknown',
+};
+
+export const EXTENSION_ICON_NAMES = {
+ failed: 'status-failed',
+ warning: 'status-alert',
+ success: 'status-success',
+ neutral: 'status-neutral',
+ error: 'status-alert',
+ notice: 'status-alert',
+ severityCritical: 'severity-critical',
+ severityHigh: 'severity-high',
+ severityMedium: 'severity-medium',
+ severityLow: 'severity-low',
+ severityInfo: 'severity-info',
+ severityUnknown: 'severity-unknown',
+};
+
+export const EXTENSION_ICON_CLASS = {
+ failed: 'gl-text-red-500',
+ warning: 'gl-text-orange-500',
+ success: 'gl-text-green-500',
+ neutral: 'gl-text-gray-400',
+ error: 'gl-text-red-500',
+ notice: 'gl-text-gray-500',
+ severityCritical: 'gl-text-red-800',
+ severityHigh: 'gl-text-red-600',
+ severityMedium: 'gl-text-orange-400',
+ severityLow: 'gl-text-orange-300',
+ severityInfo: 'gl-text-blue-400',
+ severityUnknown: 'gl-text-gray-400',
+};
+
+export { STATE_MACHINE };
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index 6c6f5e7fc73..349e9d29355 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -1,4 +1,5 @@
/* eslint-disable */
+import { EXTENSION_ICONS } from '../constants';
import issuesCollapsedQuery from './issues_collapsed.query.graphql';
import issuesQuery from './issues.query.graphql';
@@ -6,20 +7,29 @@ export default {
// Give the extension a name
// Make it easier to track in Vue dev tools
name: 'WidgetIssues',
+ i18n: {
+ label: 'Issues',
+ loading: 'Loading issues...',
+ },
// Add an array of props
// These then get mapped to values stored in the MR Widget store
- props: ['targetProjectFullPath'],
+ props: ['targetProjectFullPath', 'conflictsDocsPath'],
// Add any extra computed props in here
computed: {
// Small summary text to be displayed in the collapsed state
// Receives the collapsed data as an argument
summary(count) {
- return `<strong>${count}</strong> open issue`;
+ return 'Summary text<br/>Second line';
},
// Status icon to be used next to the summary text
// Receives the collapsed data as an argument
statusIcon(count) {
- return count > 0 ? 'warning' : 'success';
+ return EXTENSION_ICONS.warning;
+ },
+ // Tertiary action buttons that will take the user elsewhere
+ // in the GitLab app
+ tertiaryButtons() {
+ return [{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }];
},
},
methods: {
@@ -44,16 +54,13 @@ export default {
// Icon to get rendered on the side of each row
icon: {
// Required: Name maps to an icon in GitLabs SVG
- name:
- issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless',
- // Optional: An extra class to be added to the icon for additional styling
- class: issue.state === 'closed' ? 'text-danger' : 'text-success',
+ name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success,
},
// Badges get rendered next to the text on each row
- badge: issue.state === 'closed' && {
- text: 'Closed', // Required: Text to be used inside of the badge
- // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants
- },
+ // badge: issue.state === 'closed' && {
+ // text: 'Closed', // Required: Text to be used inside of the badge
+ // // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants
+ // },
// Each row can have its own link that will take the user elsewhere
// link: {
// href: 'https://google.com', // Required: href for the link
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 9d8e5d12d58..cf6472f2c8c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -32,7 +32,7 @@ export default {
isMergeImmediatelyDangerous() {
return false;
},
- shouldRenderMergeTrainHelperText() {
+ shouldRenderMergeTrainHelperIcon() {
return false;
},
pipelineId() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 78aa3941bfe..3ac1e881658 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -4,7 +4,7 @@ import { isEmpty } from 'lodash';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
-import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
+import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import createFlash from '~/flash';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import notify from '~/lib/utils/notify';
@@ -38,7 +38,8 @@ import ReadyToMergeState from './components/states/ready_to_merge.vue';
import ShaMismatch from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import WorkInProgressState from './components/states/work_in_progress.vue';
-// import ExtensionsContainer from './components/extensions/container';
+import ExtensionsContainer from './components/extensions/container';
+import { STATE_MACHINE, stateToComponentMap } from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
@@ -52,7 +53,7 @@ export default {
},
components: {
Loading,
- // ExtensionsContainer,
+ ExtensionsContainer,
'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
MrWidgetPipelineContainer,
@@ -124,7 +125,9 @@ export default {
mr: store,
state: store && store.state,
service: store && this.createService(store),
+ machineState: store?.machineValue || STATE_MACHINE.definition.initial,
loading: true,
+ recomputeComponentName: 0,
};
},
computed: {
@@ -139,7 +142,7 @@ export default {
return this.mr.state !== 'nothingToMerge';
},
componentName() {
- return stateMaps.stateToComponentMap[this.mr.state];
+ return stateToComponentMap[this.machineState] || classState[this.mr.state];
},
hasPipelineMustSucceedConflict() {
return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
@@ -148,9 +151,9 @@ export default {
return this.mr.hasCI || this.hasPipelineMustSucceedConflict;
},
shouldSuggestPipelines() {
- return (
- !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline
- );
+ const { hasCI, mergeRequestAddCiConfigPath, isDismissedSuggestPipeline } = this.mr;
+
+ return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline;
},
shouldRenderCodeQuality() {
return this.mr?.codequalityReportsPath;
@@ -204,8 +207,19 @@ export default {
hasAlerts() {
return this.mr.mergeError || this.showMergePipelineForkWarning;
},
+ shouldShowExtension() {
+ return (
+ window.gon?.features?.refactorMrWidgetsExtensions ||
+ window.gon?.features?.refactorMrWidgetsExtensionsUser
+ );
+ },
},
watch: {
+ 'mr.machineValue': {
+ handler(newValue) {
+ this.machineState = newValue;
+ },
+ },
state(newVal, oldVal) {
if (newVal !== oldVal && this.shouldRenderMergedPipeline) {
// init polling
@@ -247,6 +261,8 @@ export default {
this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data });
}
+ this.machineState = this.mr.machineValue;
+
if (!this.state) {
this.state = this.mr.state;
}
@@ -496,7 +512,7 @@ export default {
</template>
</mr-widget-alert-message>
</div>
- <!-- <extensions-container :mr="mr" /> -->
+ <extensions-container :mr="mr" />
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
:head-blob-path="mr.headBlobPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 29e0c867f6b..6628225cd46 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,11 +1,21 @@
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { statusBoxState } from '~/issuable/components/status_box.vue';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
-import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants';
+import { machine } from '~/lib/utils/finite_state_machine';
+import {
+ MTWPS_MERGE_STRATEGY,
+ MT_MERGE_STRATEGY,
+ MWPS_MERGE_STRATEGY,
+ STATE_MACHINE,
+ stateToTransitionMap,
+} from '../constants';
import { stateKey } from './state_maps';
const { format } = getTimeago();
+const { states } = STATE_MACHINE;
+const { IDLE } = states;
+
export default class MergeRequestStore {
constructor(data) {
this.sha = data.diff_head_sha;
@@ -16,6 +26,9 @@ export default class MergeRequestStore {
this.apiUnapprovePath = data.api_unapprove_path;
this.hasApprovalsAvailable = data.has_approvals_available;
+ this.stateMachine = machine(STATE_MACHINE.definition);
+ this.machineValue = this.stateMachine.value;
+
this.setPaths(data);
this.setData(data);
@@ -215,10 +228,7 @@ export default class MergeRequestStore {
setState() {
if (this.mergeOngoing) {
this.state = 'merging';
- return;
- }
-
- if (this.isOpen) {
+ } else if (this.isOpen) {
this.state = getStateKey.call(this);
} else {
switch (this.mergeRequestState) {
@@ -232,6 +242,8 @@ export default class MergeRequestStore {
this.state = null;
}
}
+
+ this.translateStateToMachine();
}
setPaths(data) {
@@ -277,7 +289,7 @@ export default class MergeRequestStore {
// Security reports
this.sastComparisonPath = data.sast_comparison_path;
- this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
+ this.secretDetectionComparisonPath = data.secret_detection_comparison_path;
}
get isNothingToMergeState() {
@@ -356,4 +368,32 @@ export default class MergeRequestStore {
(this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed)
);
}
+
+ // Because the state machine doesn't yet handle every state and transition,
+ // some use-cases will need to force a state that can't be reached by
+ // a known transition. This is undesirable long-term (as it subverts
+ // the intent of a state machine), but is necessary until the machine
+ // can handle all possible combinations. (unsafeForce)
+ transitionStateMachine({ transition, state, unsafeForce = false } = {}) {
+ if (unsafeForce && state) {
+ this.stateMachine.value = state;
+ } else {
+ this.stateMachine.send(transition);
+ }
+
+ this.machineValue = this.stateMachine.value;
+ }
+ translateStateToMachine() {
+ const transition = stateToTransitionMap[this.state];
+ let transitionOptions = {
+ state: IDLE,
+ unsafeForce: true,
+ };
+
+ if (transition) {
+ transitionOptions = { transition };
+ }
+
+ this.transitionStateMachine(transitionOptions);
+ }
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index 04454882666..4cb23407a74 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -1,4 +1,4 @@
-const stateToComponentMap = {
+export const stateToComponentMap = {
merged: 'mr-widget-merged',
closed: 'mr-widget-closed',
merging: 'mr-widget-merging',
@@ -21,7 +21,7 @@ const stateToComponentMap = {
mergeChecksFailed: 'mergeChecksFailed',
};
-const statesToShowHelpWidget = [
+export const statesToShowHelpWidget = [
'merging',
'conflicts',
'workInProgress',
@@ -50,11 +50,7 @@ export const stateKey = {
notAllowedToMerge: 'notAllowedToMerge',
readyToMerge: 'readyToMerge',
rebase: 'rebase',
+ merging: 'merging',
merged: 'merged',
mergeChecksFailed: 'mergeChecksFailed',
};
-
-export default {
- stateToComponentMap,
- statesToShowHelpWidget,
-};
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
index 0c1d55ae707..4cab5e964de 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -27,6 +27,11 @@ export default {
required: false,
default: '',
},
+ hideLineNumbers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
mounted() {
eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT);
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 84770dbac6f..40044e518c3 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -8,8 +8,6 @@ export default {
name: 'SimpleViewer',
components: {
GlIcon,
- SourceEditor: () =>
- import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'),
},
mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
@@ -22,9 +20,6 @@ export default {
lineNumbers() {
return this.content.split('\n').length;
},
- refactorBlobViewerEnabled() {
- return this.glFeatures.refactorBlobViewer;
- },
},
mounted() {
const { hash } = window.location;
@@ -52,14 +47,8 @@ export default {
</script>
<template>
<div>
- <source-editor
- v-if="isRawContent && refactorBlobViewerEnabled"
- :value="content"
- :file-name="fileName"
- :editor-options="{ readOnly: true }"
- />
- <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
- <div class="line-numbers">
+ <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme">
+ <div v-if="!hideLineNumbers" class="line-numbers">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
index 3c21b14894b..7563c35dfc8 100644
--- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -81,8 +81,8 @@ export default {
},
},
i18n: {
- fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'),
- shortDescription: __('Choose any color'),
+ fullDescription: __('Enter any color or choose one of the suggested colors below.'),
+ shortDescription: __('Enter any color.'),
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index 7b88b36aa0f..ea507017caa 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -97,7 +97,7 @@ export default {
});
})
.catch(() => {
- this.previewContent = __('An error occurred while fetching markdown preview');
+ this.previewContent = __('An error occurred while fetching Markdown preview');
this.isLoading = false;
});
}
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
index c4dfcf93a18..014276c7e36 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue
@@ -1,13 +1,11 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlAlert } from '@gitlab/ui';
import { slugifyWithUnderscore } from '~/lib/utils/text_utility';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
export default {
components: {
GlAlert,
- GlSprintf,
- GlLink,
LocalStorageSync,
},
props: {
@@ -15,10 +13,6 @@ export default {
type: String,
required: true,
},
- feedbackLink: {
- type: String,
- required: true,
- },
},
data() {
return {
@@ -44,19 +38,8 @@ export default {
<template>
<div v-show="showAlert">
<local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json />
- <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert">
- <gl-sprintf
- :message="
- __(
- 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.',
- )
- "
- >
- <template #featureName>{{ featureName }}</template>
- <template #link="{ content }">
- <gl-link :href="feedbackLink" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert">
+ <slot></slot>
</gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
new file mode 100644
index 00000000000..5d0ed8b0821
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue
@@ -0,0 +1,81 @@
+<script>
+import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes';
+
+export default {
+ model: {
+ prop: 'index',
+ event: 'change',
+ },
+ props: {
+ /* v-model property to manage location in list */
+ index: {
+ type: Number,
+ required: true,
+ },
+ /* Highest index that can be navigated to */
+ max: {
+ type: Number,
+ required: true,
+ },
+ /* Lowest index that can be navigated to */
+ min: {
+ type: Number,
+ required: true,
+ },
+ /* Which index to set v-model to on init */
+ defaultIndex: {
+ type: Number,
+ required: true,
+ },
+ },
+ watch: {
+ max() {
+ // If the max index (list length) changes, reset the index
+ this.$emit('change', this.defaultIndex);
+ },
+ },
+ created() {
+ this.$emit('change', this.defaultIndex);
+ document.addEventListener('keydown', this.handleKeydown);
+ },
+ beforeDestroy() {
+ document.removeEventListener('keydown', this.handleKeydown);
+ },
+ methods: {
+ handleKeydown(event) {
+ if (event.keyCode === DOWN_KEY_CODE) {
+ // Prevents moving scrollbar
+ event.preventDefault();
+ event.stopPropagation();
+ // Moves to next index
+ this.increment(1);
+ } else if (event.keyCode === UP_KEY_CODE) {
+ // Prevents moving scrollbar
+ event.preventDefault();
+ event.stopPropagation();
+ // Moves to previous index
+ this.increment(-1);
+ } else if (event.keyCode === TAB_KEY_CODE) {
+ this.$emit('tab');
+ }
+ },
+ increment(val) {
+ if (this.max === 0) {
+ return;
+ }
+
+ const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max));
+
+ // Return if the index didn't change
+ if (nextIndex === this.index) {
+ return;
+ }
+
+ this.$emit('change', nextIndex);
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
new file mode 100644
index 00000000000..9e9bda8ad3e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql
@@ -0,0 +1,15 @@
+fragment EpicNode on Epic {
+ id
+ iid
+ group {
+ fullPath
+ }
+ title
+ state
+ reference
+ referencePath: reference(full: true)
+ webPath
+ webUrl
+ createdAt
+ closedAt
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
new file mode 100644
index 00000000000..4bb4b586fc9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql
@@ -0,0 +1,16 @@
+#import "./epic.fragment.graphql"
+
+query searchEpics($fullPath: ID!, $search: String, $state: EpicState) {
+ group(fullPath: $fullPath) {
+ epics(
+ search: $search
+ state: $state
+ includeAncestorGroups: true
+ includeDescendantGroups: false
+ ) {
+ nodes {
+ ...EpicNode
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index d1326e96794..cee7c40aa83 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -67,6 +67,11 @@ export default {
required: false,
default: 'id',
},
+ searchBy: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
@@ -112,16 +117,18 @@ export default {
);
},
showDefaultSuggestions() {
- return this.availableDefaultSuggestions.length;
+ return this.availableDefaultSuggestions.length > 0;
},
showRecentSuggestions() {
- return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey;
+ return (
+ this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey
+ );
},
showPreloadedSuggestions() {
- return this.preloadedSuggestions.length && !this.searchKey;
+ return this.preloadedSuggestions.length > 0 && !this.searchKey;
},
showAvailableSuggestions() {
- return this.availableSuggestions.length;
+ return this.availableSuggestions.length > 0;
},
showSuggestions() {
// These conditions must match the template under `#suggestions` slot
@@ -134,13 +141,19 @@ export default {
this.showAvailableSuggestions
);
},
+ searchTerm() {
+ return this.searchBy && this.activeTokenValue
+ ? this.activeTokenValue[this.searchBy]
+ : undefined;
+ },
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.suggestions.length) {
- this.$emit('fetch-suggestions', this.value.data);
+ const search = this.searchTerm ? this.searchTerm : this.value.data;
+ this.$emit('fetch-suggestions', search);
}
},
},
@@ -148,8 +161,10 @@ export default {
methods: {
handleInput: debounce(function debouncedSearch({ data }) {
this.searchKey = data;
- if (!this.suggestionsLoading) {
- this.$emit('fetch-suggestions', data);
+
+ if (!this.suggestionsLoading && !this.activeTokenValue) {
+ const search = this.searchTerm ? this.searchTerm : data;
+ this.$emit('fetch-suggestions', search);
}
}, DEBOUNCE_DELAY),
handleTokenValueSelected(activeTokenValue) {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
index 9f68308808e..9c2f5306654 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -1,22 +1,19 @@
<script>
-import {
- GlDropdownDivider,
- GlFilteredSearchSuggestion,
- GlFilteredSearchToken,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { GlFilteredSearchSuggestion } from '@gitlab/ui';
import createFlash from '~/flash';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
-import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
+import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants';
+import searchEpicsQuery from '../queries/search_epics.query.graphql';
+
+import BaseToken from './base_token.vue';
export default {
- separator: '::&',
+ prefix: '&',
+ separator: '::',
components: {
- GlDropdownDivider,
- GlFilteredSearchToken,
+ BaseToken,
GlFilteredSearchSuggestion,
- GlLoadingIcon,
},
props: {
config: {
@@ -27,11 +24,15 @@ export default {
type: Object,
required: true,
},
+ active: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
epics: this.config.initialEpics || [],
- loading: true,
+ loading: false,
};
},
computed: {
@@ -56,98 +57,73 @@ export default {
}
return this.defaultEpics;
},
- activeEpic() {
- if (this.currentValue && this.epics.length) {
- // Check if current value is an epic ID.
- if (typeof this.currentValue === 'number') {
- return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
- }
-
- // Current value is a string.
- const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator);
- return this.epics.find(
- (epic) =>
- epic.group_full_path === groupPath &&
- epic[this.idProperty] === parseInt(idProperty, 10),
- );
- }
- return null;
- },
- displayText() {
- return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`;
- },
- },
- watch: {
- active: {
- immediate: true,
- handler(newValue) {
- if (!newValue && !this.epics.length) {
- this.searchEpics({ data: this.currentValue });
- }
- },
- },
},
methods: {
- fetchEpicsBySearchTerm({ epicPath = '', search = '' }) {
+ fetchEpics(search = '') {
+ return this.$apollo
+ .query({
+ query: searchEpicsQuery,
+ variables: { fullPath: this.config.fullPath, search },
+ })
+ .then(({ data }) => data.group?.epics.nodes);
+ },
+ fetchEpicsBySearchTerm(search) {
this.loading = true;
- this.config
- .fetchEpics({ epicPath, search })
+ this.fetchEpics(search)
.then((response) => {
- this.epics = Array.isArray(response) ? response : response.data;
+ this.epics = Array.isArray(response) ? response : response?.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
this.loading = false;
});
},
- searchEpics: debounce(function debouncedSearch({ data }) {
- let epicPath = this.activeEpic?.web_url;
-
- // When user visits the page with token value already included in filters
- // We don't have any information about selected token except for its
- // group path and iid joined by separator, so we need to manually
- // compose epic path from it.
- if (data.includes?.(this.$options.separator)) {
- const [groupPath, epicIid] = data.split(this.$options.separator);
- epicPath = `/groups/${groupPath}/-/epics/${epicIid}`;
+ getActiveEpic(epics, data) {
+ if (data && epics.length) {
+ return epics.find((epic) => this.getValue(epic) === data);
}
- this.fetchEpicsBySearchTerm({ epicPath, search: data });
- }, DEBOUNCE_DELAY),
-
+ return undefined;
+ },
getValue(epic) {
- return this.config.useIdValue
- ? String(epic[this.idProperty])
- : `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`;
+ return this.getEpicIdProperty(epic).toString();
+ },
+ displayValue(epic) {
+ return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${
+ epic?.title
+ }`;
+ },
+ getEpicIdProperty(epic) {
+ return getIdFromGraphQLId(epic[this.idProperty]);
},
},
};
</script>
<template>
- <gl-filtered-search-token
+ <base-token
:config="config"
- v-bind="{ ...$props, ...$attrs }"
+ :value="value"
+ :active="active"
+ :suggestions-loading="loading"
+ :suggestions="epics"
+ :get-active-token-value="getActiveEpic"
+ :default-suggestions="availableDefaultEpics"
+ :recent-suggestions-storage-key="config.recentSuggestionsStorageKey"
+ search-by="title"
+ @fetch-suggestions="fetchEpicsBySearchTerm"
v-on="$listeners"
- @input="searchEpics"
>
- <template #view="{ inputValue }">
- {{ activeEpic ? displayText : inputValue }}
+ <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }">
+ {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }}
</template>
- <template #suggestions>
+ <template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion
- v-for="epic in availableDefaultEpics"
- :key="epic.value"
- :value="epic.value"
+ v-for="epic in suggestions"
+ :key="epic.id"
+ :value="getValue(epic)"
>
- {{ epic.text }}
+ {{ epic.title }}
</gl-filtered-search-suggestion>
- <gl-dropdown-divider v-if="availableDefaultEpics.length" />
- <gl-loading-icon v-if="loading" size="sm" />
- <template v-else>
- <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)">
- {{ epic.title }}
- </gl-filtered-search-suggestion>
- </template>
</template>
- </gl-filtered-search-token>
+ </base-token>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
index b2f077f5329..5955f31fc70 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -77,7 +77,7 @@ export default {
};
</script>
<template>
- <div class="issue-assignees">
+ <div>
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
@@ -97,10 +97,9 @@ export default {
</user-avatar-link>
<span
v-if="numHiddenAssignees > 0"
- v-gl-tooltip
+ v-gl-tooltip.bottom
:title="assigneesCounterTooltip"
class="avatar-counter"
- data-placement="bottom"
data-qa-selector="avatar_counter_content"
>{{ assigneeCounterLabel }}</span
>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index 095d1854c8b..8aeff9257a5 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -1,6 +1,12 @@
<script>
import '~/commons/bootstrap';
-import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlTooltip,
+ GlTooltipDirective,
+ GlButton,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { sprintf } from '~/locale';
import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
@@ -22,6 +28,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml,
},
mixins: [relatedIssuableMixin],
props: {
@@ -84,7 +91,7 @@ export default {
/>
</div>
<gl-tooltip :target="() => $refs.iconElementXL">
- <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="stateTitle"></span>
</gl-tooltip>
<gl-icon
v-if="confidential"
@@ -110,7 +117,7 @@ export default {
class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
>
<gl-tooltip :target="() => this.$refs.iconElement">
- <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span>
+ <span v-safe-html="stateTitle"></span>
</gl-tooltip>
<span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
itemPath
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index d6a20984ad1..ce7cbafb97d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -1,5 +1,6 @@
<script>
import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui';
+import { __, n__ } from '~/locale';
export default {
components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton },
@@ -13,12 +14,26 @@ export default {
type: String,
required: true,
},
+ batchSuggestionsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
message: null,
};
},
+ computed: {
+ dropdownText() {
+ if (this.batchSuggestionsCount <= 1) {
+ return __('Apply suggestion');
+ }
+
+ return n__('Apply %d suggestion', 'Apply %d suggestions', this.batchSuggestionsCount);
+ },
+ },
methods: {
onApply() {
this.$emit('apply', this.message);
@@ -29,10 +44,11 @@ export default {
<template>
<gl-dropdown
- :text="__('Apply suggestion')"
+ :text="dropdownText"
:disabled="disabled"
boundary="window"
right
+ lazy
menu-class="gl-w-full!"
data-qa-selector="apply_suggestion_dropdown"
@shown="$refs.commitMessage.$el.focus()"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 77730ada9bb..86f04c78ebe 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -254,7 +254,7 @@ export default {
.then(() => $(this.$refs['markdown-preview']).renderGFM())
.catch(() =>
createFlash({
- message: __('Error rendering markdown preview'),
+ message: __('Error rendering Markdown preview'),
}),
);
},
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 9c954fce322..7d8d8c0b90e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -54,8 +54,8 @@ export default {
applySuggestion(callback, message) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback, message });
},
- applySuggestionBatch() {
- this.$emit('applyBatch');
+ applySuggestionBatch(message) {
+ this.$emit('applyBatch', message);
},
addSuggestionToBatch() {
this.$emit('addToBatch', this.suggestion.id);
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 5fdef0b1a23..f9ae59567b2 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -58,12 +58,19 @@ export default {
isApplyingSingle: false,
};
},
+
computed: {
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
tooltipMessage() {
- return this.canApply ? __('This also resolves this thread') : this.inapplicableReason;
+ if (!this.canApply) {
+ return this.inapplicableReason;
+ }
+
+ return this.batchSuggestionsCount > 1
+ ? __('This also resolves all related threads')
+ : __('This also resolves this thread');
},
isDisableButton() {
return this.isApplying || !this.canApply;
@@ -72,13 +79,30 @@ export default {
if (this.isApplyingSingle || this.batchSuggestionsCount < 2) {
return __('Applying suggestion...');
}
+
return __('Applying suggestions...');
},
isLoggedIn() {
return isLoggedIn();
},
+ showApplySuggestion() {
+ if (!this.isLoggedIn) return false;
+
+ if (this.batchSuggestionsCount >= 1 && !this.isBatched) {
+ return false;
+ }
+
+ return true;
+ },
},
methods: {
+ apply(message) {
+ if (this.batchSuggestionsCount > 1) {
+ this.applySuggestionBatch(message);
+ } else {
+ this.applySuggestion(message);
+ }
+ },
applySuggestion(message) {
if (!this.canApply) return;
this.isApplyingSingle = true;
@@ -88,9 +112,9 @@ export default {
applySuggestionCallback() {
this.isApplyingSingle = false;
},
- applySuggestionBatch() {
+ applySuggestionBatch(message) {
if (!this.canApply) return;
- this.$emit('applyBatch');
+ this.$emit('applyBatch', message);
},
addSuggestionToBatch() {
this.$emit('addToBatch');
@@ -115,45 +139,34 @@ export default {
<gl-loading-icon size="sm" class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="canApply && isBatched" class="d-flex align-items-center">
- <gl-button
- class="btn-inverted js-remove-from-batch-btn btn-grouped"
- :disabled="isApplying"
- @click="removeSuggestionFromBatch"
- >
- {{ __('Remove from batch') }}
- </gl-button>
- <gl-button
- v-gl-tooltip.viewport="__('This also resolves all related threads')"
- class="btn-inverted js-apply-batch-btn btn-grouped"
- data-qa-selector="apply_suggestions_batch_button"
- :disabled="isApplying"
- variant="success"
- @click="applySuggestionBatch"
- >
- {{ __('Apply suggestions') }}
- <span class="badge badge-pill badge-pill-success">
- {{ batchSuggestionsCount }}
- </span>
- </gl-button>
- </div>
- <div v-else class="d-flex align-items-center">
- <gl-button
- v-if="suggestionsCount > 1 && !isDisableButton"
- class="btn-inverted js-add-to-batch-btn btn-grouped"
- data-qa-selector="add_suggestion_batch_button"
- :disabled="isDisableButton"
- @click="addSuggestionToBatch"
- >
- {{ __('Add suggestion to batch') }}
- </gl-button>
+ <div v-else-if="isLoggedIn" class="d-flex align-items-center">
+ <div v-if="isBatched">
+ <gl-button
+ class="btn-inverted js-remove-from-batch-btn btn-grouped"
+ :disabled="isApplying"
+ @click="removeSuggestionFromBatch"
+ >
+ {{ __('Remove from batch') }}
+ </gl-button>
+ </div>
+ <div v-else-if="!isDisableButton && suggestionsCount > 1">
+ <gl-button
+ class="btn-inverted js-add-to-batch-btn btn-grouped"
+ data-qa-selector="add_suggestion_batch_button"
+ :disabled="isDisableButton"
+ @click="addSuggestionToBatch"
+ >
+ {{ __('Add suggestion to batch') }}
+ </gl-button>
+ </div>
<apply-suggestion
- v-if="isLoggedIn"
+ v-if="showApplySuggestion"
v-gl-tooltip.viewport="tooltipMessage"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
+ :batch-suggestions-count="batchSuggestionsCount"
class="gl-ml-3"
- @apply="applySuggestion"
+ @apply="apply"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 63774c6c498..e36cfb3b275 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -68,6 +68,10 @@ export default {
if (this.suggestionsWatch) {
this.suggestionsWatch();
}
+
+ if (this.defaultCommitMessageWatch) {
+ this.defaultCommitMessageWatch();
+ }
},
methods: {
renderSuggestions() {
@@ -123,12 +127,16 @@ export default {
suggestionDiff.suggestionsCount = this.suggestionsCount;
});
+ this.defaultCommitMessageWatch = this.$watch('defaultCommitMessage', () => {
+ suggestionDiff.defaultCommitMessage = this.defaultCommitMessage;
+ });
+
suggestionDiff.$on('apply', ({ suggestionId, callback, message }) => {
this.$emit('apply', { suggestionId, callback, flashContainer: this.$el, message });
});
- suggestionDiff.$on('applyBatch', () => {
- this.$emit('applyBatch', { flashContainer: this.$el });
+ suggestionDiff.$on('applyBatch', (message) => {
+ this.$emit('applyBatch', { message, flashContainer: this.$el });
});
suggestionDiff.$on('addToBatch', (suggestionId) => {
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index d6501a37a35..9ea14ed506c 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -34,12 +34,23 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapGetters(['getUserData']),
renderedNote() {
return renderMarkdown(this.note.body);
},
+ avatarSize() {
+ if (this.line) {
+ return 16;
+ }
+ return 40;
+ },
},
};
</script>
@@ -50,7 +61,7 @@ export default {
<user-avatar-link
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
- :img-size="40"
+ :img-size="avatarSize"
/>
</div>
<div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content">
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
new file mode 100644
index 00000000000..9700117a3da
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
@@ -0,0 +1,34 @@
+import ProjectListItem from './project_list_item.vue';
+
+export default {
+ component: ProjectListItem,
+ title: 'vue_shared/components/project_selector/project_list_item',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { ProjectListItem },
+ props: Object.keys(argTypes),
+ template: '<project-list-item v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ project: {
+ id: '1',
+ name: 'MyProject',
+ name_with_namespace: 'path / to / MyProject',
+ },
+ selected: false,
+};
+
+export const SelectedProject = Template.bind({});
+SelectedProject.args = {
+ ...Default.args,
+ selected: true,
+};
+
+export const MatchedProject = Template.bind({});
+MatchedProject.args = {
+ ...Default.args,
+ matcher: 'proj',
+};
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 36d3696ec36..0bd57c84018 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { isString } from 'lodash';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -8,6 +8,7 @@ import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/def
export default {
name: 'ProjectListItem',
components: { GlIcon, ProjectAvatar, GlButton },
+ directives: { SafeHtml },
props: {
project: {
type: Object,
@@ -58,9 +59,9 @@ export default {
<span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
</div>
<div
+ v-safe-html="highlightedProjectName"
:title="project.name"
class="js-project-name text-truncate"
- v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */"
></div>
</div>
</gl-button>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
index 5c3a6852219..6538de085b0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
@@ -62,7 +62,7 @@ export default {
<div>
<clipboard-button
v-if="!isLoading"
- css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent"
+ css-class="sidebar-collapsed-icon js-dont-change-state gl-rounded-0! gl-hover-bg-transparent"
v-bind="clipboardProps"
/>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 8853dc8b9e3..0ea22eb7aea 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -1,4 +1,5 @@
import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
+import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
@@ -66,10 +67,10 @@ export default {
}
if (isScopedLabel(candidateLabel)) {
- const scopedBase = scopedLabelKey(candidateLabel);
- const currentActiveScopedLabel = state.labels.find(({ title }) => {
- return title.startsWith(scopedBase) && title !== '' && title !== candidateLabel.title;
- });
+ const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`;
+ const currentActiveScopedLabel = state.labels.find(
+ ({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title,
+ );
if (currentActiveScopedLabel) {
currentActiveScopedLabel.set = false;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
index 00c54313292..389eb174c0e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
@@ -1,3 +1,5 @@
+export const SCOPED_LABEL_DELIMITER = '::';
+
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 0fcc67c0ffa..3ee0baf8812 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
-
+import { __, s__, sprintf } from '~/locale';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
-import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils';
+import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils';
export default {
components: {
@@ -48,10 +48,30 @@ export default {
type: String,
required: true,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
showDropdownContentsCreateView: false,
+ localSelectedLabels: [...this.selectedLabels],
+ isDirty: false,
};
},
computed: {
@@ -64,28 +84,66 @@ export default {
dropdownTitle() {
return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
},
+ buttonText() {
+ if (!this.localSelectedLabels.length) {
+ return this.dropdownButtonText || __('Label');
+ } else if (this.localSelectedLabels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: this.localSelectedLabels[0].title,
+ remainingLabelCount: this.localSelectedLabels.length - 1,
+ });
+ }
+ return this.localSelectedLabels[0].title;
+ },
showDropdownFooter() {
- return (
- !this.showDropdownContentsCreateView &&
- (this.isDropdownVariantSidebar(this.variant) ||
- this.isDropdownVariantEmbedded(this.variant))
- );
+ return !this.showDropdownContentsCreateView && !this.isStandalone;
+ },
+ isStandalone() {
+ return isDropdownVariantStandalone(this.variant);
},
},
- methods: {
- showDropdown() {
- this.$refs.dropdown.show();
+ watch: {
+ localSelectedLabels: {
+ handler() {
+ this.isDirty = true;
+ },
+ deep: true,
+ },
+ isVisible(newVal) {
+ if (newVal) {
+ this.$refs.dropdown.show();
+ this.isDirty = false;
+ } else {
+ this.$refs.dropdown.hide();
+ this.setLabels();
+ }
},
+ selectedLabels(newVal) {
+ this.localSelectedLabels = newVal;
+ },
+ },
+ methods: {
toggleDropdownContentsCreateView() {
this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
},
toggleDropdownContent() {
this.toggleDropdownContentsCreateView();
// Required to recalculate dropdown position as its size changes
- this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
+ if (this.$refs.dropdown?.$refs.dropdown) {
+ this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
+ }
+ },
+ setLabels() {
+ if (!this.isDirty) {
+ return;
+ }
+ this.$emit('setLabels', this.localSelectedLabels);
+ },
+ handleDropdownHide() {
+ if (!isDropdownVariantSidebar(this.variant)) {
+ this.setLabels();
+ }
},
- isDropdownVariantSidebar,
- isDropdownVariantEmbedded,
},
};
</script>
@@ -93,14 +151,16 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
- :text="dropdownButtonText"
+ :text="buttonText"
class="gl-w-full gl-mt-2"
data-qa-selector="labels_dropdown_content"
+ @hide="handleDropdownHide"
>
<template #header>
<div
- v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)"
+ v-if="!isStandalone"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-header"
>
<gl-button
v-if="showDropdownContentsCreateView"
@@ -119,27 +179,33 @@ export default {
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
+ data-testid="close-button"
@click="$emit('closeDropdown')"
/>
</div>
</template>
- <component
- :is="dropdownContentsView"
- :selected-labels="selectedLabels"
- :allow-multiselect="allowMultiselect"
- @hideCreateView="toggleDropdownContentsCreateView"
- @setLabels="$emit('setLabels', $event)"
- />
+ <template #default>
+ <component
+ :is="dropdownContentsView"
+ v-model="localSelectedLabels"
+ :selected-labels="selectedLabels"
+ :allow-multiselect="allowMultiselect"
+ :issuable-type="issuableType"
+ :full-path="fullPath"
+ :attr-workspace-path="attrWorkspacePath"
+ @hideCreateView="toggleDropdownContentsCreateView"
+ />
+ </template>
<template #footer>
<div v-if="showDropdownFooter" data-testid="dropdown-footer">
<gl-dropdown-item
v-if="allowLabelCreate"
data-testid="create-label-button"
- @click.native.capture.stop="toggleDropdownContent"
+ @click.capture.native.stop="toggleDropdownContent"
>
{{ footerCreateLabelTitle }}
</gl-dropdown-item>
- <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop>
+ <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
{{ footerManageLabelTitle }}
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index 2e31b386fdd..a2ed08e6b28 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -2,9 +2,10 @@
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer';
import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
+import { labelsQueries } from '~/sidebar/constants';
import createLabelMutation from './graphql/create_label.mutation.graphql';
-import projectLabelsQuery from './graphql/project_labels.query.graphql';
const errorMessage = __('Error creating label.');
@@ -18,9 +19,19 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: {
- projectPath: {
- default: '',
+ props: {
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: false,
+ default: undefined,
},
},
data() {
@@ -38,6 +49,27 @@ export default {
const colorsMap = gon.suggested_label_colors;
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
},
+ mutationVariables() {
+ if (this.issuableType === IssuableType.Epic) {
+ return {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ groupPath: this.fullPath,
+ };
+ }
+
+ return this.attrWorkspacePath !== undefined
+ ? {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ groupPath: this.attrWorkspacePath,
+ }
+ : {
+ title: this.labelTitle,
+ color: this.selectedColor,
+ projectPath: this.fullPath,
+ };
+ },
},
methods: {
getColorCode(color) {
@@ -51,8 +83,8 @@ export default {
},
updateLabelsInCache(store, label) {
const sourceData = store.readQuery({
- query: projectLabelsQuery,
- variables: { fullPath: this.projectPath, searchTerm: '' },
+ query: labelsQueries[this.issuableType].workspaceQuery,
+ variables: { fullPath: this.fullPath, searchTerm: '' },
});
const collator = new Intl.Collator('en');
@@ -63,8 +95,8 @@ export default {
});
store.writeQuery({
- query: projectLabelsQuery,
- variables: { fullPath: this.projectPath, searchTerm: '' },
+ query: labelsQueries[this.issuableType].workspaceQuery,
+ variables: { fullPath: this.fullPath, searchTerm: '' },
data,
});
},
@@ -75,11 +107,7 @@ export default {
data: { labelCreate },
} = await this.$apollo.mutate({
mutation: createLabelMutation,
- variables: {
- title: this.labelTitle,
- color: this.selectedColor,
- projectPath: this.projectPath,
- },
+ variables: this.mutationVariables,
update: (
store,
{
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index 857367a0721..e6a25362ff0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,12 +1,18 @@
<script>
-import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlDropdownForm,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIntersectionObserver,
+} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale';
-import projectLabelsQuery from './graphql/project_labels.query.graphql';
+import { labelsQueries } from '~/sidebar/constants';
import LabelItem from './label_item.vue';
export default {
@@ -15,9 +21,12 @@ export default {
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
+ GlIntersectionObserver,
LabelItem,
},
- inject: ['projectPath'],
+ model: {
+ prop: 'localSelectedLabels',
+ },
props: {
selectedLabels: {
type: Array,
@@ -27,30 +36,44 @@ export default {
type: Boolean,
required: true,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ localSelectedLabels: {
+ type: Array,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
searchKey: '',
labels: [],
- localSelectedLabels: [...this.selectedLabels],
+ isVisible: false,
};
},
apollo: {
labels: {
- query: projectLabelsQuery,
+ query() {
+ return labelsQueries[this.issuableType].workspaceQuery;
+ },
variables() {
return {
- fullPath: this.projectPath,
+ fullPath: this.fullPath,
searchTerm: this.searchKey,
};
},
skip() {
- return this.searchKey.length === 1;
+ return this.searchKey.length === 1 || !this.isVisible;
},
update: (data) => data.workspace?.labels?.nodes || [],
async result() {
if (this.$refs.searchInput) {
- await this.$nextTick();
+ await this.$nextTick;
this.$refs.searchInput.focusInput();
}
},
@@ -64,7 +87,7 @@ export default {
return this.$apollo.queries.labels.loading;
},
localSelectedLabelsIds() {
- return this.localSelectedLabels.map((label) => label.id);
+ return this.localSelectedLabels.map((label) => getIdFromGraphQLId(label.id));
},
visibleLabels() {
if (this.searchKey) {
@@ -82,7 +105,6 @@ export default {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
- this.$emit('setLabels', this.localSelectedLabels);
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
@@ -109,16 +131,21 @@ export default {
}
},
updateSelectedLabels(label) {
+ let labels;
if (this.isLabelSelected(label)) {
- this.localSelectedLabels = this.localSelectedLabels.filter(
- ({ id }) => id !== getIdFromGraphQLId(label.id),
+ labels = this.localSelectedLabels.filter(
+ ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id,
);
} else {
- this.localSelectedLabels.push({
- ...label,
- id: getIdFromGraphQLId(label.id),
- });
+ labels = [
+ ...this.localSelectedLabels,
+ {
+ ...label,
+ id: getIdFromGraphQLId(label.id),
+ },
+ ];
}
+ this.$emit('input', labels);
},
handleLabelClick(label) {
this.updateSelectedLabels(label);
@@ -129,46 +156,52 @@ export default {
setSearchKey(value) {
this.searchKey = value;
},
+ onDropdownAppear() {
+ this.isVisible = true;
+ this.$refs.searchInput.focusInput();
+ },
},
};
</script>
<template>
- <gl-dropdown-form class="labels-select-contents-list js-labels-list">
- <gl-search-box-by-type
- ref="searchInput"
- :value="searchKey"
- :disabled="labelsFetchInProgress"
- data-qa-selector="dropdown_input_field"
- data-testid="dropdown-input-field"
- @input="debouncedSearchKeyUpdate"
- />
- <div ref="labelsListContainer" data-testid="dropdown-content">
- <gl-loading-icon
- v-if="labelsFetchInProgress"
- class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full"
- size="md"
+ <gl-intersection-observer @appear="onDropdownAppear">
+ <gl-dropdown-form class="labels-select-contents-list js-labels-list">
+ <gl-search-box-by-type
+ ref="searchInput"
+ :value="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ data-testid="dropdown-input-field"
+ @input="debouncedSearchKeyUpdate"
/>
- <template v-else>
- <gl-dropdown-item
- v-for="label in visibleLabels"
- :key="label.id"
- :is-checked="isLabelSelected(label)"
- :is-check-centered="true"
- :is-check-item="true"
- data-testid="labels-list"
- @click.native.capture.stop="handleLabelClick(label)"
- >
- <label-item :label="label" />
- </gl-dropdown-item>
- <gl-dropdown-item
- v-show="showNoMatchingResultsMessage"
- class="gl-p-3 gl-text-center"
- data-testid="no-results"
- >
- {{ __('No matching results') }}
- </gl-dropdown-item>
- </template>
- </div>
- </gl-dropdown-form>
+ <div ref="labelsListContainer" data-testid="dropdown-content">
+ <gl-loading-icon
+ v-if="labelsFetchInProgress"
+ class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3"
+ size="md"
+ />
+ <template v-else>
+ <gl-dropdown-item
+ v-for="label in visibleLabels"
+ :key="label.id"
+ :is-checked="isLabelSelected(label)"
+ :is-check-centered="true"
+ :is-check-item="true"
+ data-testid="labels-list"
+ @click.native.capture.stop="handleLabelClick(label)"
+ >
+ <label-item :label="label" />
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-show="showNoMatchingResultsMessage"
+ class="gl-p-3 gl-text-center"
+ data-testid="no-results"
+ >
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </div>
+ </gl-dropdown-form>
+ </gl-intersection-observer>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
new file mode 100644
index 00000000000..a2e8579486f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql
@@ -0,0 +1,15 @@
+query epicLabels($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ issuable: epic(iid: $iid) {
+ id
+ labels {
+ nodes {
+ id
+ title
+ color
+ description
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
new file mode 100644
index 00000000000..acc9bcd2015
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql
@@ -0,0 +1,12 @@
+query groupLabels($fullPath: ID!, $searchTerm: String) {
+ workspace: group(fullPath: $fullPath) {
+ labels(searchTerm: $searchTerm, onlyGroupLabels: true) {
+ nodes {
+ id
+ title
+ color
+ description
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 3c834770563..6bd43da2203 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -1,21 +1,18 @@
<script>
-import Vue from 'vue';
-import Vuex from 'vuex';
+import createFlash from '~/flash';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { labelsQueries } from '~/sidebar/constants';
import { DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
-import issueLabelsQuery from './graphql/issue_labels.query.graphql';
import {
isDropdownVariantSidebar,
isDropdownVariantStandalone,
isDropdownVariantEmbedded,
} from './utils';
-Vue.use(Vuex);
-
export default {
components: {
DropdownValue,
@@ -23,8 +20,21 @@ export default {
DropdownValueCollapsed,
SidebarEditableItem,
},
- inject: ['iid', 'projectPath', 'allowLabelEdit'],
+ inject: {
+ allowLabelEdit: {
+ default: false,
+ },
+ },
props: {
+ iid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
allowLabelRemove: {
type: Boolean,
required: false,
@@ -90,43 +100,60 @@ export default {
required: false,
default: false,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
+ attrWorkspacePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
contentIsOnViewport: true,
- issueLabels: [],
+ issuableLabels: [],
};
},
+ computed: {
+ isLoading() {
+ return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
+ },
+ },
apollo: {
- issueLabels: {
- query: issueLabelsQuery,
+ issuableLabels: {
+ query() {
+ return labelsQueries[this.issuableType].issuableQuery;
+ },
+ skip() {
+ return !isDropdownVariantSidebar(this.variant);
+ },
variables() {
return {
iid: this.iid,
- fullPath: this.projectPath,
+ fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.issuable?.labels.nodes || [];
},
+ error() {
+ createFlash({ message: __('Error fetching labels.') });
+ },
},
},
methods: {
handleDropdownClose(labels) {
- if (labels.length) this.$emit('updateSelectedLabels', labels);
- this.$emit('onDropdownClose');
+ this.$emit('updateSelectedLabels', labels);
+ this.collapseEditableItem();
},
- collapseDropdown() {
- this.$refs.editable.collapse();
+ collapseEditableItem() {
+ this.$refs.editable?.collapse();
},
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
- showDropdown() {
- this.$nextTick(() => {
- this.$refs.dropdownContents.showDropdown();
- });
- },
isDropdownVariantSidebar,
isDropdownVariantStandalone,
isDropdownVariantEmbedded,
@@ -145,20 +172,19 @@ export default {
<template v-if="isDropdownVariantSidebar(variant)">
<dropdown-value-collapsed
ref="dropdownButtonCollapsed"
- :labels="issueLabels"
+ :labels="issuableLabels"
@onValueClick="handleCollapsedValueClick"
/>
<sidebar-editable-item
ref="editable"
:title="__('Labels')"
- :loading="labelsSelectInProgress"
+ :loading="isLoading"
:can-edit="allowLabelEdit"
- @open="showDropdown"
>
<template #collapsed>
<dropdown-value
:disable-labels="labelsSelectInProgress"
- :selected-labels="issueLabels"
+ :selected-labels="issuableLabels"
:allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@@ -170,7 +196,7 @@ export default {
<template #default="{ edit }">
<dropdown-value
:disable-labels="labelsSelectInProgress"
- :selected-labels="issueLabels"
+ :selected-labels="issuableLabels"
:allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam"
@@ -180,8 +206,6 @@ export default {
<slot></slot>
</dropdown-value>
<dropdown-contents
- v-if="edit"
- ref="dropdownContents"
:dropdown-button-text="dropdownButtonText"
:allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle"
@@ -190,11 +214,30 @@ export default {
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
:variant="variant"
- @closeDropdown="collapseDropdown"
+ :issuable-type="issuableType"
+ :is-visible="edit"
+ :full-path="fullPath"
+ :attr-workspace-path="attrWorkspacePath"
@setLabels="handleDropdownClose"
+ @closeDropdown="collapseEditableItem"
/>
</template>
</sidebar-editable-item>
</template>
+ <dropdown-contents
+ v-else
+ ref="dropdownContents"
+ :allow-multiselect="allowMultiselect"
+ :dropdown-button-text="dropdownButtonText"
+ :labels-list-title="labelsListTitle"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ :labels-create-title="labelsCreateTitle"
+ :selected-labels="selectedLabels"
+ :variant="variant"
+ :issuable-type="issuableType"
+ :full-path="fullPath"
+ @setLabels="handleDropdownClose"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
index d2afc02233e..294e5bd9f90 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
- title: 'vue_shared/components/todo_toggle/todo_button',
+ title: 'vue_shared/components/sidebar/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index afb1ea702fa..0a7a22ed3a8 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -45,7 +45,7 @@ export default {
data() {
return {
dragCounter: 0,
- isDragDataValid: false,
+ isDragDataValid: true,
};
},
computed: {
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js
new file mode 100644
index 00000000000..256db2ea1ce
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js
@@ -0,0 +1,5 @@
+// Types of obstacles to user deletion
+export const OBSTACLE_TYPES = Object.freeze({
+ oncallSchedules: 'ONCALL_SCHEDULE',
+ escalationPolicies: 'ESCALATION_POLICY',
+});
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
new file mode 100644
index 00000000000..d2030c14029
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
@@ -0,0 +1,37 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import { OBSTACLE_TYPES } from './constants';
+import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
+
+export default {
+ component: UserDeletionObstaclesList,
+ title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { UserDeletionObstaclesList },
+ props: Object.keys(argTypes),
+ template: '<user-deletion-obstacles-list v-bind="$props" v-on="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ obstacles: [
+ {
+ type: OBSTACLE_TYPES.oncallSchedules,
+ name: 'APAC',
+ url: 'https://domain.com/group/main-application/oncall_schedules',
+ projectName: 'main-application',
+ projectUrl: 'https://domain.com/group/main-application',
+ },
+ {
+ type: OBSTACLE_TYPES.escalationPolicies,
+ name: 'Engineering On-call',
+ url: 'https://domain.com/group/microservice-backend/escalation_policies',
+ projectName: 'Microservice Backend',
+ projectUrl: 'https://domain.com/group/microservice-backend',
+ },
+ ],
+ userName: 'Thomspon Smith',
+ isCurrentUser: false,
+};
diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
index e37a663ace3..1eea660d527 100644
--- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue
@@ -1,6 +1,16 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
+import { OBSTACLE_TYPES } from './constants';
+
+const OBSTACLE_TEXT = {
+ [OBSTACLE_TYPES.oncallSchedules]: s__(
+ 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}',
+ ),
+ [OBSTACLE_TYPES.escalationPolicies]: s__(
+ 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}',
+ ),
+};
export default {
components: {
@@ -8,7 +18,7 @@ export default {
GlLink,
},
props: {
- schedules: {
+ obstacles: {
type: Array,
required: true,
},
@@ -45,6 +55,15 @@ export default {
);
},
},
+ methods: {
+ textForObstacle(obstacle) {
+ return OBSTACLE_TEXT[obstacle.type];
+ },
+ urlForObstacle(obstacle) {
+ // Fallback to scheduleUrl for backwards compatibility
+ return obstacle.url || obstacle.scheduleUrl;
+ },
+ },
};
</script>
@@ -52,17 +71,15 @@ export default {
<div>
<p data-testid="title">{{ title }}</p>
- <ul data-testid="schedules-list">
- <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`">
- <gl-sprintf
- :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')"
- >
- <template #schedule>
- <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link>
+ <ul data-testid="obstacles-list">
+ <li v-for="(obstacle, index) in obstacles" :key="`${obstacle.name}-${index}`">
+ <gl-sprintf :message="textForObstacle(obstacle)">
+ <template #obstacle>
+ <gl-link :href="urlForObstacle(obstacle)" target="_blank">{{ obstacle.name }}</gl-link>
</template>
<template #project>
- <gl-link :href="schedule.projectUrl" target="_blank">{{
- schedule.projectName
+ <gl-link :href="obstacle.projectUrl" target="_blank">{{
+ obstacle.projectName
}}</gl-link>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js
new file mode 100644
index 00000000000..502302a1ef2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js
@@ -0,0 +1,19 @@
+import { OBSTACLE_TYPES } from './constants';
+
+const addTypeToObstacles = (obstacles, type) => {
+ if (!obstacles) return [];
+
+ return obstacles?.map((obstacle) => ({ type, ...obstacle }));
+};
+
+// For use with user objects formatted via internal REST API.
+// If the removal/deletion of a user could cause critical
+// problems, return a single array containing all affected
+// associations including their type.
+export const parseUserDeletionObstacles = (user) => {
+ if (!user) return [];
+
+ return Object.keys(OBSTACLE_TYPES).flatMap((type) => {
+ return addTypeToObstacles(user[type], OBSTACLE_TYPES[type]);
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 74616763f8f..05e0c3b0be3 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -93,19 +93,27 @@ export default {
</div>
<div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <gl-icon name="profile" class="gl-flex-shrink-0" />
<span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
+ <gl-icon name="work" class="gl-flex-shrink-0" />
<span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
</div>
+ <div v-if="user.location" class="gl-display-flex gl-mb-2">
+ <gl-icon name="location" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div
+ v-if="user.localTime && !user.bot"
+ class="gl-display-flex gl-mb-2"
+ data-testid="user-popover-local-time"
+ >
+ <gl-icon name="clock" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.localTime }}</span>
+ </div>
</div>
- <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex">
- <gl-icon name="location" class="gl-text-gray-400 flex-shrink-0" />
- <span class="gl-ml-2">{{ user.location }}</span>
- </div>
- <div v-if="statusHtml" class="js-user-status gl-mt-3">
+ <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
<span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot" class="gl-text-blue-500">
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index df0981aea7a..6da2d39a95a 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -92,7 +92,10 @@ export default {
const handleOptions = this.needsToFork
? {
href: '#modal-confirm-fork-edit',
- handle: () => this.showModal('#modal-confirm-fork-edit'),
+ handle: () => {
+ this.$emit('edit', 'simple');
+ this.showModal('#modal-confirm-fork-edit');
+ },
}
: { href: this.editUrl };
@@ -128,7 +131,10 @@ export default {
const handleOptions = this.needsToFork
? {
href: '#modal-confirm-fork-webide',
- handle: () => this.showModal('#modal-confirm-fork-webide'),
+ handle: () => {
+ this.$emit('edit', 'ide');
+ this.showModal('#modal-confirm-fork-webide');
+ },
}
: { href: this.webIdeUrl };
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index 692f2769b88..779b04dc2bd 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -1,4 +1,3 @@
-import { merge } from 'lodash';
import { s__ } from '~/locale';
/**
@@ -21,8 +20,15 @@ const defaultFeedbackMap = {
},
};
-const getFeedbackForElement = (feedbackMap, el) =>
- Object.values(feedbackMap).find((f) => f.isInvalid(el))?.message || el.validationMessage;
+const getFeedbackForElement = (feedbackMap, el) => {
+ const field = Object.values(feedbackMap).find((f) => f.isInvalid(el));
+ let elMessage = null;
+ if (field) {
+ elMessage = el.getAttribute('validation-message');
+ }
+
+ return field?.message || elMessage || el.validationMessage;
+};
const focusFirstInvalidInput = (e) => {
const { target: formEl } = e;
@@ -68,6 +74,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
/**
* Takes an object that allows to add or change custom feedback messages.
+ * See possibilities here: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
*
* The passed in object will be merged with the built-in feedback
* so it is possible to override a built-in message.
@@ -75,7 +82,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @example
* validate({
* tooLong: {
- * check: el => el.validity.tooLong === true,
+ * isInvalid: el => el.validity.tooLong === true,
* message: 'Your custom feedback'
* }
* })
@@ -91,7 +98,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @returns {{ inserted: function, update: function }} validateDirective
*/
export default function initValidation(customFeedbackMap = {}) {
- const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
+ const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap };
const elDataMap = new WeakMap();
return {
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
index 0ff858e6afc..42272c222fc 100644
--- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -100,6 +100,7 @@ export default {
:loading="isLoading"
:variant="variant"
:category="category"
+ :data-qa-selector="`${feature.type}_mr_button`"
@click="mutate"
>{{ $options.i18n.buttonLabel }}</gl-button
>
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index ad40ea6a964..12f2bc71505 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -50,7 +50,7 @@ export default {
required: false,
default: '',
},
- secretScanningComparisonPath: {
+ secretDetectionComparisonPath: {
type: String,
required: false,
default: '',
@@ -149,8 +149,8 @@ export default {
this.canShowCounts = true;
}
- if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) {
- this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath);
+ if (this.secretDetectionComparisonPath && this.hasSecretDetectionReports) {
+ this.setSecretDetectionDiffEndpoint(this.secretDetectionComparisonPath);
this.fetchSecretDetectionDiff();
this.canShowCounts = true;
}
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
index 4f92e181f9f..62a51abe038 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js
@@ -1,3 +1,4 @@
+import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
import { fetchDiffData } from '../../utils';
import * as types from './mutation_types';
@@ -14,7 +15,7 @@ export const receiveDiffError = ({ commit }, response) =>
export const fetchDiff = ({ state, rootState, dispatch }) => {
dispatch('requestDiff');
- return fetchDiffData(rootState, state.paths.diffEndpoint, 'sast')
+ return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SAST)
.then((data) => {
dispatch('receiveDiffSuccess', data);
})
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
index e3ae5435f5d..722dcce3075 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js
@@ -1,3 +1,4 @@
+import { REPORT_TYPE_SECRET_DETECTION } from '~/vue_shared/security_reports/constants';
import { fetchDiffData } from '../../utils';
import * as types from './mutation_types';
@@ -14,7 +15,7 @@ export const receiveDiffError = ({ commit }, response) =>
export const fetchDiff = ({ state, rootState, dispatch }) => {
dispatch('requestDiff');
- return fetchDiffData(rootState, state.paths.diffEndpoint, 'secret_detection')
+ return fetchDiffData(rootState, state.paths.diffEndpoint, REPORT_TYPE_SECRET_DETECTION)
.then((data) => {
dispatch('receiveDiffSuccess', data);
})