summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/learn_gitlab/issue_created.svg65
-rw-r--r--app/assets/images/learn_gitlab/section_deploy.svg16
-rw-r--r--app/assets/images/learn_gitlab/section_plan.svg1
-rw-r--r--app/assets/images/learn_gitlab/section_workspace.svg1
-rw-r--r--app/assets/javascripts/access_tokens/index.js16
-rw-r--r--app/assets/javascripts/activities.js33
-rw-r--r--app/assets/javascripts/admin/statistics_panel/constants.js2
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue2
-rw-r--r--app/assets/javascripts/admin/users/constants.js2
-rw-r--r--app/assets/javascripts/admin/users/new.js55
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_empty_state.vue2
-rw-r--r--app/assets/javascripts/alert_management/list.js4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue18
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue19
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue430
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue123
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js94
-rw-r--r--app/assets/javascripts/alerts_settings/index.js40
-rw-r--r--app/assets/javascripts/alerts_settings/utils/error_messages.js8
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/charts_config.js2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue2
-rw-r--r--app/assets/javascripts/api.js30
-rw-r--r--app/assets/javascripts/api/user_api.js3
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/badges/components/badge.vue7
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue14
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js16
-rw-r--r--app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js54
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js594
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js65
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js3
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js20
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue8
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js29
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js70
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js21
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue6
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js3
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue7
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js26
-rw-r--r--app/assets/javascripts/blob/stl_viewer.js4
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue1
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js4
-rw-r--r--app/assets/javascripts/boards/boards_util.js33
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue56
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue119
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_trigger.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_blocked_icon.vue192
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue28
-rw-r--r--app/assets/javascripts/boards/components/board_card_loading_skeleton.vue26
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue29
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue96
-rw-r--r--app/assets/javascripts/boards/components/board_extra_actions.vue57
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue33
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue15
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_deprecated.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue8
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue12
-rw-r--r--app/assets/javascripts/boards/components/filtered_search.vue54
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue84
-rw-r--r--app/assets/javascripts/boards/components/modal/filters.js27
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.vue80
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue80
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue151
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue141
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.vue49
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.vue42
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue4
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue7
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue12
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue19
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue18
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue25
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue (renamed from app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue)48
-rw-r--r--app/assets/javascripts/boards/components/toggle_focus.vue4
-rw-r--r--app/assets/javascripts/boards/config_toggle.js5
-rw-r--r--app/assets/javascripts/boards/constants.js29
-rw-r--r--app/assets/javascripts/boards/ee_functions.js1
-rw-r--r--app/assets/javascripts/boards/filtered_search.js25
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql16
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js71
-rw-r--r--app/assets/javascripts/boards/mixins/board_new_issue.js6
-rw-r--r--app/assets/javascripts/boards/mixins/modal_footer.js1
-rw-r--r--app/assets/javascripts/boards/mixins/modal_mixins.js12
-rw-r--r--app/assets/javascripts/boards/stores/actions.js306
-rw-r--r--app/assets/javascripts/boards/stores/getters.js12
-rw-r--r--app/assets/javascripts/boards/stores/modal_store.js95
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js15
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js70
-rw-r--r--app/assets/javascripts/branches/branch_sort_dropdown.js25
-rw-r--r--app/assets/javascripts/branches/components/sort_dropdown.vue88
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js8
-rw-r--r--app/assets/javascripts/captcha/apollo_captcha_link.js37
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue6
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue10
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue16
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue7
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js9
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue13
-rw-r--r--app/assets/javascripts/clusters/constants.js1
-rw-r--r--app/assets/javascripts/clusters/forms/show/index.js5
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js4
-rw-r--r--app/assets/javascripts/clusters_list/components/node_error_help_text.vue2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js4
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue33
-rw-r--r--app/assets/javascripts/commits.js2
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue18
-rw-r--r--app/assets/javascripts/content_editor/constants.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js38
-rw-r--r--app/assets/javascripts/content_editor/index.js2
-rw-r--r--app/assets/javascripts/content_editor/services/create_editor.js60
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js73
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue11
-rw-r--r--app/assets/javascripts/contributors/index.js9
-rw-r--r--app/assets/javascripts/contributors/stores/index.js6
-rw-r--r--app/assets/javascripts/contributors/stores/state.js4
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js30
-rw-r--r--app/assets/javascripts/delete_label_modal.js16
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue28
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue35
-rw-r--r--app/assets/javascripts/deploy_freeze/store/actions.js47
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutations.js11
-rw-r--r--app/assets/javascripts/deploy_freeze/store/state.js2
-rw-r--r--app/assets/javascripts/deploy_tokens/components/revoke_button.vue81
-rw-r--r--app/assets/javascripts/deploy_tokens/init_revoke_button.js26
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js23
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue7
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue16
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue3
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue24
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue8
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue3
-rw-r--r--app/assets/javascripts/design_management/index.js4
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue5
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue7
-rw-r--r--app/assets/javascripts/diffs/components/app.vue73
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue61
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue52
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue18
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue36
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue40
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue1
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue7
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue14
-rw-r--r--app/assets/javascripts/diffs/index.js4
-rw-r--r--app/assets/javascripts/diffs/store/actions.js93
-rw-r--r--app/assets/javascripts/diffs/store/getters.js12
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js6
-rw-r--r--app/assets/javascripts/diffs/store/modules/index.js6
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js9
-rw-r--r--app/assets/javascripts/diffs/store/utils.js17
-rw-r--r--app/assets/javascripts/diffs/utils/interoperability.js49
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js15
-rw-r--r--app/assets/javascripts/droplab/hook_button.js2
-rw-r--r--app/assets/javascripts/editor/extensions/editor_lite_extension_base.js76
-rw-r--r--app/assets/javascripts/emoji/awards_app/index.js43
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/actions.js51
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/index.js20
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/emoji/awards_app/store/mutations.js23
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue3
-rw-r--r--app/assets/javascripts/ensure_data.js56
-rw-r--r--app/assets/javascripts/environments/components/enable_review_app_modal.vue19
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue1
-rw-r--r--app/assets/javascripts/environments/index.js1
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_actions.vue2
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue6
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/getters.js4
-rw-r--r--app/assets/javascripts/experimentation/components/experiment.vue15
-rw-r--r--app/assets/javascripts/experimentation/constants.js2
-rw-r--r--app/assets/javascripts/experimentation/utils.js18
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue8
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue19
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue1
-rw-r--r--app/assets/javascripts/feature_flags/components/user_lists_table.vue7
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_popover.vue1
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js8
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js8
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js7
-rw-r--r--app/assets/javascripts/flash.js74
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js31
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue4
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js2
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql11
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql5
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql4
-rw-r--r--app/assets/javascripts/group.js6
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue14
-rw-r--r--app/assets/javascripts/header.js4
-rw-r--r--app/assets/javascripts/ide/components/cannot_push_code_alert.vue40
-rw-r--r--app/assets/javascripts/ide/components/ide.vue13
-rw-r--r--app/assets/javascripts/ide/components/ide_status_mr.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue1
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue4
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown_button.vue4
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue2
-rw-r--r--app/assets/javascripts/ide/constants.js2
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js2
-rw-r--r--app/assets/javascripts/ide/lib/languages/README.md10
-rw-r--r--app/assets/javascripts/ide/messages.js12
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js6
-rw-r--r--app/assets/javascripts/ide/stores/getters.js38
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue27
-rw-r--r--app/assets/javascripts/import_entities/constants.js58
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue2
-rw-r--r--app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue5
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue11
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue54
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue147
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue51
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue4
-rw-r--r--app/assets/javascripts/integrations/edit/index.js9
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_list.vue59
-rw-r--r--app/assets/javascripts/integrations/index/components/integrations_table.vue95
-rw-r--r--app/assets/javascripts/integrations/index/index.js23
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_modal.vue4
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_trigger.vue8
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_modal.js12
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_trigger.js6
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue19
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue71
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue16
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_export_buttons.vue31
-rw-r--r--app/assets/javascripts/issuable/components/issuable_by_email.vue6
-rw-r--r--app/assets/javascripts/issuable/init_csv_import_export_buttons.js11
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js4
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js23
-rw-r--r--app/assets/javascripts/issuable_index.js2
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue6
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue30
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_tabs.vue13
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_body.vue5
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_edit_form.vue8
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue6
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_title.vue7
-rw-r--r--app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql16
-rw-r--r--app/assets/javascripts/issuable_type_selector/components/info_popover.vue41
-rw-r--r--app/assets/javascripts/issuable_type_selector/index.js16
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue2
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue12
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue25
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue7
-rw-r--r--app/assets/javascripts/issues_list/components/issuables_list_app.vue3
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue363
-rw-r--r--app/assets/javascripts/issues_list/constants.js188
-rw-r--r--app/assets/javascripts/issues_list/index.js39
-rw-r--r--app/assets/javascripts/jira_connect/api.js24
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue64
-rw-r--r--app/assets/javascripts/jira_connect/components/group_item_name.vue34
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list.vue96
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list_item.vue41
-rw-r--r--app/assets/javascripts/jira_connect/components/subscriptions_list.vue109
-rw-r--r--app/assets/javascripts/jira_connect/index.js54
-rw-r--r--app/assets/javascripts/jira_connect/utils.js40
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue9
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue20
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue22
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue3
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue4
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue14
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql52
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js33
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue67
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue85
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue66
-rw-r--r--app/assets/javascripts/labels_select.js33
-rw-r--r--app/assets/javascripts/lib/graphql.js2
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js13
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js76
-rw-r--r--app/assets/javascripts/lib/utils/forms.js94
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js4
-rw-r--r--app/assets/javascripts/lib/utils/webpack.js6
-rw-r--r--app/assets/javascripts/main.js55
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue7
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue1
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue7
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue28
-rw-r--r--app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue7
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue10
-rw-r--r--app/assets/javascripts/members/components/app.vue14
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue3
-rw-r--r--app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue10
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue7
-rw-r--r--app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue10
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue23
-rw-r--r--app/assets/javascripts/members/components/modals/remove_group_link_modal.vue19
-rw-r--r--app/assets/javascripts/members/components/table/expiration_datepicker.vue7
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue13
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue3
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue7
-rw-r--r--app/assets/javascripts/members/index.js32
-rw-r--r--app/assets/javascripts/members/store/index.js1
-rw-r--r--app/assets/javascripts/members/store/state.js6
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue32
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue25
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue17
-rw-r--r--app/assets/javascripts/merge_conflicts/constants.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue129
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_service.js16
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js432
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js92
-rw-r--r--app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js7
-rw-r--r--app/assets/javascripts/merge_conflicts/store/actions.js5
-rw-r--r--app/assets/javascripts/merge_conflicts/store/getters.js2
-rw-r--r--app/assets/javascripts/merge_request/components/status_box.vue2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/milestone_select.js12
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js113
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/links_section.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue8
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js8
-rw-r--r--app/assets/javascripts/mr_notes/stores/actions.js35
-rw-r--r--app/assets/javascripts/mr_notes/stores/modules/index.js4
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/mutations.js6
-rw-r--r--app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue77
-rw-r--r--app/assets/javascripts/namespaces/cascading_settings/index.js15
-rw-r--r--app/assets/javascripts/notes.js31
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue128
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue13
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue29
-rw-r--r--app/assets/javascripts/notes/components/note_attachment.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue10
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue26
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue7
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue11
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js17
-rw-r--r--app/assets/javascripts/notes/stores/getters.js27
-rw-r--r--app/assets/javascripts/operation_settings/components/metrics_settings.vue4
-rw-r--r--app/assets/javascripts/packages/list/components/package_search.vue30
-rw-r--r--app/assets/javascripts/packages/list/components/package_title.vue10
-rw-r--r--app/assets/javascripts/packages/list/components/packages_list_app.vue63
-rw-r--r--app/assets/javascripts/packages/list/constants.js4
-rw-r--r--app/assets/javascripts/packages/list/packages_list_app_bundle.js8
-rw-r--r--app/assets/javascripts/packages/list/utils.js4
-rw-r--r--app/assets/javascripts/packages/shared/components/package_icon_and_name.vue17
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue22
-rw-r--r--app/assets/javascripts/packages/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages/shared/utils.js16
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue45
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue53
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js33
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/utils.js29
-rw-r--r--app/assets/javascripts/pager.js20
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/admin.js13
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue50
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue417
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js26
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/gitpod.js24
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js31
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/utils.js21
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/labels/index/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/runners/index/index.js (renamed from app/assets/javascripts/pages/admin/runners/index.js)0
-rw-r--r--app/assets/javascripts/pages/admin/runners/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/services/edit/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/spam_logs/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue4
-rw-r--r--app/assets/javascripts/pages/admin/users/new/index.js52
-rw-r--r--app/assets/javascripts/pages/dashboard/activity/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js35
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/labels/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js11
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js9
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/settings/integrations/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js135
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js23
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue7
-rw-r--r--app/assets/javascripts/pages/projects/hooks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js14
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js34
-rw-r--r--app/assets/javascripts/pages/projects/jobs/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue64
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue6
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue52
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue43
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js50
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js14
-rw-r--r--app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pages/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/path_locks/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue2
-rw-r--r--app/assets/javascripts/pages/projects/project.js17
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/integrations/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue7
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue87
-rw-r--r--app/assets/javascripts/pages/projects/tags/index/index.js2
-rw-r--r--app/assets/javascripts/pages/shared/mount_runner_instructions.js9
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue253
-rw-r--r--app/assets/javascripts/pages/shared/wikis/index.js23
-rw-r--r--app/assets/javascripts/pages/shared/wikis/wikis.js75
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue98
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue19
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue4
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue4
-rw-r--r--app/assets/javascripts/performance_bar/constants.js17
-rw-r--r--app/assets/javascripts/performance_bar/index.js32
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js13
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue42
-rw-r--r--app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js11
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue76
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue65
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue21
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue15
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue34
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue59
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue12
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue30
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue75
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue66
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js11
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql9
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js24
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js21
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue90
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue9
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js9
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue72
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue56
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue112
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue67
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js16
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue (renamed from app/assets/javascripts/pipelines/components/graph/action_component.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue (renamed from app/assets/javascripts/pipelines/components/graph/job_name_component.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue90
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js26
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue218
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue190
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue143
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue38
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue269
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue74
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/unwrapping_utils.js48
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql1
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql13
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js40
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_dag.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js13
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js8
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js29
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js11
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/pipelines/utils.js8
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_comments_button.vue42
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue107
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue8
-rw-r--r--app/assets/javascripts/projects/commit/components/form_trigger.vue35
-rw-r--r--app/assets/javascripts/projects/commit/constants.js2
-rw-r--r--app/assets/javascripts/projects/commit/index.js8
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js1
-rw-r--r--app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js20
-rw-r--r--app/assets/javascripts/projects/commit/init_commit_comments_button.js18
-rw-r--r--app/assets/javascripts/projects/commit/init_commit_options_dropdown.js35
-rw-r--r--app/assets/javascripts/projects/commit/init_revert_commit_trigger.js20
-rw-r--r--app/assets/javascripts/projects/commit/store/actions.js4
-rw-r--r--app/assets/javascripts/projects/commit_box/info/index.js16
-rw-r--r--app/assets/javascripts/projects/commit_box/info/load_branches.js3
-rw-r--r--app/assets/javascripts/projects/compare/components/app_legacy.vue27
-rw-r--r--app/assets/javascripts/projects/compare/components/repo_dropdown.vue9
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue59
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue13
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue32
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue3
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/constants.js1
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue36
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue6
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue4
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js6
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue30
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue14
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue14
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue4
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue56
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue6
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue7
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue2
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_graphql.vue4
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_rest.vue4
-rw-r--r--app/assets/javascripts/releases/components/releases_sort.vue4
-rw-r--r--app/assets/javascripts/releases/components/tag_field.vue2
-rw-r--r--app/assets/javascripts/releases/components/tag_field_existing.vue2
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue6
-rw-r--r--app/assets/javascripts/releases/mount_edit.js4
-rw-r--r--app/assets/javascripts/releases/mount_index.js8
-rw-r--r--app/assets/javascripts/releases/mount_new.js4
-rw-r--r--app/assets/javascripts/releases/mount_show.js28
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js (renamed from app/assets/javascripts/releases/stores/modules/detail/actions.js)4
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js (renamed from app/assets/javascripts/releases/stores/modules/detail/getters.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/index.js (renamed from app/assets/javascripts/releases/stores/modules/detail/index.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js (renamed from app/assets/javascripts/releases/stores/modules/detail/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js (renamed from app/assets/javascripts/releases/stores/modules/detail/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js (renamed from app/assets/javascripts/releases/stores/modules/detail/state.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/actions.js (renamed from app/assets/javascripts/releases/stores/modules/list/actions.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/index.js (renamed from app/assets/javascripts/releases/stores/modules/list/index.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutation_types.js (renamed from app/assets/javascripts/releases/stores/modules/list/mutation_types.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutations.js (renamed from app/assets/javascripts/releases/stores/modules/list/mutations.js)0
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/state.js (renamed from app/assets/javascripts/releases/stores/modules/list/state.js)0
-rw-r--r--app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue1
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue3
-rw-r--r--app/assets/javascripts/reports/components/grouped_issues_list.vue4
-rw-r--r--app/assets/javascripts/reports/components/issues_list.vue4
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue23
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue2
-rw-r--r--app/assets/javascripts/reports/constants.js2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/modal.vue21
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue18
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue31
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/actions.js2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/mutations.js19
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/state.js14
-rw-r--r--app/assets/javascripts/reports/grouped_test_report/store/utils.js9
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue100
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue53
-rw-r--r--app/assets/javascripts/repository/components/directory_download_links.vue12
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue22
-rw-r--r--app/assets/javascripts/repository/components/tree_action_link.vue28
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue8
-rw-r--r--app/assets/javascripts/repository/index.js42
-rw-r--r--app/assets/javascripts/repository/pages/blob.vue22
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql30
-rw-r--r--app/assets/javascripts/repository/router.js20
-rw-r--r--app/assets/javascripts/runner/runner_details/constants.js3
-rw-r--r--app/assets/javascripts/runner/runner_details/index.js23
-rw-r--r--app/assets/javascripts/runner/runner_details/runner_details_app.vue20
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue4
-rw-r--r--app/assets/javascripts/search/sort/components/app.vue1
-rw-r--r--app/assets/javascripts/search/topbar/components/app.vue6
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue8
-rw-r--r--app/assets/javascripts/search_settings/constants.js2
-rw-r--r--app/assets/javascripts/security_configuration/components/manage_sast.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue65
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue25
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue37
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue296
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue51
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue17
-rw-r--r--app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue203
-rw-r--r--app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue41
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue38
-rw-r--r--app/assets/javascripts/sidebar/constants.js9
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js105
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql8
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql8
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql9
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue54
-rw-r--r--app/assets/javascripts/snippets/components/embed_dropdown.vue1
-rw-r--r--app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql2
-rw-r--r--app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js4
-rw-r--r--app/assets/javascripts/tags/components/sort_dropdown.vue77
-rw-r--r--app/assets/javascripts/tags/index.js24
-rw-r--r--app/assets/javascripts/tooltips/index.js5
-rw-r--r--app/assets/javascripts/tracking.js34
-rw-r--r--app/assets/javascripts/user_lists/components/user_list.vue1
-rw-r--r--app/assets/javascripts/users_select/index.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue56
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue7
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue5
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue23
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue24
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js11
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/alert_details_table.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/delete_label_modal.vue81
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue146
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue105
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue133
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue49
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js35
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/modal_copy_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue70
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue90
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql8
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue246
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue249
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue88
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue48
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/components/url_sync.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/user_date.vue (renamed from app/assets/javascripts/admin/users/components/user_date.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue25
-rw-r--r--app/assets/javascripts/vue_shared/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js36
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue2
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue58
-rw-r--r--app/assets/javascripts/whats_new/index.js6
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js10
-rw-r--r--app/assets/javascripts/whats_new/utils/notification.js13
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/application_dark.scss57
-rw-r--r--app/assets/stylesheets/components/feature_highlight.scss9
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss43
-rw-r--r--app/assets/stylesheets/framework/awards.scss43
-rw-r--r--app/assets/stylesheets/framework/ci_variable_list.scss23
-rw-r--r--app/assets/stylesheets/framework/common.scss10
-rw-r--r--app/assets/stylesheets/framework/diffs.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss14
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss50
-rw-r--r--app/assets/stylesheets/framework/emojis.scss13
-rw-r--r--app/assets/stylesheets/framework/filters.scss19
-rw-r--r--app/assets/stylesheets/framework/header.scss12
-rw-r--r--app/assets/stylesheets/framework/modal.scss8
-rw-r--r--app/assets/stylesheets/framework/page_header.scss25
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss2
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss13
-rw-r--r--app/assets/stylesheets/framework/spinner.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss80
-rw-r--r--app/assets/stylesheets/framework/variables.scss17
-rw-r--r--app/assets/stylesheets/highlight/common.scss28
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss4
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss4
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss56
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_settings.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss117
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss38
-rw-r--r--app/assets/stylesheets/page_bundles/ci_status.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss59
-rw-r--r--app/assets/stylesheets/page_bundles/learn_gitlab.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss33
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss18
-rw-r--r--app/assets/stylesheets/pages/commits.scss8
-rw-r--r--app/assets/stylesheets/pages/editor.scss2
-rw-r--r--app/assets/stylesheets/pages/events.scss19
-rw-r--r--app/assets/stylesheets/pages/groups.scss15
-rw-r--r--app/assets/stylesheets/pages/help.scss7
-rw-r--r--app/assets/stylesheets/pages/issuable.scss45
-rw-r--r--app/assets/stylesheets/pages/issues.scss5
-rw-r--r--app/assets/stylesheets/pages/labels.scss3
-rw-r--r--app/assets/stylesheets/pages/login.scss3
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss1
-rw-r--r--app/assets/stylesheets/pages/notes.scss20
-rw-r--r--app/assets/stylesheets/pages/notifications.scss4
-rw-r--r--app/assets/stylesheets/pages/projects.scss11
-rw-r--r--app/assets/stylesheets/pages/runners.scss56
-rw-r--r--app/assets/stylesheets/pages/settings.scss4
-rw-r--r--app/assets/stylesheets/pages/tree.scss2
-rw-r--r--app/assets/stylesheets/performance_bar.scss2
-rw-r--r--app/assets/stylesheets/print.scss8
-rw-r--r--app/assets/stylesheets/themes/_dark.scss3
-rw-r--r--app/assets/stylesheets/themes/theme_light.scss46
774 files changed, 14082 insertions, 7236 deletions
diff --git a/app/assets/images/learn_gitlab/issue_created.svg b/app/assets/images/learn_gitlab/issue_created.svg
new file mode 100644
index 00000000000..01652b97fc0
--- /dev/null
+++ b/app/assets/images/learn_gitlab/issue_created.svg
@@ -0,0 +1,65 @@
+<svg width="81" height="48" viewBox="0 0 81 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M42.9799 11.6386C42.9688 11.7501 42.955 11.8786 42.9384 12.0222C42.8865 12.4687 42.8257 12.9142 42.756 13.3582C42.7493 13.3954 42.7501 13.4335 42.7584 13.4704C42.7667 13.5072 42.7822 13.542 42.8041 13.5728C42.826 13.6035 42.8538 13.6296 42.8859 13.6495C42.918 13.6693 42.9538 13.6826 42.9911 13.6884C43.0284 13.6943 43.0664 13.6926 43.1031 13.6835C43.1397 13.6745 43.1742 13.6582 43.2045 13.6356C43.2347 13.613 43.2602 13.5847 43.2793 13.5521C43.2985 13.5196 43.3109 13.4835 43.316 13.4461C43.3915 12.9637 43.4531 12.506 43.5015 12.0874C43.5221 11.9092 43.5408 11.7308 43.5577 11.5522C43.5609 11.5151 43.5568 11.4777 43.5456 11.4422C43.5343 11.4067 43.5162 11.3738 43.4923 11.3453C43.4684 11.3168 43.439 11.2933 43.406 11.2761C43.373 11.2589 43.3369 11.2484 43.2998 11.2452C43.2627 11.242 43.2253 11.2461 43.1898 11.2573C43.1543 11.2685 43.1214 11.2866 43.0929 11.3106C43.0644 11.3345 43.0409 11.3638 43.0237 11.3969C43.0065 11.4299 42.996 11.466 42.9928 11.5031C42.9909 11.5263 42.9866 11.572 42.9799 11.6386ZM41.9287 16.9968C41.9171 17.0322 41.9127 17.0695 41.9156 17.1066C41.9185 17.1438 41.9287 17.1799 41.9456 17.2131C41.9624 17.2463 41.9857 17.2758 42.014 17.3C42.0423 17.3242 42.0751 17.3426 42.1105 17.3542C42.1459 17.3657 42.1832 17.3701 42.2203 17.3672C42.2574 17.3643 42.2936 17.3541 42.3268 17.3373C42.36 17.3204 42.3895 17.2971 42.4137 17.2688C42.4379 17.2405 42.4563 17.2078 42.4678 17.1724C42.6521 16.606 42.8172 15.985 42.9645 15.321C42.9734 15.2843 42.975 15.2462 42.9691 15.2089C42.9631 15.1716 42.9498 15.1359 42.9299 15.1039C42.91 15.0718 42.8839 15.044 42.8531 15.0222C42.8223 15.0004 42.7874 14.9849 42.7506 14.9767C42.7137 14.9686 42.6756 14.9678 42.6385 14.9746C42.6013 14.9813 42.5659 14.9954 42.5343 15.016C42.5027 15.0367 42.4755 15.0634 42.4543 15.0947C42.4332 15.1259 42.4185 15.1611 42.4111 15.1981C42.2675 15.8456 42.1069 16.4491 41.9287 16.9968ZM40.0489 19.874C40.0136 19.8862 39.981 19.9053 39.9531 19.9302C39.9252 19.955 39.9025 19.9852 39.8863 20.0189C39.8701 20.0526 39.8607 20.0891 39.8587 20.1265C39.8566 20.1638 39.862 20.2012 39.8745 20.2364C39.887 20.2716 39.9063 20.3041 39.9313 20.3318C39.9564 20.3596 39.9867 20.3821 40.0205 20.3981C40.0543 20.4141 40.0909 20.4232 40.1283 20.425C40.1656 20.4267 40.2029 20.4211 40.2381 20.4084C40.8012 20.209 41.2854 19.713 41.7083 18.9671C41.7454 18.9017 41.755 18.8242 41.735 18.7517C41.715 18.6792 41.667 18.6177 41.6016 18.5806C41.5362 18.5435 41.4587 18.5339 41.3862 18.554C41.3137 18.574 41.2522 18.622 41.2151 18.6874C40.8532 19.3257 40.4601 19.7283 40.0489 19.874ZM36.3662 20.7087C36.3319 20.7231 36.3007 20.7442 36.2746 20.7706C36.2484 20.7971 36.2277 20.8285 36.2136 20.863C36.1996 20.8974 36.1925 20.9343 36.1927 20.9716C36.1929 21.0088 36.2004 21.0456 36.2149 21.0799C36.2293 21.1142 36.2504 21.1454 36.2769 21.1715C36.3033 21.1977 36.3347 21.2184 36.3692 21.2324C36.4037 21.2465 36.4406 21.2536 36.4778 21.2534C36.515 21.2532 36.5518 21.2456 36.5861 21.2312C37.1757 20.9829 37.7714 20.792 38.3357 20.6679C38.4091 20.6517 38.4731 20.607 38.5136 20.5437C38.5541 20.4803 38.5677 20.4035 38.5516 20.3301C38.5354 20.2566 38.4907 20.1926 38.4274 20.1521C38.364 20.1117 38.2872 20.098 38.2138 20.1142C37.6153 20.2459 36.9871 20.4473 36.3662 20.7087ZM33.1143 22.7955C33.0607 22.8475 33.0298 22.9186 33.0283 22.9932C33.0267 23.0678 33.0547 23.1401 33.1061 23.1942C33.1576 23.2483 33.2282 23.28 33.3029 23.2823C33.3775 23.2846 33.45 23.2574 33.5047 23.2066C33.9359 22.7967 34.4242 22.42 34.9553 22.0829C35.0187 22.0426 35.0636 21.9788 35.08 21.9054C35.0964 21.832 35.083 21.7551 35.0427 21.6916C35.0024 21.6282 34.9385 21.5833 34.8651 21.5669C34.7918 21.5505 34.7149 21.5639 34.6514 21.6042C34.0901 21.9605 33.5729 22.3598 33.1143 22.7955ZM31.0777 26.1259C31.0686 26.162 31.0667 26.1995 31.0721 26.2364C31.0776 26.2732 31.0902 26.3086 31.1093 26.3406C31.1284 26.3725 31.1536 26.4004 31.1835 26.4226C31.2134 26.4448 31.2474 26.4609 31.2835 26.47C31.3196 26.4791 31.3571 26.481 31.3939 26.4755C31.4308 26.4701 31.4662 26.4575 31.4981 26.4384C31.5301 26.4193 31.558 26.394 31.5802 26.3642C31.6024 26.3343 31.6185 26.3003 31.6276 26.2642C31.7727 25.6871 32.0133 25.1347 32.3419 24.611C32.3799 24.5474 32.3915 24.4715 32.3742 24.3994C32.3569 24.3274 32.3121 24.265 32.2493 24.2256C32.1866 24.1862 32.1109 24.1729 32.0385 24.1886C31.9661 24.2043 31.9027 24.2478 31.8619 24.3096C31.5023 24.8826 31.2379 25.4896 31.0777 26.1259ZM31.0276 29.9893C31.0322 30.0262 31.0441 30.0619 31.0626 30.0943C31.081 30.1266 31.1056 30.155 31.135 30.1778C31.1644 30.2007 31.1981 30.2175 31.234 30.2273C31.2699 30.2371 31.3074 30.2398 31.3443 30.2352C31.3813 30.2305 31.4169 30.2186 31.4493 30.2002C31.4816 30.1818 31.51 30.1571 31.5328 30.1277C31.5557 30.0983 31.5725 30.0647 31.5823 30.0288C31.5922 29.9929 31.5948 29.9554 31.5902 29.9184C31.5208 29.3685 31.4806 28.7587 31.4683 28.0633C31.467 27.9881 31.4358 27.9165 31.3817 27.8643C31.3276 27.8121 31.255 27.7835 31.1798 27.7848C31.1046 27.7861 31.0331 27.8173 30.9808 27.8714C30.9286 27.9255 30.9 27.9981 30.9014 28.0733C30.914 28.7882 30.9556 29.418 31.0276 29.9893ZM32.2028 33.6691C32.238 33.7355 32.2981 33.7853 32.37 33.8074C32.4418 33.8295 32.5195 33.8222 32.586 33.7871C32.6524 33.7519 32.7022 33.6918 32.7243 33.6199C32.7465 33.5481 32.7392 33.4704 32.704 33.4039C32.3876 32.8064 32.1554 32.271 31.9768 31.7139C31.9539 31.6423 31.9034 31.5828 31.8365 31.5484C31.7697 31.514 31.6919 31.5075 31.6203 31.5305C31.5487 31.5535 31.4892 31.6039 31.4548 31.6708C31.4204 31.7376 31.414 31.8154 31.4369 31.887C31.6265 32.4779 31.8719 33.0435 32.2028 33.6691ZM33.6326 36.0823C33.8058 36.3634 33.9778 36.6453 34.1485 36.9279C34.1874 36.9923 34.2502 37.0386 34.3233 37.0567C34.3963 37.0747 34.4734 37.063 34.5378 37.0241C34.6022 36.9853 34.6485 36.9224 34.6665 36.8494C34.6846 36.7764 34.6729 36.6992 34.634 36.6348C34.4623 36.351 34.2895 36.0678 34.1155 35.7854C34.1306 35.81 33.7462 35.1858 33.6457 35.0219C33.6264 34.9897 33.601 34.9617 33.5709 34.9394C33.5407 34.9171 33.5065 34.901 33.4701 34.892C33.4337 34.8831 33.3958 34.8814 33.3588 34.8872C33.3217 34.8929 33.2862 34.906 33.2542 34.9256C33.2222 34.9451 33.1945 34.9709 33.1725 35.0013C33.1506 35.0317 33.1349 35.0661 33.1263 35.1026C33.1177 35.1391 33.1165 35.177 33.1227 35.2139C33.1289 35.2509 33.1423 35.2863 33.1623 35.3181C33.2632 35.4825 33.6481 36.1076 33.6326 36.0825V36.0823ZM35.3062 40.3242C35.3067 40.3615 35.3145 40.3982 35.3292 40.4324C35.3439 40.4666 35.3652 40.4976 35.3918 40.5236C35.4185 40.5496 35.45 40.57 35.4846 40.5838C35.5192 40.5976 35.5561 40.6045 35.5933 40.604C35.6306 40.6035 35.6673 40.5957 35.7015 40.581C35.7357 40.5663 35.7667 40.545 35.7927 40.5184C35.8187 40.4917 35.8392 40.4602 35.853 40.4256C35.8668 40.391 35.8736 40.3541 35.8731 40.3169C35.8648 39.6518 35.7475 38.9926 35.526 38.3655C35.4998 38.2962 35.4474 38.2399 35.3802 38.2087C35.313 38.1775 35.2363 38.1738 35.1664 38.1985C35.0966 38.2232 35.0392 38.2743 35.0065 38.3407C34.9738 38.4072 34.9684 38.4839 34.9915 38.5543C35.1924 39.1231 35.2987 39.721 35.3062 40.3242ZM34.27 43.7311C34.2299 43.7937 34.216 43.8695 34.2313 43.9422C34.2466 44.015 34.2898 44.0788 34.3517 44.12C34.4135 44.1612 34.4891 44.1764 34.5621 44.1624C34.6351 44.1484 34.6997 44.1063 34.7419 44.0452C35.109 43.4961 35.3947 42.8967 35.59 42.2658C35.6121 42.1939 35.6048 42.1162 35.5696 42.0497C35.5344 41.9833 35.4743 41.9335 35.4024 41.9114C35.3305 41.8893 35.2528 41.8967 35.1864 41.9319C35.1199 41.9671 35.0702 42.0272 35.0481 42.0991C34.8689 42.6778 34.6068 43.2275 34.27 43.7311Z" fill="#FDE5D8"/>
+<path d="M60.8446 31.6803L53.1713 38.2926M49.7697 32.4009L59.3328 29.0618L49.7697 32.4009Z" stroke="#FDE5D8" stroke-linecap="round"/>
+<path d="M60.2631 30.4887C60.3987 30.4104 60.4451 30.237 60.3668 30.1014C60.2886 29.9659 60.1152 29.9194 59.9796 29.9977L54.1288 33.3756C53.9932 33.4539 53.9468 33.6273 54.0251 33.7629C54.1033 33.8984 54.2767 33.9449 54.4123 33.8666L60.2631 30.4887Z" fill="#FDE5D8"/>
+<path d="M63.2421 30.9507L61.2578 27.5138C61.1535 27.3331 60.9223 27.2711 60.7415 27.3755L59.9327 27.8425C59.752 27.9468 59.69 28.178 59.7944 28.3588L61.7786 31.7956C61.883 31.9764 62.1142 32.0383 62.2949 31.9339L63.1037 31.467C63.2845 31.3626 63.3464 31.1314 63.2421 30.9507Z" fill="white" stroke="#FDE5D8"/>
+<path d="M69.8124 20.1746L73.8754 27.2119L63.7936 33.0326L59.7306 25.9953L69.8124 20.1746Z" stroke="#FDE5D8"/>
+<path d="M68.6454 29.795L68.3599 32.8374C68.35 32.9411 68.2766 33.0767 68.1935 33.1419L64.4623 36.0762C64.2994 36.2045 64.2327 36.1536 64.3133 35.9633L65.268 33.7089L65.5559 31.5788" stroke="#FDE5D8"/>
+<path d="M64.9604 23.4123L62.1829 22.1385C62.088 22.0951 61.934 22.0911 61.836 22.1302L57.4292 23.8944C57.2367 23.9715 57.2473 24.0546 57.4525 24.08L59.8823 24.3805L61.8709 25.1962" stroke="#FDE5D8"/>
+<path d="M69.8126 20.1746L76.321 20.7344C76.6326 20.7612 76.778 21.0118 76.645 21.2955L73.8758 27.2118" stroke="#FDE5D8"/>
+<path d="M62.0503 27.3839C62.1285 27.5195 62.3012 27.5663 62.4359 27.4886C62.5705 27.4108 62.6163 27.2379 62.538 27.1023C62.4597 26.9667 62.2871 26.9198 62.1524 26.9976C62.0177 27.0754 61.972 27.2483 62.0503 27.3839Z" fill="#FC8A51"/>
+<path d="M62.6173 28.3658C62.6955 28.5014 62.8682 28.5483 63.0028 28.4705C63.1375 28.3928 63.1832 28.2198 63.105 28.0842C63.0267 27.9487 62.8541 27.9018 62.7194 27.9795C62.5847 28.0573 62.539 28.2302 62.6173 28.3658Z" fill="#FC8A51"/>
+<path d="M63.1841 29.3476C63.2624 29.4832 63.435 29.5301 63.5697 29.4523C63.7044 29.3746 63.7501 29.2016 63.6718 29.066C63.5935 28.9305 63.4209 28.8836 63.2862 28.9613C63.1516 29.0391 63.1058 29.212 63.1841 29.3476Z" fill="#FC8A51"/>
+<path d="M63.7511 30.3297C63.8294 30.4653 64.002 30.5121 64.1367 30.4344C64.2714 30.3566 64.3171 30.1837 64.2388 30.0481C64.1605 29.9125 63.9879 29.8656 63.8532 29.9434C63.7186 30.0212 63.6728 30.1941 63.7511 30.3297Z" fill="#FC8A51"/>
+<path d="M65.9899 27.0731C66.5378 28.0221 67.7464 28.3501 68.6894 27.8057C69.6324 27.2612 69.9527 26.0505 69.4048 25.1015C68.8568 24.1524 67.6482 23.8244 66.7052 24.3689C65.7622 24.9133 65.4419 26.124 65.9899 27.0731Z" stroke="#FDE5D8"/>
+<path d="M66.8032 26.6036C67.0902 27.1008 67.7233 27.2726 68.2173 26.9874C68.7113 26.7022 68.879 26.068 68.592 25.5709C68.305 25.0738 67.6719 24.902 67.1779 25.1872C66.684 25.4723 66.5162 26.1065 66.8032 26.6036Z" stroke="#FDE5D8"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5279 7.40988C18.4991 7.72887 18.5432 8.13101 18.7163 8.57152C18.7364 8.61656 18.7731 8.65211 18.8188 8.6708C18.8645 8.68948 18.9156 8.68987 18.9615 8.67188C19.0075 8.65389 19.0447 8.61891 19.0656 8.57418C19.0864 8.52945 19.0892 8.47842 19.0734 8.43167C18.924 8.05108 18.8863 7.70752 18.91 7.44408C18.9119 7.42254 18.9137 7.40893 18.9145 7.40421C18.9193 7.37911 18.9189 7.35331 18.9136 7.32834C18.9082 7.30336 18.8979 7.2797 18.8833 7.25876C18.8687 7.23781 18.85 7.22 18.8284 7.20637C18.8068 7.19273 18.7827 7.18355 18.7575 7.17936C18.7323 7.17517 18.7065 7.17605 18.6817 7.18196C18.6568 7.18787 18.6334 7.19868 18.6128 7.21376C18.5922 7.22885 18.5748 7.2479 18.5616 7.26979C18.5485 7.29169 18.5398 7.31599 18.5362 7.34128C18.5326 7.36404 18.5298 7.38692 18.5279 7.40988ZM20.6986 10.3736C20.7914 10.4189 20.9061 10.3855 20.9547 10.2991C21.0033 10.213 20.9675 10.1064 20.8746 10.061C20.5219 9.88963 20.2211 9.69933 19.9686 9.49278C19.9299 9.46178 19.8811 9.44626 19.8316 9.44923C19.7822 9.4522 19.7355 9.47344 19.7008 9.50884C19.6845 9.52574 19.6719 9.5459 19.6638 9.568C19.6557 9.59009 19.6524 9.61364 19.654 9.63711C19.6556 9.66058 19.6622 9.68344 19.6732 9.70422C19.6842 9.725 19.6995 9.74323 19.718 9.75772C19.9936 9.98298 20.3195 10.1892 20.6988 10.3738L20.6986 10.3736ZM23.1801 11.5188C23.2048 11.5264 23.2308 11.5289 23.2565 11.5263C23.2822 11.5238 23.3072 11.5161 23.3299 11.5038C23.3527 11.4915 23.3727 11.4748 23.389 11.4546C23.4052 11.4345 23.4172 11.4113 23.4244 11.3865C23.4399 11.3362 23.435 11.2819 23.4107 11.2352C23.3865 11.1886 23.3448 11.1533 23.2948 11.1371C22.9212 11.0216 22.5486 10.903 22.177 10.7814C22.1524 10.7735 22.1264 10.7705 22.1007 10.7727C22.0749 10.7749 22.0498 10.7822 22.0269 10.7941C22.004 10.8061 21.9837 10.8225 21.9672 10.8425C21.9507 10.8624 21.9383 10.8854 21.9308 10.9101C21.9146 10.9602 21.9187 11.0146 21.9423 11.0616C21.966 11.1087 22.0072 11.1445 22.057 11.1614C22.3837 11.269 22.606 11.3391 23.1801 11.5188ZM25.4353 12.2611C25.5204 12.3076 25.6285 12.2796 25.6772 12.1987C25.6887 12.1795 25.6962 12.158 25.6991 12.1358C25.7021 12.1135 25.7004 12.0909 25.6943 12.0692C25.6882 12.0476 25.6777 12.0275 25.6635 12.0101C25.6493 11.9927 25.6318 11.9784 25.6118 11.968C25.3295 11.8132 25.0086 11.676 24.6165 11.5381C24.5246 11.5059 24.4228 11.5507 24.389 11.6382C24.3549 11.7259 24.4022 11.823 24.4938 11.8554C24.8688 11.9871 25.1723 12.1169 25.4353 12.2611ZM27.2083 14.2457C27.247 14.3391 27.3614 14.3859 27.4632 14.3504C27.5653 14.3149 27.6163 14.2106 27.5776 14.1172C27.4169 13.7302 27.2274 13.3999 27.0036 13.1147C26.94 13.0336 26.8166 13.0151 26.7279 13.0735C26.6393 13.1317 26.6193 13.2447 26.6829 13.3258C26.8859 13.5843 27.0594 13.887 27.2083 14.2457ZM27.7057 16.8237C27.7091 16.9282 27.813 17.0108 27.9378 17.008C28.0625 17.005 28.1607 16.9178 28.1575 16.8133C28.1437 16.3968 28.1065 16.0185 28.04 15.6568C28.0209 15.5534 27.9054 15.4824 27.782 15.4984C27.6588 15.5143 27.5742 15.6113 27.5932 15.7146C27.6567 16.0595 27.6925 16.4224 27.7057 16.8237Z" fill="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M27.6172 18.4687C27.6047 18.7996 27.5957 19.1305 27.5903 19.4616C27.589 19.5661 27.6652 19.6521 27.7608 19.6536C27.8564 19.6551 27.9352 19.5718 27.9365 19.4675C27.9403 19.1902 27.9471 18.9608 27.9634 18.4827L27.9683 18.3378C27.9717 18.2334 27.897 18.1458 27.8016 18.142C27.706 18.1382 27.6257 18.2195 27.6221 18.324L27.6172 18.4687ZM27.7678 22.1483C27.7905 22.2513 27.895 22.3168 28.0014 22.2951C28.1076 22.2732 28.1754 22.1721 28.1529 22.0691C28.0741 21.71 28.0187 21.3434 27.9834 20.9609C27.9736 20.8562 27.878 20.7788 27.7697 20.7882C27.6614 20.7977 27.5815 20.8903 27.5911 20.995C27.6279 21.3922 27.6856 21.7737 27.7678 22.1483ZM28.7232 24.4772C28.7792 24.563 28.9033 24.5925 29.0005 24.543C29.0976 24.4937 29.1309 24.3841 29.0751 24.2983C28.8775 23.9966 28.7013 23.6814 28.5479 23.3551C28.505 23.2634 28.3863 23.22 28.2827 23.2578C28.1792 23.2956 28.1297 23.4004 28.1728 23.4919C28.333 23.8328 28.5169 24.1621 28.7232 24.4772ZM29.633 25.9325C29.8392 26.1713 30.063 26.3944 30.3025 26.5998C30.3398 26.6315 30.3877 26.6478 30.4366 26.6455C30.4855 26.6432 30.5317 26.6224 30.5658 26.5873C30.5821 26.5704 30.5948 26.5503 30.603 26.5282C30.6113 26.5062 30.6149 26.4827 30.6136 26.4592C30.6124 26.4357 30.6063 26.4127 30.5958 26.3916C30.5853 26.3706 30.5706 26.3519 30.5525 26.3368C30.3268 26.1431 30.1158 25.9329 29.9214 25.7078C29.8848 25.6652 29.8485 25.6222 29.8125 25.579C29.7807 25.5418 29.7359 25.5182 29.6872 25.5129C29.6386 25.5075 29.5897 25.5209 29.5506 25.5502C29.5315 25.5641 29.5156 25.5818 29.5036 25.6021C29.4917 25.6224 29.4841 25.645 29.4812 25.6684C29.4784 25.6918 29.4804 25.7155 29.4871 25.7381C29.4939 25.7607 29.5052 25.7816 29.5204 25.7997C29.5574 25.8439 29.5948 25.8883 29.633 25.9325ZM32.6235 28.1392C32.7142 28.1849 32.8223 28.1424 32.8647 28.0445C32.907 27.9463 32.8677 27.8299 32.777 27.7841C32.4527 27.6215 32.1381 27.4403 31.8347 27.2414C31.7491 27.1851 31.6375 27.2142 31.5853 27.3066C31.5331 27.3988 31.5602 27.5194 31.6458 27.5757C31.9605 27.7823 32.287 27.9704 32.6235 28.1392Z" fill="#E5E5E5"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2883 29.0967C35.3928 29.1234 35.4977 29.054 35.5225 28.9418C35.5472 28.8293 35.4828 28.7165 35.3783 28.6898C35.0044 28.5945 34.6348 28.4834 34.2703 28.3567C34.1683 28.3212 34.0587 28.3812 34.0254 28.4912C33.9923 28.601 34.0483 28.7189 34.1505 28.7545C34.5248 28.8846 34.9044 28.9987 35.2883 29.0965V29.0967ZM37.7828 29.48C37.8875 29.4875 37.9775 29.3959 37.9841 29.2753C37.9907 29.1545 37.9111 29.0508 37.8066 29.0432C37.4321 29.0161 37.0589 28.9735 36.6879 28.9155C36.584 28.8992 36.4885 28.9831 36.4744 29.1028C36.4604 29.2226 36.5329 29.3327 36.6369 29.349C37.0169 29.4085 37.3993 29.4522 37.783 29.48H37.7828ZM40.464 29.2169C40.568 29.2065 40.6417 29.1305 40.6286 29.0468C40.6158 28.9635 40.5211 28.9041 40.4174 28.9145C40.0551 28.9508 39.6817 28.975 39.3005 28.9867C39.1958 28.9899 39.1144 29.0608 39.1185 29.1449C39.1225 29.229 39.2105 29.2946 39.3151 29.2912C39.6988 29.2795 40.082 29.2548 40.464 29.2169ZM43.156 28.7148C43.2562 28.6732 43.3026 28.5606 43.2599 28.4631C43.2385 28.4159 43.1995 28.379 43.1513 28.3601C43.1031 28.3412 43.0494 28.3419 43.0016 28.362C42.6464 28.5079 42.2817 28.6294 41.9099 28.7254C41.8852 28.7315 41.862 28.7424 41.8416 28.7575C41.8212 28.7727 41.804 28.7918 41.7911 28.8136C41.7781 28.8355 41.7696 28.8597 41.7662 28.8849C41.7627 28.9101 41.7643 28.9357 41.7708 28.9603C41.7991 29.0627 41.9071 29.1234 42.0121 29.0958C42.4018 28.9951 42.784 28.8678 43.1562 28.7148H43.156ZM45.326 26.9135C45.3812 26.8313 45.354 26.7234 45.2656 26.6721C45.1767 26.6211 45.0601 26.6461 45.0049 26.7283C44.8241 26.9974 44.5845 27.2449 44.2899 27.4691C44.2709 27.4827 44.255 27.5003 44.2433 27.5205C44.2316 27.5407 44.2242 27.5632 44.2218 27.5864C44.2193 27.6097 44.2218 27.6332 44.229 27.6554C44.2362 27.6777 44.2481 27.6981 44.2638 27.7155C44.3299 27.7903 44.449 27.8011 44.5299 27.7397C44.8555 27.4921 45.1225 27.216 45.326 26.9135ZM46.8046 25.0747C46.8407 25.0429 46.8628 24.998 46.866 24.9499C46.8692 24.9018 46.8532 24.8544 46.8216 24.8181C46.8059 24.8001 46.7869 24.7854 46.7655 24.7748C46.7441 24.7642 46.7209 24.758 46.6971 24.7564C46.6733 24.7548 46.6494 24.7579 46.6268 24.7656C46.6043 24.7733 46.5834 24.7853 46.5655 24.8011C46.2859 25.0458 46.0227 25.3086 45.7775 25.5878C45.7458 25.6241 45.7297 25.6716 45.7329 25.7197C45.736 25.7678 45.7581 25.8127 45.7943 25.8446C45.8122 25.8604 45.833 25.8724 45.8556 25.8801C45.8781 25.8878 45.902 25.891 45.9257 25.8894C45.9495 25.8878 45.9728 25.8816 45.9941 25.871C46.0155 25.8604 46.0345 25.8458 46.0502 25.8278C46.2849 25.5606 46.5368 25.309 46.8044 25.0747H46.8046ZM49.036 23.5975C49.126 23.5489 49.1602 23.4354 49.1125 23.3439C49.1015 23.3222 49.0862 23.303 49.0676 23.2873C49.049 23.2716 49.0275 23.2598 49.0043 23.2526C48.9811 23.2453 48.9566 23.2428 48.9324 23.2451C48.9082 23.2474 48.8847 23.2545 48.8633 23.266C48.5322 23.4443 48.2091 23.6372 47.8952 23.8441C47.8099 23.9004 47.7856 24.0167 47.8409 24.1036C47.8965 24.1905 48.0106 24.2151 48.096 24.1588C48.4009 23.9579 48.7145 23.7706 49.036 23.5975ZM51.454 22.4735C51.5579 22.4415 51.6148 22.3367 51.581 22.239C51.5471 22.1411 51.4354 22.0878 51.3315 22.1195C50.9508 22.236 50.5742 22.3654 50.2024 22.5077C50.1011 22.5464 50.0523 22.6551 50.0935 22.7501C50.1347 22.8454 50.2504 22.8911 50.3517 22.8524C50.7147 22.7135 51.0823 22.5872 51.454 22.4735Z" fill="#EEEEEE"/>
+<path d="M27.7796 18.3307C28.3014 18.3307 28.7244 17.9076 28.7244 17.3858C28.7244 16.864 28.3014 16.4409 27.7796 16.4409C27.2577 16.4409 26.8347 16.864 26.8347 17.3858C26.8347 17.9076 27.2577 18.3307 27.7796 18.3307Z" fill="white" stroke="#EEEEEE"/>
+<path d="M45.3543 27.4016C45.8761 27.4016 46.2992 26.9786 46.2992 26.4567C46.2992 25.9349 45.8761 25.5118 45.3543 25.5118C44.8325 25.5118 44.4094 25.9349 44.4094 26.4567C44.4094 26.9786 44.8325 27.4016 45.3543 27.4016Z" fill="white" stroke="#EEEEEE"/>
+<path d="M4.16876 10.9607C4.16876 10.9607 0.867338 17.1969 1.90104 20.9764C2.93474 24.756 5.11364 27.0237 9.08214 29.2914C13.0506 31.5591 15.2125 28.3465 20.5984 30.4253C25.9842 32.504 26.0787 38.5513 26.0787 38.5513" stroke="#B5A7DD" stroke-width="0.4" stroke-linecap="round" stroke-dasharray="8 10"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M49.8898 47.6221C49.8898 39.5857 43.375 33.0709 35.3386 33.0709C27.3022 33.0709 20.7874 39.5857 20.7874 47.6221" fill="white"/>
+<path d="M49.8898 47.6221C49.8898 39.5857 43.375 33.0709 35.3386 33.0709C27.3022 33.0709 20.7874 39.5857 20.7874 47.6221" stroke="#EEEEEE" stroke-linecap="round"/>
+<path d="M41.1969 43.8425C42.8668 43.8425 44.2205 42.4888 44.2205 40.8189C44.2205 39.149 42.8668 37.7953 41.1969 37.7953C39.527 37.7953 38.1732 39.149 38.1732 40.8189C38.1732 42.4888 39.527 43.8425 41.1969 43.8425Z" stroke="#EEEEEE"/>
+<path d="M28.8189 40.441C29.7061 40.441 30.4252 39.7218 30.4252 38.8347C30.4252 37.9476 29.7061 37.2284 28.8189 37.2284C27.9318 37.2284 27.2126 37.9476 27.2126 38.8347C27.2126 39.7218 27.9318 40.441 28.8189 40.441Z" stroke="#EEEEEE"/>
+<path d="M24.9449 44.9764C25.4667 44.9764 25.8898 44.5534 25.8898 44.0316C25.8898 43.5097 25.4667 43.0867 24.9449 43.0867C24.423 43.0867 24 43.5097 24 44.0316C24 44.5534 24.423 44.9764 24.9449 44.9764Z" stroke="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M45.703 3.43286L46.1389 3.54966C46.346 2.7768 47.1318 2.3159 47.8939 2.5201L48.0123 2.078C47.0096 1.80933 45.9757 2.416 45.7032 3.43291L45.703 3.43286Z" fill="#FC8A51"/>
+<path d="M47.9471 2.61347C48.1478 2.66723 48.3546 2.54588 48.4091 2.34244C48.4636 2.139 48.3452 1.93051 48.1445 1.87675C47.9439 1.823 47.7371 1.94434 47.6826 2.14778C47.6281 2.35122 47.7465 2.55972 47.9471 2.61347Z" fill="#FC8A51"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M45.412 3.35511L44.9761 3.23831C45.1832 2.46545 44.7332 1.67342 43.9711 1.46921L44.0896 1.02711C45.0922 1.29577 45.6843 2.33814 45.4119 3.35506L45.412 3.35511Z" fill="#FC8A51"/>
+<path d="M43.8787 1.52354C43.6781 1.46979 43.5597 1.26129 43.6142 1.05785C43.6687 0.854411 43.8755 0.733068 44.0761 0.786823C44.2768 0.840578 44.3952 1.04908 44.3407 1.25252C44.2862 1.45596 44.0794 1.5773 43.8787 1.52354Z" fill="#FC8A51"/>
+<path d="M47.8422 7.79545L46.7544 7.50399C46.6536 7.47698 46.55 7.5368 46.523 7.63762C46.496 7.73843 46.5558 7.84205 46.6566 7.86907L47.7444 8.16052C47.8452 8.18754 47.9488 8.12771 47.9758 8.0269C48.0028 7.92608 47.943 7.82246 47.8422 7.79545Z" fill="#FC8A51"/>
+<path d="M47.4759 9.27595L46.5007 8.7129C46.4103 8.66071 46.2947 8.69168 46.2425 8.78207C46.1904 8.87245 46.2213 8.98803 46.3117 9.04021L47.287 9.60327C47.3773 9.65545 47.4929 9.62448 47.5451 9.5341C47.5973 9.44371 47.5663 9.32813 47.4759 9.27595Z" fill="#FC8A51"/>
+<path d="M48.1795 6.35876L47.0534 6.35876C46.949 6.35876 46.8644 6.44337 46.8644 6.54774C46.8644 6.65211 46.949 6.73671 47.0534 6.73671L48.1795 6.73671C48.2839 6.73671 48.3685 6.6521 48.3685 6.54774C48.3685 6.44337 48.2839 6.35876 48.1795 6.35876Z" fill="#FC8A51"/>
+<path d="M41.3783 6.06356L42.4661 6.35502C42.5669 6.38203 42.6267 6.48565 42.5997 6.58647C42.5727 6.68728 42.469 6.74711 42.3682 6.72009L41.2805 6.42863C41.1797 6.40162 41.1198 6.298 41.1469 6.19719C41.1739 6.09637 41.2775 6.03655 41.3783 6.06356Z" fill="#FC8A51"/>
+<path d="M40.9549 7.52856L42.081 7.52856C42.1854 7.52856 42.27 7.61317 42.27 7.71754C42.27 7.82191 42.1854 7.90651 42.081 7.90651L40.9549 7.90651C40.8506 7.90651 40.7659 7.8219 40.7659 7.71754C40.7659 7.61317 40.8505 7.52856 40.9549 7.52856Z" fill="#FC8A51"/>
+<path d="M41.8041 4.65032L42.7794 5.21337C42.8698 5.26556 42.9007 5.38113 42.8485 5.47152C42.7964 5.5619 42.6808 5.59287 42.5904 5.54069L41.6151 4.97763C41.5248 4.92545 41.4938 4.80987 41.546 4.71949C41.5982 4.6291 41.7137 4.59813 41.8041 4.65032Z" fill="#FC8A51"/>
+<path d="M47.3515 6.28652C47.7063 4.96254 46.9206 3.60168 45.5967 3.24693C44.2728 2.89219 42.912 3.6779 42.5572 5.00187L42.1051 6.68907C41.7504 8.01304 42.536 9.3739 43.86 9.72865C45.1839 10.0834 46.5447 9.29768 46.8995 7.97371L47.3515 6.28652Z" stroke="#FC8A51"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M42.7135 5.00037L47.0721 6.16826L46.9254 6.71587L42.5668 5.54798L42.7135 5.00037Z" fill="#FC8A51"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.4662 2.50359L17.1869 2.57842C17.1557 2.46241 17.1019 2.35369 17.0286 2.25847C16.9554 2.16325 16.8641 2.0834 16.7599 2.02348C16.6558 1.96355 16.5409 1.92474 16.4217 1.90924C16.3026 1.89374 16.1816 1.90187 16.0656 1.93316L15.9909 1.6546C16.634 1.48229 17.2944 1.86252 17.4662 2.50359Z" fill="#EEEEEE"/>
+<path d="M16.0312 1.99224C15.9025 2.02671 15.7704 1.95069 15.736 1.82246C15.7017 1.69423 15.7781 1.56233 15.9067 1.52786C16.0354 1.4934 16.1675 1.56941 16.2019 1.69764C16.2362 1.82587 16.1598 1.95777 16.0312 1.99224Z" fill="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6526 2.45366L17.9318 2.37883C17.9009 2.26274 17.8931 2.14169 17.909 2.02259C17.9248 1.9035 17.964 1.78869 18.0242 1.68473C18.0844 1.58076 18.1645 1.48969 18.2599 1.4167C18.3554 1.3437 18.4642 1.29023 18.5804 1.25932L18.5057 0.980773C17.8626 1.15308 17.4808 1.81259 17.6526 2.45366Z" fill="#EEEEEE"/>
+<path d="M18.6393 1.29344C18.7679 1.25898 18.8444 1.12708 18.81 0.998847C18.7756 0.870613 18.6435 0.794601 18.5149 0.82907C18.3862 0.863538 18.3098 0.995433 18.3442 1.12367C18.3785 1.2519 18.5107 1.32791 18.6393 1.29344Z" fill="#EEEEEE"/>
+<path d="M16.1505 5.24482L16.7167 5.0931C16.8175 5.06609 16.9211 5.12591 16.9481 5.22673C16.9752 5.32754 16.9153 5.43116 16.8145 5.45817L16.2483 5.60989C16.1475 5.63691 16.0439 5.57708 16.0168 5.47627C15.9898 5.37546 16.0497 5.27183 16.1505 5.24482Z" fill="#EEEEEE"/>
+<path d="M16.3578 6.1703L16.8655 5.8772C16.9559 5.82502 17.0714 5.85599 17.1236 5.94637C17.1758 6.03676 17.1448 6.15233 17.0545 6.20452L16.5468 6.49762C16.4564 6.5498 16.3408 6.51883 16.2886 6.42845C16.2365 6.33806 16.2674 6.22249 16.3578 6.1703Z" fill="#EEEEEE"/>
+<path d="M15.9573 4.35278L16.5435 4.35278C16.6479 4.35278 16.7325 4.43739 16.7325 4.54176C16.7325 4.64613 16.6479 4.73074 16.5435 4.73074L15.9573 4.73074C15.853 4.73074 15.7684 4.64613 15.7684 4.54176C15.7684 4.43739 15.853 4.35278 15.9573 4.35278Z" fill="#EEEEEE"/>
+<path d="M20.1626 4.16985L19.5964 4.32157C19.4956 4.34859 19.4357 4.45221 19.4628 4.55302C19.4898 4.65383 19.5934 4.71366 19.6942 4.68665L20.2604 4.53493C20.3612 4.50791 20.4211 4.40429 20.3941 4.30348C20.367 4.20267 20.2634 4.14284 20.1626 4.16985Z" fill="#EEEEEE"/>
+<path d="M20.4451 5.07507L19.8589 5.07507C19.7545 5.07507 19.6699 5.15968 19.6699 5.26405C19.6699 5.36842 19.7545 5.45303 19.8589 5.45303L20.4451 5.45303C20.5494 5.45303 20.634 5.36842 20.634 5.26405C20.634 5.15968 20.5494 5.07507 20.4451 5.07507Z" fill="#EEEEEE"/>
+<path d="M19.8834 3.30076L19.3757 3.59387C19.2853 3.64605 19.2543 3.76163 19.3065 3.85201C19.3587 3.9424 19.4743 3.97337 19.5647 3.92118L20.0723 3.62808C20.1627 3.5759 20.1937 3.46032 20.1415 3.36993C20.0893 3.27955 19.9737 3.24858 19.8834 3.30076Z" fill="#EEEEEE"/>
+<path d="M17.5341 2.38566L17.5343 2.38561C18.3829 2.15821 19.2552 2.66184 19.4826 3.51048L19.7541 4.52356C19.9815 5.3722 19.4778 6.24449 18.6292 6.47189L18.629 6.47193C17.7804 6.69933 16.9081 6.19571 16.6807 5.34707L16.4092 4.33398C16.1819 3.48534 16.6855 2.61305 17.5341 2.38566Z" stroke="#EEEEEE"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3759 3.48474L16.582 4.23337L16.6798 4.59844L19.4737 3.84982L19.3759 3.48474Z" fill="#EEEEEE"/>
+<path d="M1.98426 23.0551C2.87139 23.0551 3.59056 22.336 3.59056 21.4488C3.59056 20.5617 2.87139 19.8425 1.98426 19.8425C1.09712 19.8425 0.37796 20.5617 0.37796 21.4488C0.37796 22.336 1.09712 23.0551 1.98426 23.0551Z" fill="white" stroke="#B5A7DD" stroke-width="0.4"/>
+<path d="M32.7874 24.9449C33.4658 24.9449 34.0158 24.3949 34.0158 23.7165C34.0158 23.0381 33.4658 22.4882 32.7874 22.4882C32.109 22.4882 31.5591 23.0381 31.5591 23.7165C31.5591 24.3949 32.109 24.9449 32.7874 24.9449Z" fill="white"/>
+<path d="M10.1102 31.9371C11.4148 31.9371 12.4724 30.8795 12.4724 29.5749C12.4724 28.2702 11.4148 27.2126 10.1102 27.2126C8.80561 27.2126 7.74802 28.2702 7.74802 29.5749C7.74802 30.8795 8.80561 31.9371 10.1102 31.9371Z" fill="white" stroke="#6B4FBB"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.77635 29.759L9.44262 29.4251C9.38944 29.3719 9.31732 29.3421 9.24211 29.3421C9.16691 29.3421 9.09479 29.3719 9.04161 29.4251C8.98843 29.4783 8.95856 29.5504 8.95856 29.6256C8.95856 29.7008 8.98843 29.773 9.04161 29.8261L9.57566 30.3602H9.57585V30.3604C9.68734 30.4719 9.86592 30.4713 9.97629 30.3609L11.1801 29.1572C11.2328 29.1038 11.2623 29.0319 11.2622 28.9569C11.2621 28.8819 11.2324 28.8101 11.1795 28.7569C11.1533 28.7305 11.1222 28.7095 11.0879 28.6952C11.0535 28.6808 11.0167 28.6734 10.9795 28.6733C10.9423 28.6733 10.9055 28.6805 10.8711 28.6948C10.8368 28.709 10.8055 28.7298 10.7792 28.7561L9.77635 29.759Z" fill="#FC8A51"/>
+<path d="M32.7874 24.7559C33.4658 24.7559 34.0158 24.2059 34.0158 23.5275C34.0158 22.8491 33.4658 22.2992 32.7874 22.2992C32.109 22.2992 31.5591 22.8491 31.5591 23.5275C31.5591 24.2059 32.109 24.7559 32.7874 24.7559Z" fill="white" stroke="#FC8A51"/>
+<path d="M4.53541 11.3386C5.16162 11.3386 5.66926 10.831 5.66926 10.2048C5.66926 9.57857 5.16162 9.07092 4.53541 9.07092C3.9092 9.07092 3.40155 9.57857 3.40155 10.2048C3.40155 10.831 3.9092 11.3386 4.53541 11.3386Z" fill="white" stroke="#B5A7DD" stroke-width="0.4"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M35.9339 27.1363C37.5787 25.6725 38.0151 23.182 36.8527 21.1688C35.5261 18.8708 32.6187 18.0654 30.3591 19.3701C28.0993 20.6748 27.343 23.5953 28.6698 25.8932C29.8483 27.9342 32.2732 28.7978 34.3841 28.0513L36.4437 31.619C36.563 31.8259 36.7596 31.9769 36.9903 32.0389C37.2209 32.1008 37.4667 32.0686 37.6736 31.9493C37.8802 31.8297 38.0309 31.6329 38.0926 31.4023C38.1542 31.1716 38.1218 30.9259 38.0024 30.7191L35.9339 27.1363ZM34.34 26.2653C35.8248 25.4081 36.3218 23.4889 35.4499 21.9788C34.5782 20.4686 32.6676 19.9395 31.1828 20.7967C29.6978 21.6541 29.2008 23.5733 30.0726 25.0834C30.9445 26.5934 32.8551 27.1227 34.34 26.2653Z" fill="white" stroke="#B5A7DD" stroke-width="0.5"/>
+</g>
+<defs>
+<clipPath id="clip0">
+<rect width="80.315" height="48" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/images/learn_gitlab/section_deploy.svg b/app/assets/images/learn_gitlab/section_deploy.svg
new file mode 100644
index 00000000000..187956a66db
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_deploy.svg
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="35px" height="34px" viewBox="0 0 35 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 64 (93537) - https://sketch.com -->
+ <title>Group</title>
+ <desc>Created with Sketch.</desc>
+ <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <g id="Artboard" transform="translate(-463.000000, -393.000000)">
+ <g id="Group" transform="translate(463.000000, 393.000000)">
+ <path d="M19.3557,25.2338 C17.7369,25.9233 16.1619,26.1062 14.5861,25.6013 C13.996,26.033 13.2711,26.2395 12.5422,26.1834 C11.8132,26.1273 11.1284,25.8124 10.6114,25.2955 L8.75506,23.4392 C8.19927,22.8833 7.87849,22.1351 7.8591,21.3493 C7.83972,20.5635 8.12323,19.8003 8.65094,19.2177 C8.34425,17.8947 8.46763,16.5564 8.93356,15.1901 L6.183,14.1287 C5.81926,13.9884 5.49427,13.7633 5.23503,13.472 C4.9758,13.1808 4.7898,12.832 4.69251,12.4544 C4.59521,12.0769 4.58942,11.6816 4.67562,11.3013 C4.76182,10.9211 4.93753,10.5669 5.18813,10.2682 L8.79006,5.9759 C9.11841,5.5846 9.56087,5.3057 10.0555,5.1782 C10.5502,5.0508 11.0724,5.0811 11.5489,5.265 L15.0686,6.6234 C15.4064,6.2747 15.7529,5.9234 16.1073,5.5691 C19.6177,2.05856 24.1782,0.20312 29.7892,0.00275 C29.9524,-0.00304 30.1159,0.00032 30.2787,0.01281 C32.6881,0.19656 34.4919,2.29918 34.3077,4.7085 C33.8986,10.0569 31.957,14.4687 28.4824,17.9429 C28.2408,18.1846 27.9986,18.4257 27.7557,18.6661 L29.1763,22.3463 C29.3602,22.8229 29.3905,23.345 29.263,23.8397 C29.1355,24.3344 28.8566,24.7768 28.4653,25.1052 L24.1734,28.7071 C23.8748,28.9578 23.5206,29.1336 23.1403,29.2198 C22.76,29.3061 22.3646,29.3003 21.987,29.203 C21.6094,29.1057 21.2605,28.9197 20.9692,28.6604 C20.678,28.4011 20.4528,28.0761 20.3125,27.7122 L19.3557,25.2338 Z M9.32819,13.455 L13.8808,8.0291 L10.8962,6.8772 C10.7601,6.8247 10.611,6.816 10.4697,6.8524 C10.3284,6.8888 10.2021,6.9685 10.1082,7.0802 L6.50631,11.3725 C6.43473,11.4578 6.38455,11.559 6.35993,11.6677 C6.33532,11.7763 6.33699,11.8893 6.3648,11.9971 C6.39261,12.105 6.44576,12.2046 6.51983,12.2878 C6.59391,12.371 6.68676,12.4353 6.79069,12.4754 L9.32819,13.455 Z M20.9858,24.5675 L21.9654,27.105 C22.0055,27.2089 22.0699,27.3017 22.1531,27.3757 C22.2363,27.4498 22.336,27.5029 22.4438,27.5306 C22.5517,27.5584 22.6646,27.56 22.7732,27.5354 C22.8818,27.5107 22.983,27.4605 23.0683,27.3889 L27.3602,23.7874 C27.4721,23.6936 27.5518,23.5672 27.5883,23.4259 C27.6248,23.2845 27.6162,23.1353 27.5636,22.9991 L26.4117,20.0144 L20.9858,24.5675 Z M12.3714,22.1057 C16.2,25.9347 19.3999,24.55 27.2442,16.7056 C30.4161,13.5337 32.1845,9.5162 32.5621,4.5751 C32.5903,4.2067 32.5404,3.83648 32.4156,3.4887 C32.2909,3.14091 32.0942,2.82338 31.8383,2.55685 C31.5825,2.29033 31.2732,2.08082 30.9308,1.94203 C30.5884,1.80323 30.2205,1.73829 29.8513,1.75143 C24.6687,1.9365 20.5312,3.62 17.3449,6.8063 C9.89431,14.2569 8.41381,18.1481 12.3714,22.1057 Z" id="Shape" fill="#6E49CB"></path>
+ <path d="M22.9861,11.6834 C23.148,11.8489 23.341,11.9806 23.5541,12.071 C23.7672,12.1613 23.9962,12.2085 24.2276,12.2098236 C24.4591,12.211 24.6885,12.1664 24.9026,12.0784 C25.1166,11.9904 25.3111,11.8608 25.4748,11.6971 C25.6384,11.5334 25.768,11.3389 25.856,11.1248 C25.9439,10.9107 25.9885,10.6813 25.9872276,10.4499 C25.9859,10.2184 25.9387,9.9895 25.8483,9.7764 C25.7579,9.5633 25.6262,9.3703 25.4606,9.2085 C25.1325,8.8803 24.6873,8.6959 24.2232,8.6959 C23.759,8.6959 23.3139,8.8803 22.9857,9.2085 C22.6575,9.5367 22.4731,9.9818 22.4731,10.446 C22.4731,10.9101 22.6575,11.3552 22.9857,11.6834 L22.9861,11.6834 Z M21.748,12.9211 C21.423,12.5961 21.1651,12.2102 20.9892,11.7855 C20.8133,11.3608 20.7228,10.9056 20.7228,10.446 C20.7228,9.9863 20.8133,9.5311 20.9892,9.1064 C21.1651,8.6817 21.423,8.2958 21.748,7.9708 C22.0731,7.6458 22.4589,7.3879 22.8836,7.212 C23.3083,7.0361 23.7635,6.9456 24.2232,6.9456 C24.6829,6.9456 25.138,7.0361 25.5627,7.212 C25.9874,7.3879 26.3733,7.6458 26.6983,7.9708 C27.3482,8.6285 27.7113,9.5166 27.708615,10.4412 C27.7058,11.3658 27.3373,12.2517 26.6836,12.9055 C26.0298,13.5593 25.1439,13.9278 24.2194,13.9307161 C23.2948,13.9335 22.4067,13.5704 21.7489,12.9207 L21.748,12.9211 Z" id="Shape" fill="#C2B7E6" fill-rule="nonzero"></path>
+ <path d="M6.58996,23.1303 C6.754,23.2943 6.84615,23.5169 6.84615,23.7489 C6.84615,23.9809 6.754,24.2034 6.58996,24.3675 L1.64009,29.3165 C1.5594,29.4001 1.46287,29.4668 1.35614,29.5127 C1.2494,29.5586 1.13459,29.5828 1.01841,29.5838391 C0.902228,29.5849 0.787001,29.5628 0.679451,29.5188 C0.571902,29.4749 0.474183,29.4099 0.391998,29.3278 C0.309813,29.2457 0.244808,29.148 0.200774,29.0405 C0.15674,28.933 0.13456,28.8177 0.135497628,28.7016 C0.136497,28.5854 0.160594,28.4706 0.206414,28.3638 C0.252233,28.257 0.318858,28.1604 0.4024,28.0797 L5.35228,23.1298 C5.43353,23.0485 5.53001,22.984 5.63619,22.9401 C5.74237,22.8961 5.85618,22.8734 5.97112,22.8734 C6.08606,22.8734 6.19987,22.8961 6.30605,22.9401 C6.41223,22.984 6.50871,23.0485 6.58996,23.1298 L6.58996,23.1303 Z M10.9208,27.4611 C11.0848,27.6252 11.177,27.8477 11.177,28.0797 C11.177,28.3117 11.0848,28.5342 10.9208,28.6983 L7.20859,32.4105 C7.12735,32.4918 7.0309,32.5562 6.92474,32.6002 C6.81859,32.6442 6.70481,32.6669 6.5899,32.6669 C6.47499,32.6669 6.3612,32.6443 6.25503,32.6004 C6.14886,32.5564 6.05239,32.492 5.97112,32.4107 C5.88985,32.3295 5.82538,32.233 5.78139,32.1269 C5.7374,32.0207 5.71471999,31.9069 5.71471999,31.792 C5.71471999,31.6771 5.73731,31.5633 5.78127,31.4572 C5.82522,31.351 5.88966,31.2545 5.9709,31.1733 L9.68353,27.4611 C9.84761,27.297 10.0701,27.2049 10.3022,27.2049 C10.5342,27.2049 10.7567,27.297 10.9208,27.4611 L10.9208,27.4611 Z" id="Shape" fill="#E0DBF2"></path>
+ <path d="M8.75534,25.2954 C8.91937,25.4595 9.01152,25.682 9.01152,25.914 C9.01152,26.1461 8.91937,26.3686 8.75534,26.5327 L1.94959,33.3389 C1.78546,33.503 1.56286,33.5952 1.33074,33.5952 C1.09863,33.5952 0.876027,33.503 0.7119,33.3389 C0.547772,33.1747 0.455566,32.9521 0.455566,32.72 C0.455566,32.4879 0.547772,32.2653 0.7119,32.1012 L7.51809,25.2959 C7.68217,25.1318 7.90469,25.0397 8.13671,25.0397 C8.36873,25.0397 8.59125,25.1318 8.75534,25.2959 L8.75534,25.2954 Z" id="Path" fill="#C2B7E6"></path>
+ </g>
+ </g>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/learn_gitlab/section_plan.svg b/app/assets/images/learn_gitlab/section_plan.svg
new file mode 100644
index 00000000000..2348e837e5f
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_plan.svg
@@ -0,0 +1 @@
+<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M3.343 6c.82 0 1.516.145 1.968.34.198.085.315.165.375.218v31.441a1.45 1.45 0 01-.375.218c-.452.195-1.148.34-1.968.34-.82 0-1.516-.145-1.968-.34A1.45 1.45 0 011 37.999V6.558c.06-.053.177-.133.375-.218C1.827 6.145 2.523 6 3.343 6z" fill="#EFEDF8" stroke="#6E49CB" stroke-width="2"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.686 6.41l21.724 6.692c2.085.642 2.122 1.774.095 2.523L6.686 23.69V6.412z" fill="#6E49CB"/></svg> \ No newline at end of file
diff --git a/app/assets/images/learn_gitlab/section_workspace.svg b/app/assets/images/learn_gitlab/section_workspace.svg
new file mode 100644
index 00000000000..5cb7fd36ddd
--- /dev/null
+++ b/app/assets/images/learn_gitlab/section_workspace.svg
@@ -0,0 +1 @@
+<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.544 6.44C14.816 5.58 15.581 5 16.442 5h1.116c.861 0 1.626.58 1.898 1.44l1.05 3.32c.744.238 1.456.55 2.13.93l2.974-1.566c.77-.405 1.7-.247 2.309.394l.79.832c.608.64.76 1.62.374 2.43l-1.486 3.131c.359.71.656 1.46.882 2.243l3.153 1.106c.817.287 1.368 1.091 1.368 1.998v1.176c0 .906-.55 1.71-1.368 1.998l-3.153 1.106a12.936 12.936 0 01-.882 2.242l1.486 3.131c.385.81.234 1.79-.374 2.43l-.79.832c-.609.641-1.539.8-2.309.395l-2.973-1.566c-.675.379-1.387.692-2.13.93l-1.051 3.32c-.272.86-1.037 1.44-1.898 1.44h-1.116c-.861 0-1.626-.58-1.898-1.44l-1.05-3.32a11.604 11.604 0 01-2.13-.93L8.39 34.569c-.77.406-1.7.247-2.309-.395l-.79-.831a2.189 2.189 0 01-.374-2.43l1.487-3.131c-.36-.71-.657-1.46-.883-2.243l-3.153-1.106C1.55 24.145 1 23.34 1 22.434v-1.176c0-.906.55-1.711 1.368-1.998l3.153-1.106c.226-.783.523-1.533.883-2.243l-1.487-3.13a2.19 2.19 0 01.374-2.431l.79-.832c.609-.64 1.539-.8 2.309-.394l2.973 1.565a11.599 11.599 0 012.13-.93l1.051-3.32zM17 30.269c4.418 0 8-3.771 8-8.423s-3.582-8.423-8-8.423-8 3.771-8 8.423 3.582 8.423 8 8.423z" fill="#EFEDF8" stroke="#6E49CB" stroke-width="2"/><path d="M17 27.11c2.762 0 5-2.357 5-5.264 0-2.908-2.238-5.265-5-5.265-2.76 0-5 2.357-5 5.265 0 2.907 2.24 5.264 5 5.264z" stroke="#6E49CB" stroke-linecap="round"/></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 43d56295f78..7f5f0403de6 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,20 +1,10 @@
import Vue from 'vue';
import createFlash from '~/flash';
+import { parseRailsFormFields } from '~/lib/utils/forms';
import { __ } from '~/locale';
import ExpiresAtField from './components/expires_at_field.vue';
-const getInputAttrs = (el) => {
- const input = el.querySelector('input');
-
- return {
- id: input.id,
- name: input.name,
- value: input.value,
- placeholder: input.placeholder,
- };
-};
-
export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
@@ -22,7 +12,7 @@ export const initExpiresAtField = () => {
return null;
}
- const inputAttrs = getInputAttrs(el);
+ const { expiresAt: inputAttrs } = parseRailsFormFields(el);
return new Vue({
el,
@@ -43,7 +33,7 @@ export const initProjectsField = () => {
return null;
}
- const inputAttrs = getInputAttrs(el);
+ const { projects: inputAttrs } = parseRailsFormFields(el);
if (window.gon.features.personalAccessTokensScopedToProjects) {
return new Promise((resolve) => {
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 5064d9ee2d2..b671d038ce8 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -2,14 +2,20 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
export default class Activities {
- constructor(container = '') {
- this.container = container;
+ constructor(containerSelector = '') {
+ this.containerSelector = containerSelector;
+ this.containerEl = this.containerSelector
+ ? document.querySelector(this.containerSelector)
+ : undefined;
+ this.$contentList = $('.content_list');
- Pager.init(20, true, false, (data) => data, this.updateTooltips, this.container);
+ this.loadActivities();
$('.event-filter-link').on('click', (e) => {
e.preventDefault();
@@ -18,13 +24,30 @@ export default class Activities {
});
}
+ loadActivities() {
+ Pager.init({
+ limit: 20,
+ preload: true,
+ prepareData: (data) => data,
+ successCallback: () => this.updateTooltips(),
+ errorCallback: () =>
+ createFlash({
+ message: s__(
+ 'Activity|An error occured while retrieving activity. Reload the page to try again.',
+ ),
+ parent: this.containerEl,
+ }),
+ container: this.containerSelector,
+ });
+ }
+
updateTooltips() {
localTimeAgo($('.js-timeago', '.content_list'));
}
reloadActivities() {
- $('.content_list').html('');
- Pager.init(20, true, false, (data) => data, this.updateTooltips, this.container);
+ this.$contentList.html('');
+ this.loadActivities();
}
toggleFilter(sender) {
diff --git a/app/assets/javascripts/admin/statistics_panel/constants.js b/app/assets/javascripts/admin/statistics_panel/constants.js
index 2dce19a3894..de413b2e7f0 100644
--- a/app/assets/javascripts/admin/statistics_panel/constants.js
+++ b/app/assets/javascripts/admin/statistics_panel/constants.js
@@ -3,7 +3,7 @@ import { s__ } from '~/locale';
const statisticsLabels = {
forks: s__('AdminStatistics|Forks'),
issues: s__('AdminStatistics|Issues'),
- mergeRequests: s__('AdminStatistics|Merge Requests'),
+ mergeRequests: s__('AdminStatistics|Merge requests'),
notes: s__('AdminStatistics|Notes'),
snippets: s__('AdminStatistics|Snippets'),
sshKeys: s__('AdminStatistics|SSH Keys'),
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index 8962068601c..8b41a063abc 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -1,9 +1,9 @@
<script>
import { GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
+import UserDate from '~/vue_shared/components/user_date.vue';
import UserActions from './user_actions.vue';
import UserAvatar from './user_avatar.vue';
-import UserDate from './user_date.vue';
const DEFAULT_TH_CLASSES =
'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js
index 8ea1bd3ca7a..c55edefe607 100644
--- a/app/assets/javascripts/admin/users/constants.js
+++ b/app/assets/javascripts/admin/users/constants.js
@@ -2,8 +2,6 @@ import { s__, __ } from '~/locale';
export const USER_AVATAR_SIZE = 32;
-export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
-
export const LENGTH_OF_USER_NOTE_TOOLTIP = 100;
export const I18N_USER_ACTIONS = {
diff --git a/app/assets/javascripts/admin/users/new.js b/app/assets/javascripts/admin/users/new.js
new file mode 100644
index 00000000000..33565bfc14f
--- /dev/null
+++ b/app/assets/javascripts/admin/users/new.js
@@ -0,0 +1,55 @@
+const DATA_ATTR_REGEX_PATTERN = 'data-user-internal-regex-pattern';
+const DATA_ATTR_REGEX_OPTIONS = 'data-user-internal-regex-options';
+export const ID_USER_EXTERNAL = 'user_external';
+export const ID_WARNING = 'warning_external_automatically_set';
+export const ID_USER_EMAIL = 'user_email';
+
+const getAttributeValue = (attr) => document.querySelector(`[${attr}]`)?.getAttribute(attr);
+
+const getRegexPattern = () => getAttributeValue(DATA_ATTR_REGEX_PATTERN);
+
+const getRegexOptions = () => getAttributeValue(DATA_ATTR_REGEX_OPTIONS);
+
+export const setupInternalUserRegexHandler = () => {
+ const regexPattern = getRegexPattern();
+
+ if (!regexPattern) {
+ return;
+ }
+
+ const regexOptions = getRegexOptions();
+ const elExternal = document.getElementById(ID_USER_EXTERNAL);
+ const elWarningMessage = document.getElementById(ID_WARNING);
+ const elUserEmail = document.getElementById(ID_USER_EMAIL);
+
+ const isEmailInternal = (email) => {
+ const regex = new RegExp(regexPattern, regexOptions);
+ return regex.test(email);
+ };
+
+ const setExternalCheckbox = (email) => {
+ const isChecked = elExternal.checked;
+
+ if (isEmailInternal(email)) {
+ if (isChecked) {
+ elExternal.checked = false;
+ elWarningMessage.classList.remove('hidden');
+ }
+ } else if (!isChecked) {
+ elExternal.checked = true;
+ elWarningMessage.classList.add('hidden');
+ }
+ };
+
+ const setupListeners = () => {
+ elUserEmail.addEventListener('input', (event) => {
+ setExternalCheckbox(event.target.value);
+ });
+
+ elExternal.addEventListener('change', () => {
+ elWarningMessage.classList.add('hidden');
+ });
+ };
+
+ setupListeners();
+};
diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
index 9b0e5090a75..77c14d9f812 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue
@@ -43,7 +43,7 @@ export default {
</gl-link>
</div>
<div v-if="userCanEnableAlertManagement" class="gl-display-block center gl-pt-4">
- <gl-button category="primary" variant="success" :href="enableAlertManagementPath">
+ <gl-button category="primary" variant="confirm" :href="enableAlertManagementPath">
{{ $options.i18n.emptyState.buttonText }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index f5eac26431f..b23f8a8eba4 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -5,6 +5,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
import AlertManagementList from './components/alert_management_list_wrapper.vue';
+import alertsHelpUrlQuery from './graphql/queries/alert_help_url.query.graphql';
Vue.use(VueApollo);
@@ -41,7 +42,8 @@ export default () => {
),
});
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: alertsHelpUrlQuery,
data: {
alertsHelpUrl,
},
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 07b2e59671e..5171588eb64 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -118,17 +118,17 @@ export default {
<template>
<div class="gl-display-table gl-w-full gl-mt-5">
<div class="gl-display-table-row">
- <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3">
+ <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3">
{{ $options.i18n.columns.gitlabKeyTitle }}
</h5>
- <h5 class="gl-display-table-cell gl-py-3 gl-pr-3">&nbsp;</h5>
- <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3">
+ <h5 class="gl-display-table-cell gl-pb-3 gl-pr-3">&nbsp;</h5>
+ <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3">
{{ $options.i18n.columns.payloadKeyTitle }}
</h5>
<h5
v-if="hasFallbackColumn"
id="fallbackFieldsHeader"
- class="gl-display-table-cell gl-py-3 gl-pr-3"
+ class="gl-display-table-cell gl-pb-3 gl-pr-3"
>
{{ $options.i18n.columns.fallbackKeyTitle }}
<gl-icon
@@ -140,11 +140,7 @@ export default {
</h5>
</div>
- <div
- v-for="(gitlabField, index) in mappingData"
- :key="gitlabField.name"
- class="gl-display-table-row"
- >
+ <div v-for="gitlabField in mappingData" :key="gitlabField.name" class="gl-display-table-row">
<div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-form-input
aria-labelledby="gitlabFieldsHeader"
@@ -153,8 +149,8 @@ export default {
/>
</div>
- <div class="gl-display-table-cell gl-py-3 gl-pr-3">
- <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }">
+ <div class="gl-display-table-cell gl-pr-3 gl-vertical-align-middle">
+ <div class="right-arrow">
<i class="right-arrow-head"></i>
</div>
</div>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index a5e17d80f86..ef29fc5e8b4 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -21,8 +21,10 @@ import {
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
export const i18n = {
+ deleteIntegration: s__('AlertSettings|Delete integration'),
+ editIntegration: s__('AlertSettings|Edit integration'),
title: s__('AlertsIntegrations|Current integrations'),
- emptyState: s__('AlertsIntegrations|No integrations have been added yet'),
+ emptyState: s__('AlertsIntegrations|No integrations have been added yet.'),
status: {
enabled: {
name: __('Enabled'),
@@ -139,7 +141,7 @@ export default {
<template>
<div class="incident-management-list">
- <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5>
+ <h5 class="gl-font-lg gl-mt-5">{{ $options.i18n.title }}</h5>
<gl-table
class="integration-list"
:items="integrations"
@@ -174,11 +176,16 @@ export default {
<template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3">
- <gl-button icon="settings" @click="editIntegration(item)" />
+ <gl-button
+ icon="settings"
+ :aria-label="$options.i18n.editIntegration"
+ @click="editIntegration(item)"
+ />
<gl-button
v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus"
icon="remove"
+ :aria-label="$options.i18n.deleteIntegration"
@click="setIntegrationToDelete(item)"
/>
</gl-button-group>
@@ -198,15 +205,15 @@ export default {
</gl-table>
<gl-modal
modal-id="deleteIntegration"
- :title="s__('AlertSettings|Delete integration')"
- :ok-title="s__('AlertSettings|Delete integration')"
+ :title="$options.i18n.deleteIntegration"
+ :ok-title="$options.i18n.deleteIntegration"
ok-variant="danger"
@ok="deleteIntegration"
>
<gl-sprintf
:message="
s__(
- 'AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone.',
+ 'AlertsIntegrations|If you delete the %{integrationName} integration, alerts are no longer sent from this endpoint. This action cannot be undone.',
)
"
>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 5d9513e5b53..a5f7b84446f 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -14,7 +14,7 @@ import {
GlTab,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
-import { isEmpty, omit } from 'lodash';
+import { isEqual, isEmpty, omit } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import {
integrationTypes,
@@ -24,8 +24,9 @@ import {
JSON_VALIDATE_DELAY,
targetPrometheusUrlPlaceholder,
typeSet,
- viewCredentialsTabIndex,
i18n,
+ tabIndices,
+ testAlertModalId,
} from '../constants';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql';
@@ -40,6 +41,10 @@ export default {
typeSet,
integrationSteps,
i18n,
+ primaryProps: { text: i18n.integrationFormSteps.testPayload.savedAndTest },
+ secondaryProps: { text: i18n.integrationFormSteps.testPayload.proceedWithoutSave },
+ cancelProps: { text: i18n.integrationFormSteps.testPayload.cancel },
+ testAlertModalId,
components: {
ClipboardButton,
GlButton,
@@ -60,11 +65,8 @@ export default {
GlModal: GlModalDirective,
},
inject: {
- generic: {
- default: {},
- },
- prometheus: {
- default: {},
+ alertsUsageUrl: {
+ default: '#',
},
multiIntegrations: {
default: false,
@@ -87,6 +89,11 @@ export default {
required: false,
default: null,
},
+ tabIndex: {
+ type: Number,
+ required: false,
+ default: tabIndices.configureDetails,
+ },
},
apollo: {
currentIntegration: {
@@ -96,11 +103,10 @@ export default {
data() {
return {
integrationTypesOptions: Object.values(integrationTypes),
- selectedIntegration: integrationTypes.none.value,
- active: false,
samplePayload: {
json: null,
error: null,
+ loading: false,
},
testPayload: {
json: null,
@@ -108,18 +114,32 @@ export default {
},
resetPayloadAndMappingConfirmed: false,
mapping: [],
- parsingPayload: false,
+ integrationForm: {
+ active: false,
+ type: integrationTypes.none.value,
+ name: '',
+ token: '',
+ url: '',
+ apiUrl: '',
+ },
+ activeTabIndex: this.tabIndex,
currentIntegration: null,
parsedPayload: [],
- activeTabIndex: 0,
+ validationState: {
+ name: true,
+ apiUrl: true,
+ },
};
},
computed: {
isPrometheus() {
- return this.selectedIntegration === this.$options.typeSet.prometheus;
+ return this.integrationForm.type === typeSet.prometheus;
},
isHttp() {
- return this.selectedIntegration === this.$options.typeSet.http;
+ return this.integrationForm.type === typeSet.http;
+ },
+ isNone() {
+ return !this.isHttp && !this.isPrometheus;
},
isCreating() {
return !this.currentIntegration;
@@ -130,29 +150,6 @@ export default {
isTestPayloadValid() {
return this.testPayload.error === null;
},
- selectedIntegrationType() {
- switch (this.selectedIntegration) {
- case typeSet.http:
- return this.generic;
- case typeSet.prometheus:
- return this.prometheus;
- default:
- return {};
- }
- },
- integrationForm() {
- return {
- name: this.currentIntegration?.name || '',
- active: this.currentIntegration?.active || false,
- token:
- this.currentIntegration?.token ||
- (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.token : ''),
- url:
- this.currentIntegration?.url ||
- (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.url : ''),
- apiUrl: this.currentIntegration?.apiUrl || '',
- };
- },
testAlertPayload() {
return {
data: this.testPayload.json,
@@ -170,13 +167,7 @@ export default {
return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed;
},
canParseSamplePayload() {
- return !this.active || !this.isSampePayloadValid || !this.samplePayload.json;
- },
- isResetAuthKeyDisabled() {
- return !this.active && !this.integrationForm.token !== '';
- },
- isPayloadEditDisabled() {
- return !this.active || this.canEditPayload;
+ return this.isSampePayloadValid && this.samplePayload.json;
},
isSelectDisabled() {
return this.currentIntegration !== null || !this.canAddIntegration;
@@ -186,30 +177,105 @@ export default {
? i18n.integrationFormSteps.setupCredentials.prometheusHelp
: i18n.integrationFormSteps.setupCredentials.help;
},
+ isFormValid() {
+ return (
+ Object.values(this.validationState).every(Boolean) &&
+ !this.isNone &&
+ this.isSampePayloadValid
+ );
+ },
+ isFormDirty() {
+ const { type, active, name, apiUrl, payloadAlertFields = [], payloadAttributeMappings = [] } =
+ this.currentIntegration || {};
+ const {
+ name: formName,
+ apiUrl: formApiUrl,
+ active: formActive,
+ type: formType,
+ } = this.integrationForm;
+
+ const isDirty =
+ type !== formType ||
+ active !== formActive ||
+ name !== formName ||
+ apiUrl !== formApiUrl ||
+ !isEqual(this.parsedPayload, payloadAlertFields) ||
+ !isEqual(this.mapping, this.getCleanMapping(payloadAttributeMappings));
+
+ return isDirty;
+ },
+ canSubmitForm() {
+ return this.isFormValid && this.isFormDirty;
+ },
+ dataForSave() {
+ const { name, apiUrl, active } = this.integrationForm;
+ const customMappingVariables = {
+ payloadAttributeMappings: this.mapping,
+ payloadExample: this.samplePayload.json || '{}',
+ };
+
+ const variables = this.isHttp
+ ? { name, active, ...customMappingVariables }
+ : { apiUrl, active };
+
+ return { type: this.integrationForm.type, variables };
+ },
+ testAlertModal() {
+ return this.isFormDirty ? testAlertModalId : null;
+ },
+ prometheusUrlInvalidFeedback() {
+ const { blankUrlError, invalidUrlError } = i18n.integrationFormSteps.prometheusFormUrl;
+ return this.integrationForm.apiUrl?.length ? invalidUrlError : blankUrlError;
+ },
},
watch: {
+ tabIndex(val) {
+ this.activeTabIndex = val;
+ },
currentIntegration(val) {
if (val === null) {
this.reset();
return;
}
- const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val;
- this.selectedIntegration = type;
- this.active = active;
- if (type === typeSet.http && this.showMappingBuilder) {
+ this.resetPayloadAndMapping();
+ const {
+ name,
+ type,
+ active,
+ url,
+ apiUrl,
+ token,
+ payloadExample,
+ payloadAlertFields,
+ payloadAttributeMappings,
+ } = val;
+ this.integrationForm = { type, name, active, url, apiUrl, token };
+
+ if (this.showMappingBuilder) {
+ this.resetPayloadAndMappingConfirmed = false;
this.parsedPayload = payloadAlertFields;
- this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null;
- const mapping = payloadAttributeMappings.map((mappingItem) =>
- omit(mappingItem, '__typename'),
- );
- this.updateMapping(mapping);
+ this.samplePayload.json = this.getPrettifiedPayload(payloadExample);
+ this.updateMapping(this.getCleanMapping(payloadAttributeMappings));
}
- this.activeTabIndex = viewCredentialsTabIndex;
this.$el.scrollIntoView({ block: 'center' });
},
},
methods: {
+ getCleanMapping(mapping) {
+ return mapping.map((mappingItem) => omit(mappingItem, '__typename'));
+ },
+ validateName() {
+ this.validationState.name = Boolean(this.integrationForm.name?.length);
+ },
+ validateApiUrl() {
+ try {
+ const parsedUrl = new URL(this.integrationForm.apiUrl);
+ this.validationState.apiUrl = ['http:', 'https:'].includes(parsedUrl.protocol);
+ } catch (e) {
+ this.validationState.apiUrl = false;
+ }
+ },
isValidNonEmptyJSON(JSONString) {
if (JSONString) {
let parsed;
@@ -222,29 +288,37 @@ export default {
}
return false;
},
+ getPrettifiedPayload(payload) {
+ return this.isValidNonEmptyJSON(payload)
+ ? JSON.stringify(JSON.parse(payload), null, '\t')
+ : null;
+ },
+ triggerValidation() {
+ if (this.isHttp) {
+ this.validationState.apiUrl = true;
+ this.validateName();
+ if (!this.validationState.name) {
+ this.$refs.integrationName.$el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ } else if (this.isPrometheus) {
+ this.validationState.name = true;
+ this.validateApiUrl();
+ }
+ },
sendTestAlert() {
this.$emit('test-alert-payload', this.testAlertPayload);
},
- submit() {
- const { name, apiUrl } = this.integrationForm;
- const customMappingVariables = {
- payloadAttributeMappings: this.mapping,
- payloadExample: this.samplePayload.json || '{}',
- };
-
- const variables =
- this.selectedIntegration === typeSet.http
- ? { name, active: this.active, ...customMappingVariables }
- : { apiUrl, active: this.active };
-
- const integrationPayload = { type: this.selectedIntegration, variables };
+ saveAndSendTestAlert() {
+ this.$emit('save-and-test-alert-payload', this.dataForSave, this.testAlertPayload);
+ },
+ submit(testAfterSubmit = false) {
+ this.triggerValidation();
- if (this.currentIntegration) {
- return this.$emit('update-integration', integrationPayload);
+ if (!this.isFormValid) {
+ return;
}
-
- this.reset();
- return this.$emit('create-new-integration', integrationPayload);
+ const event = this.currentIntegration ? 'update-integration' : 'create-new-integration';
+ this.$emit(event, this.dataForSave, testAfterSubmit);
},
reset() {
this.resetFormValues();
@@ -252,14 +326,14 @@ export default {
this.$emit('clear-current-integration', { type: this.currentIntegration?.type });
},
resetFormValues() {
- this.selectedIntegration = integrationTypes.none.value;
+ this.integrationForm.type = integrationTypes.none.value;
this.integrationForm.name = '';
+ this.integrationForm.active = false;
this.integrationForm.apiUrl = '';
this.samplePayload = {
json: null,
error: null,
};
- this.active = false;
},
resetAuthKey() {
if (!this.currentIntegration) {
@@ -267,7 +341,7 @@ export default {
}
this.$emit('reset-token', {
- type: this.selectedIntegration,
+ type: this.integrationForm.type,
variables: { id: this.currentIntegration.id },
});
},
@@ -285,8 +359,8 @@ export default {
payload.error = JSON.stringify(e.message);
}
},
- parseMapping() {
- this.parsingPayload = true;
+ parseSamplePayload() {
+ this.samplePayload.loading = true;
return this.$apollo
.query({
@@ -303,7 +377,7 @@ export default {
this.resetPayloadAndMappingConfirmed = false;
this.$toast.show(
- this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg,
+ this.$options.i18n.integrationFormSteps.mapFields.payloadParsedSucessMsg,
);
},
)
@@ -311,7 +385,7 @@ export default {
this.samplePayload.error = message;
})
.finally(() => {
- this.parsingPayload = false;
+ this.samplePayload.loading = false;
});
},
updateMapping(mapping) {
@@ -338,7 +412,7 @@ export default {
<template>
<gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset">
<gl-tabs v-model="activeTabIndex">
- <gl-tab :title="$options.i18n.integrationTabs.configureDetails">
+ <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3">
<gl-form-group
v-if="isCreating"
id="integration-type"
@@ -351,7 +425,7 @@ export default {
label-for="integration-type"
>
<gl-form-select
- v-model="selectedIntegration"
+ v-model="integrationForm.type"
:disabled="isSelectDisabled"
class="gl-max-w-full"
:options="integrationTypesOptions"
@@ -369,7 +443,6 @@ export default {
<div class="gl-mt-3">
<gl-form-group
v-if="isHttp"
- id="name-integration"
:label="
getLabelWithStepNumber(
$options.integrationSteps.nameIntegration,
@@ -377,67 +450,82 @@ export default {
)
"
label-for="name-integration"
+ :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error"
+ :state="validationState.name"
>
<gl-form-input
+ id="name-integration"
+ ref="integrationName"
v-model="integrationForm.name"
type="text"
:placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder"
+ @input="validateName"
/>
</gl-form-group>
- <gl-toggle
- v-model="active"
- :is-loading="loading"
- :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
- class="gl-my-4 gl-font-weight-normal"
- />
-
- <div v-if="isPrometheus" class="gl-my-4">
- <span class="gl-font-weight-bold">
- {{
- getLabelWithStepNumber(
- $options.integrationSteps.setPrometheusApiUrl,
- $options.i18n.integrationFormSteps.prometheusFormUrl.label,
- )
- }}
- </span>
+ <gl-form-group
+ v-if="!isNone"
+ :label="
+ getLabelWithStepNumber(
+ isHttp
+ ? $options.integrationSteps.enableHttpIntegration
+ : $options.integrationSteps.enablePrometheusIntegration,
+ $options.i18n.integrationFormSteps.enableIntegration.label,
+ )
+ "
+ >
+ <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span>
+
+ <gl-toggle
+ id="enable-integration"
+ v-model="integrationForm.active"
+ :is-loading="loading"
+ :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle"
+ class="gl-mt-4 gl-font-weight-normal"
+ />
+ </gl-form-group>
+ <gl-form-group
+ v-if="isPrometheus"
+ class="gl-my-4"
+ :label="$options.i18n.integrationFormSteps.prometheusFormUrl.label"
+ label-for="api-url"
+ :invalid-feedback="prometheusUrlInvalidFeedback"
+ :state="validationState.apiUrl"
+ >
<gl-form-input
- id="integration-apiUrl"
+ id="api-url"
v-model="integrationForm.apiUrl"
type="text"
:placeholder="$options.placeholders.prometheus"
+ @input="validateApiUrl"
/>
-
<span class="gl-text-gray-400">
{{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }}
</span>
- </div>
+ </gl-form-group>
<template v-if="showMappingBuilder">
<gl-form-group
data-testid="sample-payload-section"
:label="
getLabelWithStepNumber(
- $options.integrationSteps.setSamplePayload,
- $options.i18n.integrationFormSteps.setSamplePayload.label,
+ $options.integrationSteps.customizeMapping,
+ $options.i18n.integrationFormSteps.mapFields.label,
)
"
label-for="sample-payload"
class="gl-mb-0!"
:invalid-feedback="samplePayload.error"
>
- <alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp"
- :link="generic.alertsUsageUrl"
- />
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span>
<gl-form-textarea
id="sample-payload"
- v-model.trim="samplePayload.json"
- :disabled="isPayloadEditDisabled"
+ v-model="samplePayload.json"
+ :disabled="canEditPayload"
:state="isSampePayloadValid"
- :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
+ :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder"
class="gl-my-3"
:debounce="$options.JSON_VALIDATE_DELAY"
rows="6"
@@ -450,71 +538,76 @@ export default {
v-if="canEditPayload"
v-gl-modal.resetPayloadModal
data-testid="payload-action-btn"
- :disabled="!active"
+ :disabled="!integrationForm.active"
class="gl-mt-3"
>
- {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }}
+ {{ $options.i18n.integrationFormSteps.mapFields.editPayload }}
</gl-button>
<gl-button
v-else
data-testid="payload-action-btn"
:class="{ 'gl-mt-3': samplePayload.error }"
- :disabled="canParseSamplePayload"
- :loading="parsingPayload"
- @click="parseMapping"
+ :disabled="!canParseSamplePayload"
+ :loading="samplePayload.loading"
+ @click="parseSamplePayload"
>
- {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }}
+ {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }}
</gl-button>
<gl-modal
modal-id="resetPayloadModal"
- :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader"
- :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk"
+ :title="$options.i18n.integrationFormSteps.mapFields.resetHeader"
+ :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk"
ok-variant="danger"
- @ok="resetPayloadAndMapping"
+ @ok="resetPayloadAndMappingConfirmed = true"
>
- {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }}
+ {{ $options.i18n.integrationFormSteps.mapFields.resetBody }}
</gl-modal>
- <gl-form-group
- id="mapping-builder"
- class="gl-mt-5"
- :label="
- getLabelWithStepNumber(
- $options.integrationSteps.customizeMapping,
- $options.i18n.integrationFormSteps.mapFields.label,
- )
- "
- label-for="mapping-builder"
- >
- <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span>
+ <div class="gl-mt-5">
+ <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span>
<mapping-builder
:parsed-payload="parsedPayload"
:saved-mapping="mapping"
:alert-fields="alertFields"
@onMappingUpdate="updateMapping"
/>
- </gl-form-group>
+ </div>
</template>
</div>
-
<div class="gl-display-flex gl-justify-content-start gl-py-3">
<gl-button
- type="submit"
+ :disabled="!canSubmitForm"
variant="confirm"
class="js-no-auto-disable"
data-testid="integration-form-submit"
+ @click="submit(false)"
>
{{ $options.i18n.saveIntegration }}
</gl-button>
+ <gl-button
+ :disabled="!canSubmitForm"
+ variant="confirm"
+ category="secondary"
+ class="gl-ml-3 js-no-auto-disable"
+ data-testid="integration-form-test-and-submit"
+ @click="submit(true)"
+ >
+ {{ $options.i18n.saveAndTestIntegration }}
+ </gl-button>
+
<gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
$options.i18n.cancelAndClose
}}</gl-button>
</div>
</gl-tab>
- <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating">
+ <gl-tab
+ :title="$options.i18n.integrationTabs.viewCredentials"
+ :disabled="isCreating"
+ class="gl-mt-3"
+ >
<alert-settings-form-help-block
:message="viewCredentialsHelpMsg"
link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html"
@@ -559,13 +652,15 @@ export default {
</div>
</gl-form-group>
- <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger">
- {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
- </gl-button>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button v-gl-modal.authKeyModal variant="danger">
+ {{ $options.i18n.integrationFormSteps.setupCredentials.reset }}
+ </gl-button>
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
- $options.i18n.cancelAndClose
- }}</gl-button>
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
+ {{ $options.i18n.cancelAndClose }}
+ </gl-button>
+ </div>
<gl-modal
modal-id="authKeyModal"
@@ -578,18 +673,22 @@ export default {
</gl-modal>
</gl-tab>
- <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating">
+ <gl-tab
+ :title="$options.i18n.integrationTabs.sendTestAlert"
+ :disabled="isCreating"
+ class="gl-mt-3"
+ >
<gl-form-group id="test-integration" :invalid-feedback="testPayload.error">
<alert-settings-form-help-block
- :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp"
- :link="generic.alertsUsageUrl"
+ :message="$options.i18n.integrationFormSteps.testPayload.help"
+ :link="alertsUsageUrl"
/>
<gl-form-textarea
id="test-payload"
- v-model.trim="testPayload.json"
+ v-model="testPayload.json"
:state="isTestPayloadValid"
- :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder"
+ :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder"
class="gl-my-3"
:debounce="$options.JSON_VALIDATE_DELAY"
rows="6"
@@ -597,20 +696,35 @@ export default {
@input="validateJson(false)"
/>
</gl-form-group>
+ <div class="gl-display-flex gl-justify-content-start gl-py-3">
+ <gl-button
+ v-gl-modal="testAlertModal"
+ :disabled="!isTestPayloadValid"
+ :loading="loading"
+ data-testid="send-test-alert"
+ variant="confirm"
+ class="js-no-auto-disable"
+ @click="isFormDirty ? null : sendTestAlert()"
+ >
+ {{ $options.i18n.send }}
+ </gl-button>
- <gl-button
- :disabled="!isTestPayloadValid"
- data-testid="send-test-alert"
- variant="confirm"
- class="js-no-auto-disable"
- @click="sendTestAlert"
- >
- {{ $options.i18n.send }}
- </gl-button>
+ <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">
+ {{ $options.i18n.cancelAndClose }}
+ </gl-button>
+ </div>
- <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{
- $options.i18n.cancelAndClose
- }}</gl-button>
+ <gl-modal
+ :modal-id="$options.testAlertModalId"
+ :title="$options.i18n.integrationFormSteps.testPayload.modalTitle"
+ :action-primary="$options.primaryProps"
+ :action-secondary="$options.secondaryProps"
+ :action-cancel="$options.cancelProps"
+ @primary="saveAndSendTestAlert"
+ @secondary="sendTestAlert"
+ >
+ {{ $options.i18n.integrationFormSteps.testPayload.modalBody }}
+ </gl-modal>
</gl-tab>
</gl-tabs>
</gl-form>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 3ffb652e61b..f51c8d7e9f7 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -1,11 +1,11 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlAlert } from '@gitlab/ui';
import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
-import { s__ } from '~/locale';
-import { typeSet } from '../constants';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { typeSet, i18n, tabIndices } from '../constants';
import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql';
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
@@ -28,21 +28,12 @@ import {
RESET_INTEGRATION_TOKEN_ERROR,
UPDATE_INTEGRATION_ERROR,
INTEGRATION_PAYLOAD_TEST_ERROR,
+ INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR,
+ DEFAULT_ERROR,
} from '../utils/error_messages';
import IntegrationsList from './alerts_integrations_list.vue';
import AlertSettingsForm from './alerts_settings_form.vue';
-export const i18n = {
- changesSaved: s__(
- 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.',
- ),
- integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'),
- alertSent: s__(
- 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.',
- ),
- addNewIntegration: s__('AlertSettings|Add new integration'),
-};
-
export default {
typeSet,
i18n,
@@ -50,14 +41,9 @@ export default {
IntegrationsList,
AlertSettingsForm,
GlButton,
+ GlAlert,
},
inject: {
- generic: {
- default: {},
- },
- prometheus: {
- default: {},
- },
projectPath: {
default: '',
},
@@ -124,7 +110,10 @@ export default {
integrations: {},
httpIntegrations: {},
currentIntegration: null,
+ newIntegration: null,
formVisible: false,
+ showSuccessfulCreateAlert: false,
+ tabIndex: tabIndices.configureDetails,
};
},
computed: {
@@ -139,10 +128,10 @@ export default {
isHttp(type) {
return type === typeSet.http;
},
- createNewIntegration({ type, variables }) {
+ createNewIntegration({ type, variables }, testAfterSubmit) {
const { projectPath } = this;
-
const isHttp = this.isHttp(type);
+
this.isUpdating = true;
this.$apollo
.mutate({
@@ -163,16 +152,19 @@ export default {
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0];
if (error) {
- return createFlash({ message: error });
+ createFlash({ message: error });
+ return;
}
- const { integration } = httpIntegrationCreate || prometheusIntegrationCreate;
- this.editIntegration(integration);
+ const { integration } = httpIntegrationCreate || prometheusIntegrationCreate;
+ this.newIntegration = integration;
+ this.showSuccessfulCreateAlert = true;
- return createFlash({
- message: this.$options.i18n.changesSaved,
- type: FLASH_TYPES.SUCCESS,
- });
+ if (testAfterSubmit) {
+ this.viewIntegration(this.newIntegration, tabIndices.sendTestAlert);
+ } else {
+ this.setFormVisibility(false);
+ }
})
.catch(() => {
createFlash({ message: ADD_INTEGRATION_ERROR });
@@ -181,9 +173,9 @@ export default {
this.isUpdating = false;
});
},
- updateIntegration({ type, variables }) {
+ updateIntegration({ type, variables }, testAfterSubmit) {
this.isUpdating = true;
- this.$apollo
+ return this.$apollo
.mutate({
mutation: this.isHttp(type)
? updateHttpIntegrationMutation
@@ -196,12 +188,20 @@ export default {
.then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => {
const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0];
if (error) {
- return createFlash({ message: error });
+ createFlash({ message: error });
+ return;
}
- this.clearCurrentIntegration({ type });
+ const integration =
+ httpIntegrationUpdate?.integration || prometheusIntegrationUpdate?.integration;
- return createFlash({
+ if (testAfterSubmit) {
+ this.viewIntegration(integration, tabIndices.sendTestAlert);
+ } else {
+ this.clearCurrentIntegration(type);
+ }
+
+ createFlash({
message: this.$options.i18n.changesSaved,
type: FLASH_TYPES.SUCCESS,
});
@@ -261,13 +261,23 @@ export default {
currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
}
- this.$apollo.mutate({
- mutation: this.isHttp(type)
- ? updateCurrentHttpIntegrationMutation
- : updateCurrentPrometheusIntegrationMutation,
- variables: currentIntegration,
- });
- this.setFormVisibility(true);
+ this.viewIntegration(currentIntegration, tabIndices.viewCredentials);
+ },
+ viewIntegration(integration, tabIndex) {
+ this.$apollo
+ .mutate({
+ mutation: this.isHttp(integration.type)
+ ? updateCurrentHttpIntegrationMutation
+ : updateCurrentPrometheusIntegrationMutation,
+ variables: integration,
+ })
+ .then(() => {
+ this.setFormVisibility(true);
+ this.tabIndex = tabIndex;
+ })
+ .catch(() => {
+ createFlash({ message: DEFAULT_ERROR });
+ });
},
deleteIntegration({ id, type }) {
const { projectPath } = this;
@@ -319,19 +329,44 @@ export default {
type: FLASH_TYPES.SUCCESS,
});
})
- .catch(() => {
- createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ .catch((error) => {
+ let message = INTEGRATION_PAYLOAD_TEST_ERROR;
+ if (error.response?.status === httpStatusCodes.FORBIDDEN) {
+ message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR;
+ }
+ createFlash({ message });
});
},
+ saveAndTestAlertPayload(integration, payload) {
+ return this.updateIntegration(integration, false).then(() => {
+ this.testAlertPayload(payload);
+ });
+ },
setFormVisibility(visible) {
this.formVisible = visible;
},
+ viewCreatedIntegration() {
+ this.viewIntegration(this.newIntegration, tabIndices.viewCredentials);
+ this.showSuccessfulCreateAlert = false;
+ this.newIntegration = null;
+ },
},
};
</script>
<template>
<div>
+ <gl-alert
+ v-if="showSuccessfulCreateAlert"
+ class="gl-mt-n2"
+ :primary-button-text="$options.i18n.integrationCreated.btnCaption"
+ :title="$options.i18n.integrationCreated.title"
+ @primaryAction="viewCreatedIntegration"
+ @dismiss="showSuccessfulCreateAlert = false"
+ >
+ {{ $options.i18n.integrationCreated.successMsg }}
+ </gl-alert>
+
<integrations-list
:integrations="integrations.list"
:loading="loading"
@@ -353,11 +388,13 @@ export default {
:loading="isUpdating"
:can-add-integration="canAddIntegration"
:alert-fields="alertFields"
+ :tab-index="tabIndex"
@create-new-integration="createNewIntegration"
@update-integration="updateIntegration"
@reset-token="resetToken"
@clear-current-integration="clearCurrentIntegration"
@test-alert-payload="testAlertPayload"
+ @save-and-test-alert-payload="saveAndTestAlertPayload"
/>
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index ce6cf61b5dd..4a180ed2bc0 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -10,96 +10,118 @@ export const i18n = {
selectType: {
label: s__('AlertSettings|Select integration type'),
enterprise: s__(
- 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ 'AlertSettings|Free versions of GitLab are limited to one integration per type. To add more, %{linkStart}upgrade your subscription%{linkEnd}.',
),
},
nameIntegration: {
label: s__('AlertSettings|Name integration'),
placeholder: s__('AlertSettings|Enter integration name'),
activeToggle: __('Active'),
+ error: __("Name can't be blank"),
+ },
+ enableIntegration: {
+ label: s__('AlertSettings|Enable integration'),
+ help: s__(
+ 'AlertSettings|A webhook URL and authorization key is generated for the integration. After you save the integration, both are visible under the “View credentials” tab.',
+ ),
},
setupCredentials: {
help: s__(
- "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ 'AlertSettings|Use the URL and authorization key below to configure how an external service sends alerts to GitLab. %{linkStart}How do I configure the endpoint?%{linkEnd}',
),
prometheusHelp: s__(
- 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
+ 'AlertSettings|Use the URL and authorization key below to configure how Prometheus sends alerts to GitLab. Review the %{linkStart}GitLab documentation%{linkEnd} to learn how to configure your endpoint.',
),
webhookUrl: s__('AlertSettings|Webhook URL'),
authorizationKey: s__('AlertSettings|Authorization key'),
reset: s__('AlertSettings|Reset Key'),
},
- setSamplePayload: {
- label: s__('AlertSettings|Sample alert payload (optional)'),
- testPayloadHelpHttp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional).',
- ),
- testPayloadHelp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.',
+ mapFields: {
+ label: s__('AlertSettings|Customize alert payload mapping (optional)'),
+ help: s__(
+ 'AlertSettings|To create a custom mapping, enter an example payload from your monitoring tool, in JSON format. Select the "Parse payload fields" button to continue.',
),
placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
- resetHeader: s__('AlertSettings|Reset the mapping'),
- resetBody: s__(
- "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
- ),
- resetOk: s__('AlertSettings|Proceed with editing'),
editPayload: s__('AlertSettings|Edit payload'),
- parsePayload: s__('AlertSettings|Parse payload for custom mapping'),
+ parsePayload: s__('AlertSettings|Parse payload fields'),
payloadParsedSucessMsg: s__(
'AlertSettings|Sample payload has been parsed. You can now map the fields.',
),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__('AlertSettings|If you edit the payload, you must re-map the fields again.'),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ mapIntro: s__(
+ 'AlertSettings|You can map default GitLab alert fields to your payload keys in the dropdowns below.',
+ ),
},
- mapFields: {
- label: s__('AlertSettings|Customize alert payload mapping (optional)'),
- intro: s__(
- 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.',
+ testPayload: {
+ help: s__(
+ 'AlertSettings|Enter an example payload from your selected monitoring tool. This supports sending alerts to a GitLab endpoint.',
),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ modalTitle: s__('AlertSettings|The form has unsaved changes'),
+ modalBody: s__('AlertSettings|The form has unsaved changes. How would you like to proceed?'),
+ savedAndTest: s__('AlertSettings|Save integration & send'),
+ proceedWithoutSave: s__('AlertSettings|Send without saving'),
+ cancel: __('Cancel'),
},
prometheusFormUrl: {
label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http: or https:.'),
+ blankUrlError: __('URL cannot be blank'),
+ invalidUrlError: __('URL is invalid'),
},
restKeyInfo: {
label: s__(
- 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ 'AlertSettings|If you reset the authorization key for this project, you must update the key in every enabled alert source.',
),
},
},
saveIntegration: s__('AlertSettings|Save integration'),
- changesSaved: s__('AlertSettings|Your integration was successfully updated.'),
+ saveAndTestIntegration: s__('AlertSettings|Save & create test alert'),
cancelAndClose: __('Cancel and close'),
- send: s__('AlertSettings|Send'),
+ send: __('Send'),
copy: __('Copy'),
+ integrationCreated: {
+ title: s__('AlertSettings|Integration successfully saved'),
+ successMsg: s__(
+ 'AlertSettings|GitLab has created a URL and authorization key for your integration. You can use them to set up a webhook and authorize your endpoint to send alerts to GitLab.',
+ ),
+ btnCaption: s__('AlertSettings|View URL and authorization key'),
+ },
+ changesSaved: s__('AlertsIntegrations|The integration is saved.'),
+ integrationRemoved: s__('AlertsIntegrations|The integration is deleted.'),
+ alertSent: s__('AlertsIntegrations|The test alert should now be visible in your alerts list.'),
+ addNewIntegration: s__('AlertSettings|Add new integration'),
};
export const integrationSteps = {
selectType: 'SELECT_TYPE',
nameIntegration: 'NAME_INTEGRATION',
- setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL',
- setSamplePayload: 'SET_SAMPLE_PAYLOAD',
+ enableHttpIntegration: 'ENABLE_HTTP_INTEGRATION',
+ enablePrometheusIntegration: 'ENABLE_PROMETHEUS_INTEGRATION',
customizeMapping: 'CUSTOMIZE_MAPPING',
};
export const createStepNumbers = {
[integrationSteps.selectType]: 1,
[integrationSteps.nameIntegration]: 2,
- [integrationSteps.setPrometheusApiUrl]: 2,
- [integrationSteps.setSamplePayload]: 3,
+ [integrationSteps.enableHttpIntegration]: 3,
+ [integrationSteps.enablePrometheusIntegration]: 2,
[integrationSteps.customizeMapping]: 4,
};
export const editStepNumbers = {
- [integrationSteps.selectType]: 1,
[integrationSteps.nameIntegration]: 1,
- [integrationSteps.setPrometheusApiUrl]: null,
- [integrationSteps.setSamplePayload]: 2,
+ [integrationSteps.enableHttpIntegration]: 2,
+ [integrationSteps.enablePrometheusIntegration]: null,
[integrationSteps.customizeMapping]: 3,
};
export const integrationTypes = {
none: { value: '', text: s__('AlertSettings|Select integration type') },
http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
- prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
+ prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|Prometheus') },
};
export const typeSet = {
@@ -127,4 +149,10 @@ export const mappingFields = {
fallback: 'fallback',
};
-export const viewCredentialsTabIndex = 1;
+export const tabIndices = {
+ configureDetails: 0,
+ viewCredentials: 1,
+ sendTestAlert: 2,
+};
+
+export const testAlertModalId = 'confirmSendTestAlert';
diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js
index 321af9fedb6..953a867b2b7 100644
--- a/app/assets/javascripts/alerts_settings/index.js
+++ b/app/assets/javascripts/alerts_settings/index.js
@@ -3,12 +3,15 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue';
import apolloProvider from './graphql';
+import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql';
-apolloProvider.clients.defaultClient.cache.writeData({
+apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: getCurrentIntegrationQuery,
data: {
currentIntegration: null,
},
});
+
Vue.use(GlToast);
export default (el) => {
@@ -16,23 +19,7 @@ export default (el) => {
return null;
}
- const {
- prometheusActivated,
- prometheusUrl,
- prometheusAuthorizationKey,
- prometheusFormPath,
- prometheusResetKeyPath,
- prometheusApiUrl,
- activated: activatedStr,
- alertsSetupUrl,
- alertsUsageUrl,
- formPath,
- authorizationKey,
- url,
- projectPath,
- multiIntegrations,
- alertFields,
- } = el.dataset;
+ const { alertsUsageUrl, projectPath, multiIntegrations, alertFields } = el.dataset;
return new Vue({
el,
@@ -40,22 +27,7 @@ export default (el) => {
AlertSettingsWrapper,
},
provide: {
- prometheus: {
- active: parseBoolean(prometheusActivated),
- url: prometheusUrl,
- token: prometheusAuthorizationKey,
- prometheusFormPath,
- prometheusResetKeyPath,
- prometheusApiUrl,
- },
- generic: {
- alertsSetupUrl,
- alertsUsageUrl,
- active: parseBoolean(activatedStr),
- formPath,
- token: authorizationKey,
- url,
- },
+ alertsUsageUrl,
projectPath,
multiIntegrations: parseBoolean(multiIntegrations),
},
diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js
index e380257f983..9a0644b4e22 100644
--- a/app/assets/javascripts/alerts_settings/utils/error_messages.js
+++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const DELETE_INTEGRATION_ERROR = s__(
'AlertsIntegrations|The integration could not be deleted. Please try again.',
@@ -19,3 +19,9 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__(
export const INTEGRATION_PAYLOAD_TEST_ERROR = s__(
'AlertsIntegrations|Integration payload is invalid.',
);
+
+export const INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR = s__(
+ 'AlertsIntegrations|The integration is currently inactive. Enable the integration to send the test alert.',
+);
+
+export const DEFAULT_ERROR = __('Something went wrong on our end.');
diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
index 014f823cdc4..ea11ecb0c5b 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
+++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js
@@ -83,7 +83,7 @@ export default [
'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
),
noDataMessage,
- chartTitle: s__('UsageTrends|Issues & Merge Requests'),
+ chartTitle: s__('UsageTrends|Issues & merge requests'),
yAxisTitle: s__('UsageTrends|Items'),
xAxisTitle: s__('UsageTrends|Month'),
queries: [
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 0630cca93ae..80ad36d0519 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -45,7 +45,7 @@ export default {
projects: s__('UsageTrends|Projects'),
groups: s__('UsageTrends|Groups'),
issues: s__('UsageTrends|Issues'),
- mergeRequests: s__('UsageTrends|Merge Requests'),
+ mergeRequests: s__('UsageTrends|Merge requests'),
pipelines: s__('UsageTrends|Pipelines'),
},
loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'),
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 48005787d81..516235657cb 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -44,7 +44,7 @@ const Api = {
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
mergeRequestsPath: '/api/:version/merge_requests',
- groupLabelsPath: '/groups/:namespace_path/-/labels',
+ groupLabelsPath: '/api/:version/groups/:namespace_path/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
issuableTemplatesPath: '/:namespace_path/:project_path/templates/:type',
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
@@ -79,6 +79,7 @@ const Api = {
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
+ freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id',
usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter',
usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
@@ -282,7 +283,7 @@ const Api = {
},
/**
- * Get all Merge Requests for a project, eventually filtering based on
+ * Get all merge requests for a project, eventually filtering based on
* supplied parameters
* @param projectPath
* @param params
@@ -306,7 +307,7 @@ const Api = {
return axios.post(url, options);
},
- // Return Merge Request for project
+ // Return merge request for project
projectMergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.projectMergeRequestPath)
.replace(':id', encodeURIComponent(projectPath))
@@ -401,18 +402,29 @@ const Api = {
newLabel(namespacePath, projectPath, data, callback) {
let url;
+ let payload;
if (projectPath) {
url = Api.buildUrl(Api.projectLabelsPath)
.replace(':namespace_path', namespacePath)
.replace(':project_path', projectPath);
+ payload = {
+ label: data,
+ };
} else {
url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath);
+
+ // groupLabelsPath uses public API which accepts
+ // `name` and `color` props.
+ payload = {
+ name: data.title,
+ color: data.color,
+ };
}
return axios
.post(url, {
- label: data,
+ ...payload,
})
.then((res) => callback(res.data))
.catch((e) => callback(e.response.data));
@@ -784,7 +796,7 @@ const Api = {
return axios.delete(url, { data });
},
- getRawFile(id, path, params = { ref: 'master' }) {
+ getRawFile(id, path, params = {}) {
const url = Api.buildUrl(this.rawFilePath)
.replace(':id', encodeURIComponent(id))
.replace(':path', encodeURIComponent(path));
@@ -832,6 +844,14 @@ const Api = {
return axios.post(url, freezePeriod);
},
+ updateFreezePeriod(id, freezePeriod = {}) {
+ const url = Api.buildUrl(this.freezePeriodPath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':freeze_period_id', encodeURIComponent(freezePeriod.id));
+
+ return axios.put(url, freezePeriod);
+ },
+
trackRedisCounterEvent(event) {
if (!gon.features?.usageDataApi) {
return null;
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index 5efc7063efa..27901120c53 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -55,12 +55,13 @@ export function getUserProjects(userId, query, options, callback) {
.catch(() => flash(__('Something went wrong while fetching projects')));
}
-export function updateUserStatus({ emoji, message, availability }) {
+export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
const url = buildApiUrl(USER_POST_STATUS_PATH);
return axios.put(url, {
emoji,
message,
availability,
+ clear_status_after: clearStatusAfter,
});
}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index dbdc7e43d2d..3a2f2078e44 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -446,7 +446,7 @@ export class AwardsHandler {
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
- <button class="btn award-control js-emoji-btn has-tooltip active" title="You">
+ <button class="gl-button btn btn-default award-control js-emoji-btn has-tooltip active" title="You">
${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 9e5d70075f3..309af368df9 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -1,7 +1,11 @@
<script>
import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
+ i18n: {
+ buttonLabel: s__('Badges|Reload badge image'),
+ },
// name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Badge',
@@ -94,7 +98,8 @@ export default {
<gl-button
v-show="hasError"
v-gl-tooltip.hover
- :title="s__('Badges|Reload badge image')"
+ :title="$options.i18n.buttonLabel"
+ :aria-label="$options.i18n.buttonLabel"
category="tertiary"
variant="confirm"
type="button"
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index 756bcfdb3d0..753608cf6f7 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -41,13 +41,17 @@ export default {
titleText() {
const file = this.discussion ? this.discussion.diff_file : this.draft;
- if (file) {
+ if (file?.file_path) {
return file.file_path;
}
- return sprintf(__("%{authorsName}'s thread"), {
- authorsName: this.discussion.notes.find((note) => !note.system).author.name,
- });
+ if (this.discussion) {
+ return sprintf(__("%{authorsName}'s thread"), {
+ authorsName: this.discussion.notes.find((note) => !note.system).author.name,
+ });
+ }
+
+ return __('Your new comment');
},
linePosition() {
if (this.position?.position_type === IMAGE_DIFF_POSITION_TYPE) {
@@ -94,7 +98,7 @@ export default {
<span class="review-preview-item-header">
<gl-icon class="flex-shrink-0" :name="iconName" />
<span class="bold text-nowrap gl-align-items-center">
- <span class="review-preview-item-header-text block-truncated">
+ <span class="review-preview-item-header-text block-truncated gl-ml-2">
{{ titleText }}
</span>
<template v-if="showLinePosition">
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
index 0b085da1ff9..bec360e3b2e 100644
--- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -1,9 +1,7 @@
import { mapGetters } from 'vuex';
import { sprintf, s__, __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
- mixins: [glFeatureFlagsMixin()],
props: {
discussionId: {
type: String,
@@ -52,16 +50,18 @@ export default {
return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion';
},
resolveButtonTitle() {
- if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
+ const escapeParameters = false;
- let title = __('Mark as resolved');
+ if (this.isDraft || this.discussionId) return this.resolvedStatusMessage;
- if (this.glFeatures.removeResolveNote) {
- title = __('Resolve thread');
- }
+ let title = __('Resolve thread');
if (this.resolvedBy) {
- title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
+ title = sprintf(
+ __('Resolved by %{name}'),
+ { name: this.resolvedBy.name },
+ escapeParameters,
+ );
}
return title;
diff --git a/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js b/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js
new file mode 100644
index 00000000000..5731474e3a4
--- /dev/null
+++ b/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js
@@ -0,0 +1,15 @@
+import $ from 'jquery';
+
+export default function initDeprecatedRemoveRowBehavior() {
+ $('.js-remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
+ $(this).closest('li').addClass('gl-display-none!');
+ });
+
+ $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
+ $(this).parent().find('.btn').addClass('disabled');
+ });
+
+ $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
+ $(this).closest('tr').addClass('gl-display-none!');
+ });
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 0cb13815c7e..5b5148a850b 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { once } from 'lodash';
import { deprecatedCreateFlash as flash } from '~/flash';
+import { darkModeEnabled } from '~/lib/utils/color_utils';
import { __, sprintf } from '~/locale';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
@@ -27,37 +28,34 @@ let renderedMermaidBlocks = 0;
let mermaidModule = {};
+export function initMermaid(mermaid) {
+ let theme = 'neutral';
+
+ if (darkModeEnabled()) {
+ theme = 'dark';
+ }
+
+ mermaid.initialize({
+ // mermaid core options
+ mermaid: {
+ startOnLoad: false,
+ },
+ // mermaidAPI options
+ theme,
+ flowchart: {
+ useMaxWidth: true,
+ htmlLabels: false,
+ },
+ securityLevel: 'strict',
+ });
+
+ return mermaid;
+}
+
function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then((mermaid) => {
- let theme = 'neutral';
- const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
-
- if (
- ideDarkThemes.includes(window.gon?.user_color_scheme) &&
- // if on the Web IDE page
- document.querySelector('.ide')
- ) {
- theme = 'dark';
- }
-
- mermaid.initialize({
- // mermaid core options
- mermaid: {
- startOnLoad: false,
- },
- // mermaidAPI options
- theme,
- flowchart: {
- useMaxWidth: true,
- htmlLabels: false,
- },
- securityLevel: 'strict',
- });
-
- mermaidModule = mermaid;
-
- return mermaid;
+ mermaidModule = initMermaid(mermaid);
})
.catch((err) => {
flash(sprintf(__("Can't load mermaid module: %{err}"), { err }));
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index a8fe00d26e6..6abbd7f3243 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -1,6 +1,6 @@
import { memoize } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
-import { s__ } from '~/locale';
+import { __ } from '~/locale';
const isCustomizable = (command) =>
'customizable' in command ? Boolean(command.customizable) : true;
@@ -33,42 +33,608 @@ export const getCustomizations = memoize(() => {
});
// All available commands
+export const TOGGLE_KEYBOARD_SHORTCUTS_DIALOG = {
+ id: 'globalShortcuts.toggleKeyboardShortcutsDialog',
+ description: __('Toggle keyboard shortcuts help dialog'),
+ defaultKeys: ['?'],
+};
+
+export const GO_TO_YOUR_PROJECTS = {
+ id: 'globalShortcuts.goToYourProjects',
+ description: __('Go to your projects'),
+ defaultKeys: ['shift+p'],
+};
+
+export const GO_TO_YOUR_GROUPS = {
+ id: 'globalShortcuts.goToYourGroups',
+ description: __('Go to your groups'),
+ defaultKeys: ['shift+g'],
+};
+
+export const GO_TO_ACTIVITY_FEED = {
+ id: 'globalShortcuts.goToActivityFeed',
+ description: __('Go to the activity feed'),
+ defaultKeys: ['shift+a'],
+};
+
+export const GO_TO_MILESTONE_LIST = {
+ id: 'globalShortcuts.goToMilestoneList',
+ description: __('Go to the milestone list'),
+ defaultKeys: ['shift+l'],
+};
+
+export const GO_TO_YOUR_SNIPPETS = {
+ id: 'globalShortcuts.goToYourSnippets',
+ description: __('Go to your snippets'),
+ defaultKeys: ['shift+s'],
+};
+
+export const START_SEARCH = {
+ id: 'globalShortcuts.startSearch',
+ description: __('Start search'),
+ defaultKeys: ['s', '/'],
+};
+
+export const FOCUS_FILTER_BAR = {
+ id: 'globalShortcuts.focusFilterBar',
+ description: __('Focus filter bar'),
+ defaultKeys: ['f'],
+};
+
+export const GO_TO_YOUR_ISSUES = {
+ id: 'globalShortcuts.goToYourIssues',
+ description: __('Go to your issues'),
+ defaultKeys: ['shift+i'],
+};
+
+export const GO_TO_YOUR_MERGE_REQUESTS = {
+ id: 'globalShortcuts.goToYourMergeRequests',
+ description: __('Go to your merge requests'),
+ defaultKeys: ['shift+m'],
+};
+
+export const GO_TO_YOUR_TODO_LIST = {
+ id: 'globalShortcuts.goToYourTodoList',
+ description: __('Go to your To-Do list'),
+ defaultKeys: ['shift+t'],
+};
+
export const TOGGLE_PERFORMANCE_BAR = {
id: 'globalShortcuts.togglePerformanceBar',
- description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['p b'],
+ description: __('Toggle the Performance Bar'),
+ defaultKeys: ['p b'], // eslint-disable-line @gitlab/require-i18n-strings
};
export const TOGGLE_CANARY = {
id: 'globalShortcuts.toggleCanary',
- description: s__('KeyboardShortcuts|Toggle GitLab Next'),
- // eslint-disable-next-line @gitlab/require-i18n-strings
- defaultKeys: ['g x'],
+ description: __('Toggle GitLab Next'),
+ defaultKeys: ['g x'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const BOLD_TEXT = {
+ id: 'editing.boldText',
+ description: __('Bold text'),
+ defaultKeys: ['mod+b'],
+ customizable: false,
+};
+
+export const ITALIC_TEXT = {
+ id: 'editing.italicText',
+ description: __('Italic text'),
+ defaultKeys: ['mod+i'],
+ customizable: false,
+};
+
+export const LINK_TEXT = {
+ id: 'editing.linkText',
+ description: __('Link text'),
+ defaultKeys: ['mod+k'],
+ customizable: false,
+};
+
+export const TOGGLE_MARKDOWN_PREVIEW = {
+ id: 'editing.toggleMarkdownPreview',
+ description: __('Toggle Markdown preview'),
+ // Note: Ideally, keyboard shortcuts should be made cross-platform by using the special `mod` key
+ // instead of binding both `ctrl` and `command` versions of the shortcut.
+ // See https://docs.gitlab.com/ee/development/fe_guide/keyboard_shortcuts.html#make-cross-platform-shortcuts.
+ // However, this particular shortcut has been in place since before the `mod` key was available.
+ // We've chosen to leave this implemented as-is for the time being to avoid breaking people's workflows.
+ // See discussion in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45308#note_527490548.
+ defaultKeys: ['ctrl+shift+p', 'command+shift+p'],
+};
+
+export const EDIT_RECENT_COMMENT = {
+ id: 'editing.editRecentComment',
+ description: __('Edit your most recent comment in a thread (from an empty textarea)'),
+ defaultKeys: ['up'],
+};
+
+export const EDIT_WIKI_PAGE = {
+ id: 'wiki.editWikiPage',
+ description: __('Edit wiki page'),
+ defaultKeys: ['e'],
+};
+
+export const REPO_GRAPH_SCROLL_LEFT = {
+ id: 'repositoryGraph.scrollLeft',
+ description: __('Scroll left'),
+ defaultKeys: ['left', 'h'],
+};
+
+export const REPO_GRAPH_SCROLL_RIGHT = {
+ id: 'repositoryGraph.scrollRight',
+ description: __('Scroll right'),
+ defaultKeys: ['right', 'l'],
+};
+
+export const REPO_GRAPH_SCROLL_UP = {
+ id: 'repositoryGraph.scrollUp',
+ description: __('Scroll up'),
+ defaultKeys: ['up', 'k'],
+};
+
+export const REPO_GRAPH_SCROLL_DOWN = {
+ id: 'repositoryGraph.scrollDown',
+ description: __('Scroll down'),
+ defaultKeys: ['down', 'j'],
+};
+
+export const REPO_GRAPH_SCROLL_TOP = {
+ id: 'repositoryGraph.scrollToTop',
+ description: __('Scroll to top'),
+ defaultKeys: ['shift+up', 'shift+k'],
+};
+
+export const REPO_GRAPH_SCROLL_BOTTOM = {
+ id: 'repositoryGraph.scrollToBottom',
+ description: __('Scroll to bottom'),
+ defaultKeys: ['shift+down', 'shift+j'],
+};
+
+export const GO_TO_PROJECT_OVERVIEW = {
+ id: 'project.goToOverview',
+ description: __("Go to the project's overview page"),
+ defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_ACTIVITY_FEED = {
+ id: 'project.goToActivityFeed',
+ description: __("Go to the project's activity feed"),
+ defaultKeys: ['g v'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_RELEASES = {
+ id: 'project.goToReleases',
+ description: __('Go to releases'),
+ defaultKeys: ['g r'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_FILES = {
+ id: 'project.goToFiles',
+ description: __('Go to files'),
+ defaultKeys: ['g f'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_FIND_FILE = {
+ id: 'project.goToFindFile',
+ description: __('Go to find file'),
+ defaultKeys: ['t'],
+};
+
+export const GO_TO_PROJECT_COMMITS = {
+ id: 'project.goToCommits',
+ description: __('Go to commits'),
+ defaultKeys: ['g c'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_REPO_GRAPH = {
+ id: 'project.goToRepoGraph',
+ description: __('Go to repository graph'),
+ defaultKeys: ['g n'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_REPO_CHARTS = {
+ id: 'project.goToRepoCharts',
+ description: __('Go to repository charts'),
+ defaultKeys: ['g d'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_ISSUES = {
+ id: 'project.goToIssues',
+ description: __('Go to issues'),
+ defaultKeys: ['g i'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const NEW_ISSUE = {
+ id: 'project.newIssue',
+ description: __('New issue'),
+ defaultKeys: ['i'],
+};
+
+export const GO_TO_PROJECT_ISSUE_BOARDS = {
+ id: 'project.goToIssueBoards',
+ description: __('Go to issue boards'),
+ defaultKeys: ['g b'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_MERGE_REQUESTS = {
+ id: 'project.goToMergeRequests',
+ description: __('Go to merge requests'),
+ defaultKeys: ['g m'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_JOBS = {
+ id: 'project.goToJobs',
+ description: __('Go to jobs'),
+ defaultKeys: ['g j'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_METRICS = {
+ id: 'project.goToMetrics',
+ description: __('Go to metrics'),
+ defaultKeys: ['g l'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_ENVIRONMENTS = {
+ id: 'project.goToEnvironments',
+ description: __('Go to environments'),
+ defaultKeys: ['g e'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_KUBERNETES = {
+ id: 'project.goToKubernetes',
+ description: __('Go to kubernetes'),
+ defaultKeys: ['g k'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_SNIPPETS = {
+ id: 'project.goToSnippets',
+ description: __('Go to snippets'),
+ defaultKeys: ['g s'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const GO_TO_PROJECT_WIKI = {
+ id: 'project.goToWiki',
+ description: __('Go to wiki'),
+ defaultKeys: ['g w'], // eslint-disable-line @gitlab/require-i18n-strings
+};
+
+export const PROJECT_FILES_MOVE_SELECTION_UP = {
+ id: 'projectFiles.moveSelectionUp',
+ description: __('Move selection up'),
+ defaultKeys: ['up'],
+};
+
+export const PROJECT_FILES_MOVE_SELECTION_DOWN = {
+ id: 'projectFiles.moveSelectionDown',
+ description: __('Move selection down'),
+ defaultKeys: ['down'],
+};
+
+export const PROJECT_FILES_OPEN_SELECTION = {
+ id: 'projectFiles.openSelection',
+ description: __('Open Selection'),
+ defaultKeys: ['enter'],
+};
+
+export const PROJECT_FILES_GO_BACK = {
+ id: 'projectFiles.goBack',
+ description: __('Go back (while searching for files)'),
+ defaultKeys: ['esc'],
+};
+
+export const PROJECT_FILES_GO_TO_PERMALINK = {
+ id: 'projectFiles.goToFilePermalink',
+ description: __('Go to file permalink (while viewing a file)'),
+ defaultKeys: ['y'],
+};
+
+export const ISSUABLE_COMMENT_OR_REPLY = {
+ id: 'issuables.commentReply',
+ description: __('Comment/Reply (quoting selected text)'),
+ defaultKeys: ['r'],
+};
+
+export const ISSUABLE_EDIT_DESCRIPTION = {
+ id: 'issuables.editDescription',
+ description: __('Edit description'),
+ defaultKeys: ['e'],
+};
+
+export const ISSUABLE_CHANGE_LABEL = {
+ id: 'issuables.changeLabel',
+ description: __('Change label'),
+ defaultKeys: ['l'],
+};
+
+export const ISSUE_MR_CHANGE_ASSIGNEE = {
+ id: 'issuesMRs.changeAssignee',
+ description: __('Change assignee'),
+ defaultKeys: ['a'],
+};
+
+export const ISSUE_MR_CHANGE_MILESTONE = {
+ id: 'issuesMRs.changeMilestone',
+ description: __('Change milestone'),
+ defaultKeys: ['m'],
+};
+
+export const MR_NEXT_FILE_IN_DIFF = {
+ id: 'mergeRequests.nextFileInDiff',
+ description: __('Next file in diff'),
+ defaultKeys: [']', 'j'],
+};
+
+export const MR_PREVIOUS_FILE_IN_DIFF = {
+ id: 'mergeRequests.previousFileInDiff',
+ description: __('Previous file in diff'),
+ defaultKeys: ['[', 'k'],
+};
+
+export const MR_GO_TO_FILE = {
+ id: 'mergeRequests.goToFile',
+ description: __('Go to file'),
+ defaultKeys: ['t', 'mod+p'],
+ customizable: false,
+};
+
+export const MR_NEXT_UNRESOLVED_DISCUSSION = {
+ id: 'mergeRequests.nextUnresolvedDiscussion',
+ description: __('Next unresolved discussion'),
+ defaultKeys: ['n'],
+};
+
+export const MR_PREVIOUS_UNRESOLVED_DISCUSSION = {
+ id: 'mergeRequests.previousUnresolvedDiscussion',
+ description: __('Previous unresolved discussion'),
+ defaultKeys: ['p'],
+};
+
+export const MR_COPY_SOURCE_BRANCH_NAME = {
+ id: 'mergeRequests.copySourceBranchName',
+ description: __('Copy source branch name'),
+ defaultKeys: ['b'],
+};
+
+export const MR_COMMITS_NEXT_COMMIT = {
+ id: 'mergeRequestCommits.nextCommit',
+ description: __('Next commit'),
+ defaultKeys: ['c'],
+};
+
+export const MR_COMMITS_PREVIOUS_COMMIT = {
+ id: 'mergeRequestCommits.previousCommit',
+ description: __('Previous commit'),
+ defaultKeys: ['x'],
+};
+
+export const ISSUE_NEXT_DESIGN = {
+ id: 'issues.nextDesign',
+ description: __('Next design'),
+ defaultKeys: ['right'],
+};
+
+export const ISSUE_PREVIOUS_DESIGN = {
+ id: 'issues.previousDesign',
+ description: __('Previous design'),
+ defaultKeys: ['left'],
+};
+
+export const ISSUE_CLOSE_DESIGN = {
+ id: 'issues.closeDesign',
+ description: __('Close design'),
+ defaultKeys: ['esc'],
+};
+
+export const WEB_IDE_GO_TO_FILE = {
+ id: 'webIDE.goToFile',
+ description: __('Go to file'),
+ defaultKeys: ['mod+p'],
};
export const WEB_IDE_COMMIT = {
id: 'webIDE.commit',
- description: s__('KeyboardShortcuts|Commit (when editing commit message)'),
+ description: __('Commit (when editing commit message)'),
defaultKeys: ['mod+enter'],
customizable: false,
};
+export const METRICS_EXPAND_PANEL = {
+ id: 'metrics.expandPanel',
+ description: __('Expand panel'),
+ defaultKeys: ['e'],
+ customizable: false,
+};
+
+export const METRICS_VIEW_LOGS = {
+ id: 'metrics.viewLogs',
+ description: __('View logs'),
+ defaultKeys: ['l'],
+ customizable: false,
+};
+
+export const METRICS_DOWNLOAD_CSV = {
+ id: 'metrics.downloadCSV',
+ description: __('Download CSV'),
+ defaultKeys: ['d'],
+ customizable: false,
+};
+
+export const METRICS_COPY_LINK_TO_CHART = {
+ id: 'metrics.copyLinkToChart',
+ description: __('Copy link to chart'),
+ defaultKeys: ['c'],
+ customizable: false,
+};
+
+export const METRICS_SHOW_ALERTS = {
+ id: 'metrics.showAlerts',
+ description: __('Alerts'),
+ defaultKeys: ['a'],
+ customizable: false,
+};
+
// All keybinding groups
export const GLOBAL_SHORTCUTS_GROUP = {
id: 'globalShortcuts',
- name: s__('KeyboardShortcuts|Global Shortcuts'),
- keybindings: [TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY],
+ name: __('Global Shortcuts'),
+ keybindings: [
+ TOGGLE_KEYBOARD_SHORTCUTS_DIALOG,
+ GO_TO_YOUR_PROJECTS,
+ GO_TO_YOUR_GROUPS,
+ GO_TO_ACTIVITY_FEED,
+ GO_TO_MILESTONE_LIST,
+ GO_TO_YOUR_SNIPPETS,
+ START_SEARCH,
+ FOCUS_FILTER_BAR,
+ GO_TO_YOUR_ISSUES,
+ GO_TO_YOUR_MERGE_REQUESTS,
+ GO_TO_YOUR_TODO_LIST,
+ TOGGLE_PERFORMANCE_BAR,
+ ],
+};
+
+export const EDITING_SHORTCUTS_GROUP = {
+ id: 'editing',
+ name: __('Editing'),
+ keybindings: [BOLD_TEXT, ITALIC_TEXT, LINK_TEXT, TOGGLE_MARKDOWN_PREVIEW, EDIT_RECENT_COMMENT],
+};
+
+export const WIKI_SHORTCUTS_GROUP = {
+ id: 'wiki',
+ name: __('Wiki'),
+ keybindings: [EDIT_WIKI_PAGE],
+};
+
+export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = {
+ id: 'repositoryGraph',
+ name: __('Repository Graph'),
+ keybindings: [
+ REPO_GRAPH_SCROLL_LEFT,
+ REPO_GRAPH_SCROLL_RIGHT,
+ REPO_GRAPH_SCROLL_UP,
+ REPO_GRAPH_SCROLL_DOWN,
+ REPO_GRAPH_SCROLL_TOP,
+ REPO_GRAPH_SCROLL_BOTTOM,
+ ],
+};
+
+export const PROJECT_SHORTCUTS_GROUP = {
+ id: 'project',
+ name: __('Project'),
+ keybindings: [
+ GO_TO_PROJECT_OVERVIEW,
+ GO_TO_PROJECT_ACTIVITY_FEED,
+ GO_TO_PROJECT_RELEASES,
+ GO_TO_PROJECT_FILES,
+ GO_TO_PROJECT_FIND_FILE,
+ GO_TO_PROJECT_COMMITS,
+ GO_TO_PROJECT_REPO_GRAPH,
+ GO_TO_PROJECT_REPO_CHARTS,
+ GO_TO_PROJECT_ISSUES,
+ NEW_ISSUE,
+ GO_TO_PROJECT_ISSUE_BOARDS,
+ GO_TO_PROJECT_MERGE_REQUESTS,
+ GO_TO_PROJECT_JOBS,
+ GO_TO_PROJECT_METRICS,
+ GO_TO_PROJECT_ENVIRONMENTS,
+ GO_TO_PROJECT_KUBERNETES,
+ GO_TO_PROJECT_SNIPPETS,
+ GO_TO_PROJECT_WIKI,
+ ],
+};
+
+export const PROJECT_FILES_SHORTCUTS_GROUP = {
+ id: 'projectFiles',
+ name: __('Project Files'),
+ keybindings: [
+ PROJECT_FILES_MOVE_SELECTION_UP,
+ PROJECT_FILES_MOVE_SELECTION_DOWN,
+ PROJECT_FILES_OPEN_SELECTION,
+ PROJECT_FILES_GO_BACK,
+ PROJECT_FILES_GO_TO_PERMALINK,
+ ],
+};
+
+export const ISSUABLE_SHORTCUTS_GROUP = {
+ id: 'issuables',
+ name: __('Epics, issues, and merge requests'),
+ keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL],
+};
+
+export const ISSUE_MR_SHORTCUTS_GROUP = {
+ id: 'issuesMRs',
+ name: __('Issues and merge requests'),
+ keybindings: [ISSUE_MR_CHANGE_ASSIGNEE, ISSUE_MR_CHANGE_MILESTONE],
};
-export const WEB_IDE_GROUP = {
+export const MR_SHORTCUTS_GROUP = {
+ id: 'mergeRequests',
+ name: __('Merge requests'),
+ keybindings: [
+ MR_NEXT_FILE_IN_DIFF,
+ MR_PREVIOUS_FILE_IN_DIFF,
+ MR_GO_TO_FILE,
+ MR_NEXT_UNRESOLVED_DISCUSSION,
+ MR_PREVIOUS_UNRESOLVED_DISCUSSION,
+ MR_COPY_SOURCE_BRANCH_NAME,
+ ],
+};
+
+export const MR_COMMITS_SHORTCUTS_GROUP = {
+ id: 'mergeRequestCommits',
+ name: __('Merge request commits'),
+ keybindings: [MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT],
+};
+
+export const ISSUES_SHORTCUTS_GROUP = {
+ id: 'issues',
+ name: __('Issues'),
+ keybindings: [ISSUE_NEXT_DESIGN, ISSUE_PREVIOUS_DESIGN, ISSUE_CLOSE_DESIGN],
+};
+
+export const WEB_IDE_SHORTCUTS_GROUP = {
id: 'webIDE',
- name: s__('KeyboardShortcuts|Web IDE'),
- keybindings: [WEB_IDE_COMMIT],
+ name: __('Web IDE'),
+ keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT],
+};
+
+export const METRICS_SHORTCUTS_GROUP = {
+ id: 'metrics',
+ name: __('Metrics'),
+ keybindings: [
+ METRICS_EXPAND_PANEL,
+ METRICS_VIEW_LOGS,
+ METRICS_DOWNLOAD_CSV,
+ METRICS_COPY_LINK_TO_CHART,
+ METRICS_SHOW_ALERTS,
+ ],
+};
+
+export const MISC_SHORTCUTS_GROUP = {
+ id: 'misc',
+ name: __('Miscellaneous'),
+ keybindings: [TOGGLE_CANARY],
};
/** All keybindings, grouped and ordered with descriptions */
-export const keybindingGroups = [GLOBAL_SHORTCUTS_GROUP, WEB_IDE_GROUP];
+export const keybindingGroups = [
+ GLOBAL_SHORTCUTS_GROUP,
+ EDITING_SHORTCUTS_GROUP,
+ WIKI_SHORTCUTS_GROUP,
+ REPOSITORY_GRAPH_SHORTCUTS_GROUP,
+ PROJECT_SHORTCUTS_GROUP,
+ PROJECT_FILES_SHORTCUTS_GROUP,
+ ISSUABLE_SHORTCUTS_GROUP,
+ ISSUE_MR_SHORTCUTS_GROUP,
+ MR_SHORTCUTS_GROUP,
+ MR_COMMITS_SHORTCUTS_GROUP,
+ ISSUES_SHORTCUTS_GROUP,
+ WEB_IDE_SHORTCUTS_GROUP,
+ METRICS_SHORTCUTS_GROUP,
+ MISC_SHORTCUTS_GROUP,
+];
/**
* Gets keyboard shortcuts associated with a command
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index e4ec68601e0..03cba78cf31 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -6,13 +6,29 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import findAndFollowLink from '~/lib/utils/navigation_utility';
import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility';
-
-import { keysFor, TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY } from './keybindings';
+import {
+ keysFor,
+ TOGGLE_KEYBOARD_SHORTCUTS_DIALOG,
+ START_SEARCH,
+ FOCUS_FILTER_BAR,
+ TOGGLE_PERFORMANCE_BAR,
+ TOGGLE_CANARY,
+ TOGGLE_MARKDOWN_PREVIEW,
+ GO_TO_YOUR_TODO_LIST,
+ GO_TO_ACTIVITY_FEED,
+ GO_TO_YOUR_ISSUES,
+ GO_TO_YOUR_MERGE_REQUESTS,
+ GO_TO_YOUR_PROJECTS,
+ GO_TO_YOUR_GROUPS,
+ GO_TO_MILESTONE_LIST,
+ GO_TO_YOUR_SNIPPETS,
+ GO_TO_PROJECT_FIND_FILE,
+} from './keybindings';
import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
const defaultStopCallback = Mousetrap.prototype.stopCallback;
Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
- if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) {
+ if (keysFor(TOGGLE_MARKDOWN_PREVIEW).indexOf(combo) !== -1) {
return false;
}
@@ -58,28 +74,41 @@ export default class Shortcuts {
this.helpModalElement = null;
this.helpModalVueInstance = null;
- Mousetrap.bind('?', this.onToggleHelp);
- Mousetrap.bind('s', Shortcuts.focusSearch);
- Mousetrap.bind('/', Shortcuts.focusSearch);
- Mousetrap.bind('f', this.focusFilter.bind(this));
+ Mousetrap.bind(keysFor(TOGGLE_KEYBOARD_SHORTCUTS_DIALOG), this.onToggleHelp);
+ Mousetrap.bind(keysFor(START_SEARCH), Shortcuts.focusSearch);
+ Mousetrap.bind(keysFor(FOCUS_FILTER_BAR), this.focusFilter.bind(this));
Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar);
Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary);
const findFileURL = document.body.dataset.findFile;
- Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
- Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
- Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
- Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
- Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
- Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
- Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
- Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
-
- Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], Shortcuts.toggleMarkdownPreview);
+ Mousetrap.bind(keysFor(GO_TO_YOUR_TODO_LIST), () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind(keysFor(GO_TO_ACTIVITY_FEED), () =>
+ findAndFollowLink('.dashboard-shortcuts-activity'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_ISSUES), () =>
+ findAndFollowLink('.dashboard-shortcuts-issues'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () =>
+ findAndFollowLink('.dashboard-shortcuts-merge_requests'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () =>
+ findAndFollowLink('.dashboard-shortcuts-projects'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_GROUPS), () =>
+ findAndFollowLink('.dashboard-shortcuts-groups'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_MILESTONE_LIST), () =>
+ findAndFollowLink('.dashboard-shortcuts-milestones'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_YOUR_SNIPPETS), () =>
+ findAndFollowLink('.dashboard-shortcuts-snippets'),
+ );
+
+ Mousetrap.bind(keysFor(TOGGLE_MARKDOWN_PREVIEW), Shortcuts.toggleMarkdownPreview);
if (typeof findFileURL !== 'undefined' && findFileURL !== null) {
- Mousetrap.bind('t', () => {
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_FIND_FILE), () => {
visitUrl(findFileURL);
});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index 11b4fcd4e1c..ab7fcbb35f1 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -1,4 +1,5 @@
import Mousetrap from 'mousetrap';
+import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings';
import {
getLocationHash,
updateHistory,
@@ -28,7 +29,7 @@ export default class ShortcutsBlob extends Shortcuts {
this.shortcircuitPermalinkButton();
- Mousetrap.bind('y', this.moveToFilePermalink.bind(this));
+ Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.moveToFilePermalink.bind(this));
}
moveToFilePermalink() {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
index f0d2ecfd210..992e571e596 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js
@@ -1,4 +1,11 @@
import Mousetrap from 'mousetrap';
+import {
+ keysFor,
+ PROJECT_FILES_MOVE_SELECTION_UP,
+ PROJECT_FILES_MOVE_SELECTION_DOWN,
+ PROJECT_FILES_OPEN_SELECTION,
+ PROJECT_FILES_GO_BACK,
+} from '~/behaviors/shortcuts/keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsFindFile extends ShortcutsNavigation {
@@ -10,7 +17,10 @@ export default class ShortcutsFindFile extends ShortcutsNavigation {
Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
if (
element === projectFindFile.inputElement[0] &&
- (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter')
+ (keysFor(PROJECT_FILES_MOVE_SELECTION_UP).includes(combo) ||
+ keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN).includes(combo) ||
+ keysFor(PROJECT_FILES_GO_BACK).includes(combo) ||
+ keysFor(PROJECT_FILES_OPEN_SELECTION).includes(combo))
) {
// when press up/down key in textbox, cursor prevent to move to home/end
e.preventDefault();
@@ -20,9 +30,9 @@ export default class ShortcutsFindFile extends ShortcutsNavigation {
return oldStopCallback.call(this, e, element, combo);
};
- Mousetrap.bind('up', projectFindFile.selectRowUp);
- Mousetrap.bind('down', projectFindFile.selectRowDown);
- Mousetrap.bind('esc', projectFindFile.goToTree);
- Mousetrap.bind('enter', projectFindFile.goToBlob);
+ Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_UP), projectFindFile.selectRowUp);
+ Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN), projectFindFile.selectRowDown);
+ Mousetrap.bind(keysFor(PROJECT_FILES_GO_BACK), projectFindFile.goToTree);
+ Mousetrap.bind(keysFor(PROJECT_FILES_OPEN_SELECTION), projectFindFile.goToBlob);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
index 1277dd0ed37..49216cc4aa0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
@@ -397,7 +397,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Epics, Issues, and Merge Requests') }}</th>
+ <th>{{ __('Epics, issues, and merge requests') }}</th>
</tr>
<tr>
<td class="shortcut">
@@ -421,7 +421,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Issues and Merge Requests') }}</th>
+ <th>{{ __('Issues and merge requests') }}</th>
</tr>
<tr>
<td class="shortcut">
@@ -439,7 +439,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Merge Requests') }}</th>
+ <th>{{ __('Merge requests') }}</th>
</tr>
<tr>
<td class="shortcut">
@@ -485,7 +485,7 @@ export default {
<tbody>
<tr>
<th></th>
- <th>{{ __('Merge Request Commits') }}</th>
+ <th>{{ __('Merge request commits') }}</th>
</tr>
<tr>
<td class="shortcut">
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 476745beb19..c2908133fd0 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -5,18 +5,33 @@ import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import Sidebar from '../../right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
+import {
+ keysFor,
+ ISSUE_MR_CHANGE_ASSIGNEE,
+ ISSUE_MR_CHANGE_MILESTONE,
+ ISSUABLE_CHANGE_LABEL,
+ ISSUABLE_COMMENT_OR_REPLY,
+ ISSUABLE_EDIT_DESCRIPTION,
+ MR_COPY_SOURCE_BRANCH_NAME,
+} from './keybindings';
import Shortcuts from './shortcuts';
export default class ShortcutsIssuable extends Shortcuts {
constructor() {
super();
- Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee'));
- Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone'));
- Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
- Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText);
- Mousetrap.bind('e', ShortcutsIssuable.editIssue);
- Mousetrap.bind('b', ShortcutsIssuable.copyBranchName);
+ Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () =>
+ ShortcutsIssuable.openSidebarDropdown('assignee'),
+ );
+ Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_MILESTONE), () =>
+ ShortcutsIssuable.openSidebarDropdown('milestone'),
+ );
+ Mousetrap.bind(keysFor(ISSUABLE_CHANGE_LABEL), () =>
+ ShortcutsIssuable.openSidebarDropdown('labels'),
+ );
+ Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
+ Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
+ Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName);
}
static replyWithSelectedText() {
@@ -105,7 +120,7 @@ export default class ShortcutsIssuable extends Shortcuts {
static copyBranchName() {
// There are two buttons - one that is shown when the sidebar
// is expanded, and one that is shown when it's collapsed.
- const allCopyBtns = Array.from(document.querySelectorAll('.sidebar-source-branch button'));
+ const allCopyBtns = Array.from(document.querySelectorAll('.js-sidebar-source-branch button'));
// Select whichever button is currently visible so that
// the "Copied" tooltip is shown when a click is simulated.
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index b46b4132ba8..b188d3b0ec3 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -1,27 +1,63 @@
import Mousetrap from 'mousetrap';
import findAndFollowLink from '../../lib/utils/navigation_utility';
+import {
+ keysFor,
+ GO_TO_PROJECT_OVERVIEW,
+ GO_TO_PROJECT_ACTIVITY_FEED,
+ GO_TO_PROJECT_RELEASES,
+ GO_TO_PROJECT_FILES,
+ GO_TO_PROJECT_COMMITS,
+ GO_TO_PROJECT_JOBS,
+ GO_TO_PROJECT_REPO_GRAPH,
+ GO_TO_PROJECT_REPO_CHARTS,
+ GO_TO_PROJECT_ISSUES,
+ GO_TO_PROJECT_ISSUE_BOARDS,
+ GO_TO_PROJECT_MERGE_REQUESTS,
+ GO_TO_PROJECT_WIKI,
+ GO_TO_PROJECT_SNIPPETS,
+ GO_TO_PROJECT_KUBERNETES,
+ GO_TO_PROJECT_ENVIRONMENTS,
+ GO_TO_PROJECT_METRICS,
+ NEW_ISSUE,
+} from './keybindings';
import Shortcuts from './shortcuts';
export default class ShortcutsNavigation extends Shortcuts {
constructor() {
super();
- Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
- Mousetrap.bind('g v', () => findAndFollowLink('.shortcuts-project-activity'));
- Mousetrap.bind('g r', () => findAndFollowLink('.shortcuts-project-releases'));
- Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
- Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
- Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
- Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
- Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
- Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
- Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
- Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
- Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
- Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
- Mousetrap.bind('g k', () => findAndFollowLink('.shortcuts-kubernetes'));
- Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-environments'));
- Mousetrap.bind('g l', () => findAndFollowLink('.shortcuts-metrics'));
- Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_OVERVIEW), () => findAndFollowLink('.shortcuts-project'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ACTIVITY_FEED), () =>
+ findAndFollowLink('.shortcuts-project-activity'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_RELEASES), () =>
+ findAndFollowLink('.shortcuts-project-releases'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_FILES), () => findAndFollowLink('.shortcuts-tree'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_COMMITS), () => findAndFollowLink('.shortcuts-commits'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_JOBS), () => findAndFollowLink('.shortcuts-builds'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_GRAPH), () =>
+ findAndFollowLink('.shortcuts-network'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_CHARTS), () =>
+ findAndFollowLink('.shortcuts-repository-charts'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUES), () => findAndFollowLink('.shortcuts-issues'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUE_BOARDS), () =>
+ findAndFollowLink('.shortcuts-issue-boards'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_MERGE_REQUESTS), () =>
+ findAndFollowLink('.shortcuts-merge_requests'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_WIKI), () => findAndFollowLink('.shortcuts-wiki'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_SNIPPETS), () => findAndFollowLink('.shortcuts-snippets'));
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_KUBERNETES), () =>
+ findAndFollowLink('.shortcuts-kubernetes'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_ENVIRONMENTS), () =>
+ findAndFollowLink('.shortcuts-environments'),
+ );
+ Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics'));
+ Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue'));
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
index 3e791e4673a..c33c092b009 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js
@@ -1,15 +1,24 @@
import Mousetrap from 'mousetrap';
+import {
+ keysFor,
+ REPO_GRAPH_SCROLL_BOTTOM,
+ REPO_GRAPH_SCROLL_DOWN,
+ REPO_GRAPH_SCROLL_LEFT,
+ REPO_GRAPH_SCROLL_RIGHT,
+ REPO_GRAPH_SCROLL_TOP,
+ REPO_GRAPH_SCROLL_UP,
+} from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsNetwork extends ShortcutsNavigation {
constructor(graph) {
super();
- Mousetrap.bind(['left', 'h'], graph.scrollLeft);
- Mousetrap.bind(['right', 'l'], graph.scrollRight);
- Mousetrap.bind(['up', 'k'], graph.scrollUp);
- Mousetrap.bind(['down', 'j'], graph.scrollDown);
- Mousetrap.bind(['shift+up', 'shift+k'], graph.scrollTop);
- Mousetrap.bind(['shift+down', 'shift+j'], graph.scrollBottom);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_LEFT), graph.scrollLeft);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_RIGHT), graph.scrollRight);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_UP), graph.scrollUp);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_DOWN), graph.scrollDown);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_TOP), graph.scrollTop);
+ Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_BOTTOM), graph.scrollBottom);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
index 8418c0f66ac..6cbe443062a 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
@@ -1,9 +1,13 @@
<script>
import { GlToggle } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
+import { __ } from '~/locale';
import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle';
export default {
+ i18n: {
+ toggleLabel: __('Keyboard shortcuts'),
+ },
components: {
GlToggle,
},
@@ -31,7 +35,7 @@ export default {
<gl-toggle
v-model="shortcutsEnabled"
aria-describedby="shortcutsToggle"
- label="Keyboard shortcuts"
+ :label="$options.i18n.toggleLabel"
label-position="left"
@change="onChange"
/>
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
index c609936a02a..59c1d2654bc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
@@ -1,11 +1,12 @@
import Mousetrap from 'mousetrap';
import findAndFollowLink from '../../lib/utils/navigation_utility';
+import { keysFor, EDIT_WIKI_PAGE } from './keybindings';
import ShortcutsNavigation from './shortcuts_navigation';
export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
- Mousetrap.bind('e', ShortcutsWiki.editWiki);
+ Mousetrap.bind(keysFor(EDIT_WIKI_PAGE), ShortcutsWiki.editWiki);
}
static editWiki() {
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index eb7f45cba6f..f5f06436bcc 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -21,6 +21,11 @@ export default {
default: '',
required: false,
},
+ isRawContent: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
loading: {
type: Boolean,
default: true,
@@ -65,6 +70,8 @@ export default {
v-else
ref="contentViewer"
:content="content"
+ :is-raw-content="isRawContent"
+ :file-name="blob.name"
:type="activeViewer.fileType"
data-qa-selector="file_content"
/>
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index a5c8050b772..e02217d0deb 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -19,6 +19,17 @@ export default class FileTemplateSelector {
this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text');
this.initDropdown();
+ this.selectInitialTemplate();
+ }
+
+ selectInitialTemplate() {
+ const template = this.$dropdown.data('selected');
+
+ if (!template) {
+ return;
+ }
+
+ this.mediator.selectTemplateFile(this, template);
}
show() {
@@ -27,6 +38,19 @@ export default class FileTemplateSelector {
}
this.$wrapper.removeClass('hidden');
+
+ /**
+ * We set the focus on the dropdown that was just shown. This is done so that, after selecting
+ * a template type, the template selector immediately receives the focus.
+ * This improves the UX of the tour as the suggest_gitlab_ci_yml popover requires its target to
+ * be have the focus to appear. This way, users don't have to interact with the template
+ * selector to actually see the first hint: it is shown as soon as the selector becomes visible.
+ * We also need a timeout here, otherwise the template type selector gets stuck and can not be
+ * closed anymore.
+ */
+ setTimeout(() => {
+ this.$dropdown.focus();
+ }, 0);
}
hide() {
@@ -36,7 +60,7 @@ export default class FileTemplateSelector {
}
isHidden() {
- return this.$wrapper.hasClass('hidden');
+ return !this.$wrapper || this.$wrapper.hasClass('hidden');
}
getToggleText() {
diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js
index 339906adc34..0ea623a705a 100644
--- a/app/assets/javascripts/blob/stl_viewer.js
+++ b/app/assets/javascripts/blob/stl_viewer.js
@@ -9,8 +9,8 @@ export default () => {
e.preventDefault();
- document.querySelector('.js-material-changer.active').classList.remove('active');
- target.classList.add('active');
+ document.querySelector('.js-material-changer.selected').classList.remove('selected');
+ target.classList.add('selected');
target.blur();
viewer.changeObjectMaterials(target.dataset.type);
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
index 6fee40fb061..aee8bf15e44 100644
--- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
+++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
@@ -108,7 +108,6 @@ export default {
show
:target="target"
placement="right"
- trigger="manual"
container="viewport"
:css-classes="['suggest-gitlab-ci-yml', 'ml-4']"
>
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 7c8f6646c0d..ab2fc80e653 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -43,7 +43,7 @@ export default class EditBlob {
blobPath: fileNameEl.value,
blobContent: editorEl.innerText,
});
- this.editor.use(new FileTemplateExtension());
+ this.editor.use(new FileTemplateExtension({ instance: this.editor }));
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
@@ -82,7 +82,7 @@ export default class EditBlob {
this.$editModePanes.hide();
- currentPane.fadeIn(200);
+ currentPane.show();
if (paneId === '#preview') {
this.$toggleButton.hide();
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 2cd25f58770..a8b870f9b8e 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,4 +1,4 @@
-import { sortBy } from 'lodash';
+import { sortBy, cloneDeep } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ListType, NOT_FILTER } from './constants';
@@ -113,6 +113,37 @@ export function formatIssueInput(issueInput, boardConfig) {
};
}
+export function shouldCloneCard(fromListType, toListType) {
+ const involvesClosed = fromListType === ListType.closed || toListType === ListType.closed;
+ const involvesBacklog = fromListType === ListType.backlog || toListType === ListType.backlog;
+
+ if (involvesClosed || involvesBacklog) {
+ return false;
+ }
+
+ if (fromListType !== toListType) {
+ return true;
+ }
+
+ return false;
+}
+
+export function getMoveData(state, params) {
+ const { boardItems, boardItemsByListId, boardLists } = state;
+ const { itemId, fromListId, toListId } = params;
+ const fromListType = boardLists[fromListId].listType;
+ const toListType = boardLists[toListId].listType;
+
+ return {
+ reordering: fromListId === toListId,
+ shouldClone: shouldCloneCard(fromListType, toListType),
+ itemNotInToList: !boardItemsByListId[toListId].includes(itemId),
+ originalIssue: cloneDeep(boardItems[itemId]),
+ originalIndex: boardItemsByListId[fromListId].indexOf(itemId),
+ ...params,
+ };
+}
+
export function moveItemListHelper(item, fromList, toList) {
const updatedItem = item;
if (
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 3c7c792b787..d4b559add6e 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -1,23 +1,16 @@
<script>
-import {
- GlFormRadio,
- GlFormRadioGroup,
- GlLabel,
- GlTooltipDirective as GlTooltip,
-} from '@gitlab/ui';
+import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
import { ListType } from '~/boards/constants';
import boardsStore from '~/boards/stores/boards_store';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
BoardAddNewColumnForm,
GlFormRadio,
GlFormRadioGroup,
- GlLabel,
},
directives: {
GlTooltip,
@@ -26,17 +19,12 @@ export default {
data() {
return {
selectedId: null,
+ selectedLabel: null,
};
},
computed: {
...mapState(['labels', 'labelsLoading']),
...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
- selectedLabel() {
- if (!this.selectedId) {
- return null;
- }
- return this.labels.find(({ id }) => id === this.selectedId);
- },
columnForSelected() {
return this.getListByLabelId(this.selectedId);
},
@@ -89,8 +77,13 @@ export default {
this.fetchLabels(searchTerm);
},
- showScopedLabels(label) {
- return this.scopedLabelsAvailable && isScopedLabel(label);
+ setSelectedItem(selectedId) {
+ const label = this.labels.find(({ id }) => id === selectedId);
+ if (!selectedId || !label) {
+ this.selectedLabel = null;
+ } else {
+ this.selectedLabel = { ...label };
+ }
},
},
};
@@ -99,38 +92,39 @@ export default {
<template>
<board-add-new-column-form
:loading="labelsLoading"
- :form-description="__('A label list displays issues with the selected label.')"
- :search-label="__('Select label')"
+ :none-selected="__('Select a label')"
:search-placeholder="__('Search labels')"
:selected-id="selectedId"
@filter-items="filterItems"
@add-list="addList"
>
- <template slot="selected">
- <gl-label
- v-if="selectedLabel"
- v-gl-tooltip
- :title="selectedLabel.title"
- :description="selectedLabel.description"
- :background-color="selectedLabel.color"
- :scoped="showScopedLabels(selectedLabel)"
- />
+ <template #selected>
+ <template v-if="selectedLabel">
+ <span
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
+ :style="{
+ backgroundColor: selectedLabel.color,
+ }"
+ ></span>
+ <div class="gl-text-truncate">{{ selectedLabel.title }}</div>
+ </template>
</template>
- <template slot="items">
+ <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"
+ @change="setSelectedItem"
>
<label
v-for="label in labels"
:key="label.id"
- class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal"
+ class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word"
>
- <gl-form-radio :value="label.id" class="gl-mb-0" />
+ <gl-form-radio :value="label.id" />
<span
- class="dropdown-label-box gl-top-0"
+ class="dropdown-label-box gl-top-0 gl-flex-shrink-0"
:style="{
backgroundColor: label.color,
}"
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index d85343a5390..70ba90bb1d4 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -1,5 +1,12 @@
<script>
-import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlFormGroup,
+ GlIcon,
+ GlSearchBoxByType,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __ } from '~/locale';
@@ -8,13 +15,16 @@ export default {
add: __('Add to board'),
cancel: __('Cancel'),
newList: __('New list'),
- noneSelected: __('None'),
noResults: __('No matching results'),
+ scope: __('Scope'),
+ scopeDescription: __('Issues must match this scope to appear in this list.'),
selected: __('Selected'),
},
components: {
GlButton,
+ GlDropdown,
GlFormGroup,
+ GlIcon,
GlSearchBoxByType,
GlSkeletonLoader,
},
@@ -23,11 +33,12 @@ export default {
type: Boolean,
required: true,
},
- formDescription: {
+ searchLabel: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
- searchLabel: {
+ noneSelected: {
type: String,
required: true,
},
@@ -46,8 +57,23 @@ export default {
searchValue: '',
};
},
+ watch: {
+ selectedId(val) {
+ if (val) {
+ this.$refs.dropdown.hide(true);
+ }
+ },
+ },
methods: {
...mapActions(['setAddColumnFormVisibility']),
+ setFocus() {
+ this.$refs.searchBox.focusInput();
+ },
+ onHide() {
+ this.searchValue = '';
+ this.$emit('filter-items', '');
+ this.$emit('hide');
+ },
},
};
</script>
@@ -62,51 +88,64 @@ export default {
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white"
>
<h3
- class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ class="gl-font-size-h2 gl-px-5 gl-py-4 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
data-testid="board-add-column-form-title"
>
{{ $options.i18n.newList }}
</h3>
- <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden">
- <slot name="select-list-type">
- <div class="gl-mb-5"></div>
- </slot>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-y-auto gl-align-items-flex-start"
+ >
+ <div class="gl-px-5">
+ <h3 class="gl-font-lg gl-mt-5 gl-mb-2">
+ {{ $options.i18n.scope }}
+ </h3>
+ <p class="gl-mb-3">{{ $options.i18n.scopeDescription }}</p>
+ </div>
- <p class="gl-px-5">{{ formDescription }}</p>
+ <slot name="select-list-type"></slot>
- <div class="gl-px-5 gl-pb-4">
- <label class="gl-mb-2">{{ $options.i18n.selected }}</label>
- <slot name="selected">
- <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div>
- </slot>
- </div>
+ <gl-form-group class="gl-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel">
+ <gl-dropdown
+ ref="dropdown"
+ class="gl-mb-3 gl-max-w-full"
+ toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate"
+ boundary="viewport"
+ @shown="setFocus"
+ @hide="onHide"
+ >
+ <template #button-content>
+ <slot name="selected">
+ <div>{{ noneSelected }}</div>
+ </slot>
+ <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" />
+ </template>
- <gl-form-group
- class="gl-mx-5 gl-mb-3"
- :label="searchLabel"
- label-for="board-available-column-entities"
- >
- <gl-search-box-by-type
- id="board-available-column-entities"
- v-model="searchValue"
- debounce="250"
- :placeholder="searchPlaceholder"
- @input="$emit('filter-items', $event)"
- />
- </gl-form-group>
+ <template #header>
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model="searchValue"
+ debounce="250"
+ class="gl-mt-0!"
+ :placeholder="searchPlaceholder"
+ @input="$emit('filter-items', $event)"
+ />
+ </template>
- <div v-if="loading" class="gl-px-5">
- <gl-skeleton-loader :width="500" :height="172">
- <rect width="480" height="20" x="10" y="15" rx="4" />
- <rect width="380" height="20" x="10" y="50" rx="4" />
- <rect width="430" height="20" x="10" y="85" rx="4" />
- </gl-skeleton-loader>
- </div>
+ <div v-if="loading" class="gl-px-5">
+ <gl-skeleton-loader :width="400" :height="172">
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ <rect width="330" height="20" x="10" y="85" rx="4" />
+ </gl-skeleton-loader>
+ </div>
- <slot v-else name="items">
- <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
- </slot>
+ <slot v-else name="items">
+ <p class="gl-mx-5">{{ $options.i18n.noResults }}</p>
+ </slot>
+ </gl-dropdown>
+ </gl-form-group>
</div>
<div
class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
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 7c08e33be7e..85f001d9d61 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
@@ -13,9 +13,9 @@ export default {
</script>
<template>
- <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
+ <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list">
<gl-button variant="confirm" @click="setAddColumnFormVisibility(true)"
>{{ __('Create list') }}
</gl-button>
- </span>
+ </div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue
new file mode 100644
index 00000000000..0f92e714752
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue
@@ -0,0 +1,192 @@
+<script>
+import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
+import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
+import { IssueType } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { truncate } from '~/lib/utils/text_utility';
+import { __, n__, s__, sprintf } from '~/locale';
+
+export default {
+ i18n: {
+ issuableType: {
+ [issuableTypes.issue]: __('issue'),
+ },
+ },
+ graphQLIdType: {
+ [issuableTypes.issue]: IssueType,
+ },
+ referenceFormatter: {
+ [issuableTypes.issue]: (r) => r.split('/')[1],
+ },
+ defaultDisplayLimit: 3,
+ textTruncateWidth: 80,
+ components: {
+ GlIcon,
+ GlPopover,
+ GlLink,
+ GlLoadingIcon,
+ },
+ blockingIssuablesQueries,
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ uniqueId: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [issuableTypes.issue].includes(value);
+ },
+ },
+ },
+ apollo: {
+ blockingIssuables: {
+ skip() {
+ return this.skip;
+ },
+ query() {
+ return blockingIssuablesQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
+ };
+ },
+ update(data) {
+ this.skip = true;
+
+ return data?.issuable?.blockingIssuables?.nodes || [];
+ },
+ error(error) {
+ const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
+ issuableType: this.issuableTypeText,
+ });
+ this.$emit('blocking-issuables-error', { error, message });
+ },
+ },
+ },
+ data() {
+ return {
+ skip: true,
+ blockingIssuables: [],
+ };
+ },
+ computed: {
+ displayedIssuables() {
+ const { defaultDisplayLimit, referenceFormatter } = this.$options;
+ return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
+ return {
+ ...i,
+ title: truncate(i.title, this.$options.textTruncateWidth),
+ reference: referenceFormatter[this.issuableType](i.reference),
+ };
+ });
+ },
+ loading() {
+ return this.$apollo.queries.blockingIssuables.loading;
+ },
+ issuableTypeText() {
+ return this.$options.i18n.issuableType[this.issuableType];
+ },
+ blockedLabel() {
+ return sprintf(
+ n__(
+ 'Boards|Blocked by %{blockedByCount} %{issuableType}',
+ 'Boards|Blocked by %{blockedByCount} %{issuableType}s',
+ this.item.blockedByCount,
+ ),
+ {
+ blockedByCount: this.item.blockedByCount,
+ issuableType: this.issuableTypeText,
+ },
+ );
+ },
+ glIconId() {
+ return `blocked-icon-${this.uniqueId}`;
+ },
+ hasMoreIssuables() {
+ return this.item.blockedByCount > this.$options.defaultDisplayLimit;
+ },
+ displayedIssuablesCount() {
+ return this.hasMoreIssuables
+ ? this.item.blockedByCount - this.$options.defaultDisplayLimit
+ : this.item.blockedByCount;
+ },
+ moreIssuablesText() {
+ return sprintf(
+ n__(
+ 'Boards|+ %{displayedIssuablesCount} more %{issuableType}',
+ 'Boards|+ %{displayedIssuablesCount} more %{issuableType}s',
+ this.displayedIssuablesCount,
+ ),
+ {
+ displayedIssuablesCount: this.displayedIssuablesCount,
+ issuableType: this.issuableTypeText,
+ },
+ );
+ },
+ viewAllIssuablesText() {
+ return sprintf(s__('Boards|View all blocking %{issuableType}s'), {
+ issuableType: this.issuableTypeText,
+ });
+ },
+ loadingMessage() {
+ return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), {
+ issuableType: this.issuableTypeText,
+ });
+ },
+ },
+ methods: {
+ handleMouseEnter() {
+ this.skip = false;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-inline">
+ <gl-icon
+ :id="glIconId"
+ ref="icon"
+ name="issue-block"
+ class="issue-blocked-icon gl-mr-2 gl-cursor-pointer"
+ data-testid="issue-blocked-icon"
+ @mouseenter="handleMouseEnter"
+ />
+ <gl-popover :target="glIconId" placement="top">
+ <template #title
+ ><span data-testid="popover-title">{{ blockedLabel }}</span></template
+ >
+ <template v-if="loading">
+ <gl-loading-icon />
+ <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p>
+ </template>
+ <template v-else>
+ <ul class="gl-list-style-none gl-p-0">
+ <li v-for="issuable in displayedIssuables" :key="issuable.id">
+ <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{
+ issuable.reference
+ }}</gl-link>
+ <p class="gl-mb-3 gl-display-block!" data-testid="issuable-title">
+ {{ issuable.title }}
+ </p>
+ </li>
+ </ul>
+ <div v-if="hasMoreIssuables" class="gl-mt-4">
+ <p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p>
+ <gl-link
+ data-testid="view-all-issues"
+ :href="`${item.webUrl}#related-issues`"
+ class="gl-text-blue-500! gl-font-sm"
+ >{{ viewAllIssuablesText }}</gl-link
+ >
+ </div>
+ </template>
+ </gl-popover>
+ </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 d4d6b17a589..9ff2cdd76d0 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -10,6 +10,7 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import { ListType } from '../constants';
import eventHub from '../eventhub';
+import BoardBlockedIcon from './board_blocked_icon.vue';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
@@ -22,6 +23,7 @@ export default {
IssueDueDate,
IssueTimeEstimate,
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
+ BoardBlockedIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -52,7 +54,7 @@ export default {
};
},
computed: {
- ...mapState(['isShowingLabels']),
+ ...mapState(['isShowingLabels', 'issuableType']),
...mapGetters(['isEpicBoard']),
cappedAssignees() {
// e.g. maxRender is 4,
@@ -114,7 +116,7 @@ export default {
},
},
methods: {
- ...mapActions(['performSearch']),
+ ...mapActions(['performSearch', 'setError']),
isIndexLessThanlimit(index) {
return index < this.limitBeforeCounter;
},
@@ -164,14 +166,12 @@ export default {
<div>
<div class="gl-display-flex" dir="auto">
<h4 class="board-card-title gl-mb-0 gl-mt-0">
- <gl-icon
+ <board-blocked-icon
v-if="item.blocked"
- v-gl-tooltip
- name="issue-block"
- :title="blockedLabel"
- class="issue-blocked-icon gl-mr-2"
- :aria-label="blockedLabel"
- data-testid="issue-blocked-icon"
+ :item="item"
+ :unique-id="`${item.id}${list.id}`"
+ :issuable-type="issuableType"
+ @blocking-issuables-error="setError"
/>
<gl-icon
v-if="item.confidential"
@@ -181,13 +181,9 @@ export default {
class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
- <a
- :href="item.path || item.webUrl || ''"
- :title="item.title"
- class="js-no-trigger"
- @mousemove.stop
- >{{ item.title }}</a
- >
+ <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{
+ item.title
+ }}</a>
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
diff --git a/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue
new file mode 100644
index 00000000000..15bff1226a6
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ name: 'BoardCardLoading',
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <div
+ class="board-card-skeleton gl-mb-3 gl-bg-white gl-rounded-base gl-p-5 gl-border-1 gl-border-solid gl-border-gray-50"
+ >
+ <div class="board-card-skeleton-inner">
+ <gl-skeleton-loader :width="340" :height="100">
+ <rect width="340" height="16" rx="4" />
+ <rect y="30" width="118" height="16" rx="8" />
+ <rect x="122" y="30" width="130" height="16" rx="8" />
+ <rect y="62" width="38" height="16" rx="4" />
+ <circle cx="320" cy="68" r="16" />
+ </gl-skeleton-loader>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index e9c4237d759..a4b1e6adacf 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -17,21 +17,20 @@ export default {
gon.features?.graphqlBoardLists || gon.features?.epicBoards
? BoardColumn
: BoardColumnDeprecated,
- BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'),
+ BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'),
+ EpicBoardContentSidebar: () =>
+ import('ee_component/boards/components/epic_board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
mixins: [glFeatureFlagMixin()],
+ inject: ['canAdminList'],
props: {
lists: {
type: Array,
required: false,
default: () => [],
},
- canAdminList: {
- type: Boolean,
- required: true,
- },
disabled: {
type: Boolean,
required: true,
@@ -69,7 +68,7 @@ export default {
},
},
methods: {
- ...mapActions(['moveList']),
+ ...mapActions(['moveList', 'unsetError']),
afterFormEnters() {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
@@ -99,8 +98,8 @@ export default {
</script>
<template>
- <div>
- <gl-alert v-if="error" variant="danger" :dismissible="false">
+ <div v-cloak data-qa-selector="boards_list">
+ <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError">
{{ error }}
</gl-alert>
<component
@@ -127,13 +126,23 @@ export default {
</component>
<epics-swimlanes
- v-else
+ v-else-if="boardListsToUse.length"
ref="swimlanes"
:lists="boardListsToUse"
:can-admin-list="canAdminList"
:disabled="disabled"
/>
- <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" />
+ <board-content-sidebar
+ v-if="isSwimlanesOn || glFeatures.graphqlBoardLists"
+ class="boards-sidebar"
+ data-testid="issue-boards-sidebar"
+ />
+
+ <epic-board-content-sidebar
+ v-else-if="isEpicBoard"
+ class="boards-sidebar"
+ data-testid="epic-boards-sidebar"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
new file mode 100644
index 00000000000..46359cc2bca
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -0,0 +1,96 @@
+<script>
+import { GlDrawer } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
+import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
+import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue';
+import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.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 { contentTop } from '~/lib/utils/common_utils';
+import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ headerHeight: `${contentTop()}px`,
+ components: {
+ GlDrawer,
+ BoardSidebarTitle,
+ SidebarAssigneesWidget,
+ BoardSidebarTimeTracker,
+ BoardSidebarLabelsSelect,
+ BoardSidebarDueDate,
+ BoardSidebarSubscription,
+ BoardSidebarMilestoneSelect,
+ BoardSidebarEpicSelect: () =>
+ import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'),
+ BoardSidebarWeightInput: () =>
+ import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
+ SidebarIterationWidget: () =>
+ import('ee_component/sidebar/components/sidebar_iteration_widget.vue'),
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ ...mapGetters([
+ 'isSidebarOpen',
+ 'activeBoardItem',
+ 'groupPathForActiveIssue',
+ 'projectPathForActiveIssue',
+ ]),
+ ...mapState(['sidebarType', 'issuableType']),
+ isIssuableSidebar() {
+ return this.sidebarType === ISSUABLE;
+ },
+ showSidebar() {
+ return this.isIssuableSidebar && this.isSidebarOpen;
+ },
+ fullPath() {
+ return this.activeBoardItem?.referencePath?.split('#')[0] || '';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleBoardItem', 'setAssignees']),
+ handleClose() {
+ this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-drawer
+ v-if="showSidebar"
+ :open="isSidebarOpen"
+ :header-height="$options.headerHeight"
+ @close="handleClose"
+ >
+ <template #header>{{ __('Issue details') }}</template>
+ <template #default>
+ <board-sidebar-title />
+ <sidebar-assignees-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :initial-assignees="activeBoardItem.assignees"
+ class="assignee"
+ @assignees-updated="setAssignees"
+ />
+ <board-sidebar-epic-select class="epic" />
+ <div>
+ <board-sidebar-milestone-select />
+ <sidebar-iteration-widget
+ :iid="activeBoardItem.iid"
+ :workspace-path="projectPathForActiveIssue"
+ :iterations-workspace-path="groupPathForActiveIssue"
+ :issuable-type="issuableType"
+ class="gl-mt-5"
+ />
+ </div>
+ <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" />
+ <board-sidebar-due-date />
+ <board-sidebar-labels-select class="labels" />
+ <board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" />
+ <board-sidebar-subscription class="subscriptions" />
+ </template>
+ </gl-drawer>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue
deleted file mode 100644
index b802ccc7882..00000000000
--- a/app/assets/javascripts/boards/components/board_extra_actions.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { GlTooltip, GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- name: 'BoardExtraActions',
- components: {
- GlTooltip,
- GlButton,
- },
- props: {
- canAdminList: {
- type: Boolean,
- required: true,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- openModal: {
- type: Function,
- required: true,
- },
- },
- computed: {
- tooltipTitle() {
- if (this.disabled) {
- return __('Please add a list to your board first');
- }
-
- return '';
- },
- },
-};
-</script>
-
-<template>
- <div class="board-extra-actions">
- <span ref="addIssuesButtonTooltip" class="gl-ml-3">
- <gl-button
- v-if="canAdminList"
- type="button"
- data-placement="bottom"
- data-track-event="click_button"
- data-track-label="board_add_issues"
- :disabled="disabled"
- :aria-disabled="disabled"
- @click="openModal"
- >
- {{ __('Add issues') }}
- </gl-button>
- </span>
- <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom">
- {{ tooltipTitle }}
- </gl-tooltip>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index d8504dcfb0f..78da4137d69 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -107,7 +107,7 @@ export default {
};
},
computed: {
- ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']),
+ ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']),
isNewForm() {
return this.currentPage === formType.new;
},
@@ -127,7 +127,7 @@ export default {
if (this.isDeleteForm) {
return 'danger';
}
- return 'info';
+ return 'confirm';
},
title() {
if (this.readonly) {
@@ -163,6 +163,9 @@ export default {
currentMutation() {
return this.board.id ? updateBoardMutation : createBoardMutation;
},
+ deleteMutation() {
+ return destroyBoardMutation;
+ },
baseMutationVariables() {
const { board } = this;
const variables = {
@@ -182,7 +185,7 @@ export default {
groupPath: this.isGroupBoard ? this.fullPath : undefined,
};
},
- boardScopeMutationVariables() {
+ issueBoardScopeMutationVariables() {
/* eslint-disable @gitlab/require-i18n-strings */
return {
weight: this.board.weight,
@@ -193,13 +196,18 @@ export default {
this.board.milestone?.id || this.board.milestone?.id === 0
? convertToGraphQLId('Milestone', this.board.milestone.id)
: null,
- labelIds: this.board.labels.map(fullLabelId),
iterationId: this.board.iteration_id
? convertToGraphQLId('Iteration', this.board.iteration_id)
: null,
};
/* eslint-enable @gitlab/require-i18n-strings */
},
+ boardScopeMutationVariables() {
+ return {
+ labelIds: this.board.labels.map(fullLabelId),
+ ...(this.isIssueBoard && this.issueBoardScopeMutationVariables),
+ };
+ },
mutationVariables() {
return {
...this.baseMutationVariables,
@@ -239,17 +247,20 @@ export default {
return this.boardUpdateResponse(response.data);
},
+ async deleteBoard() {
+ await this.$apollo.mutate({
+ mutation: this.deleteMutation,
+ variables: {
+ id: fullBoardId(this.board.id),
+ },
+ });
+ },
async submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
try {
- await this.$apollo.mutate({
- mutation: destroyBoardMutation,
- variables: {
- id: fullBoardId(this.board.id),
- },
- });
+ await this.deleteBoard();
visitUrl(this.rootPath);
} catch {
Flash(this.$options.i18n.deleteErrorMessage);
@@ -324,7 +335,7 @@ export default {
/>
<board-scope
- v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard"
+ v-if="scopedIssueBoardFeatureEnabled"
:collapse-scope="isNewForm"
:board="board"
:can-admin-board="canAdminBoard"
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index ae8434be312..94e29f3ad86 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -190,7 +190,7 @@ export default {
}
this.moveItem({
- itemId,
+ itemId: Number(itemId),
itemIid,
itemPath,
fromListId: from.dataset.listId,
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
index d59fbcc1b31..0534e027c86 100644
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue
@@ -134,9 +134,10 @@ export default {
e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
const toBoardType = containerEl.dataset.boardType;
const cloneActions = {
- label: ['milestone', 'assignee'],
- assignee: ['milestone', 'label'],
- milestone: ['label', 'assignee'],
+ label: ['milestone', 'assignee', 'iteration'],
+ assignee: ['milestone', 'label', 'iteration'],
+ milestone: ['label', 'assignee', 'iteration'],
+ iteration: ['label', 'assignee', 'milestone'],
};
if (toBoardType) {
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 6ccaec4a633..ca66ad6934a 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -328,6 +328,7 @@ export default {
<div
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
+ data-testid="issue-count-badge"
:class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader,
'gl-p-0': list.collapsed,
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index a81c28733cd..144cae15ab3 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -2,23 +2,23 @@
import { GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { getMilestone } from 'ee_else_ce/boards/boards_util';
+import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue';
import { __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
export default {
name: 'BoardNewIssue',
i18n: {
- submit: __('Submit issue'),
+ submit: __('Create issue'),
cancel: __('Cancel'),
},
components: {
ProjectSelect,
GlButton,
},
- mixins: [glFeatureFlagMixin()],
- inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'],
+ mixins: [BoardNewIssueMixin],
+ inject: ['groupId'],
props: {
list: {
type: Object,
@@ -53,14 +53,11 @@ export default {
submit(e) {
e.preventDefault();
+ const { title } = this;
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
const milestone = getMilestone(this.list);
- const weight = this.weightFeatureAvailable ? this.boardWeight : undefined;
-
- const { title } = this;
-
eventHub.$emit(`scroll-board-list-${this.list.id}`);
return this.addListNewIssue({
@@ -70,7 +67,7 @@ export default {
assigneeIds: assignees?.map((a) => a?.id),
milestoneId: milestone?.id,
projectPath: this.selectedProject.fullPath,
- weight: weight >= 0 ? weight : null,
+ ...this.extraIssueInput(),
},
list: this.list,
}).then(() => {
diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
index 16f23dfff0e..1218941065f 100644
--- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
@@ -121,7 +121,7 @@ export default {
variant="success"
category="primary"
type="submit"
- >{{ __('Submit issue') }}</gl-button
+ >{{ __('Create issue') }}</gl-button
>
<gl-button
ref="cancelButton"
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 7cfedad0aed..997655c346a 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -22,13 +22,7 @@ export default {
import('ee_component/boards/components/board_settings_list_types.vue'),
},
mixins: [glFeatureFlagMixin()],
- props: {
- canAdminList: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
+ inject: ['canAdminList'],
data() {
return {
ListType,
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index 7ec99e51f5b..fdb60d0ae6a 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -15,7 +15,8 @@ export default {
props: {
boardsStore: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
canAdminList: {
type: Boolean,
@@ -26,11 +27,6 @@ export default {
required: true,
},
},
- data() {
- return {
- state: this.boardsStore.state,
- };
- },
computed: {
buttonText() {
return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope');
@@ -42,7 +38,9 @@ export default {
methods: {
showPage() {
eventHub.$emit('showBoardModal', formType.edit);
- return this.boardsStore.showPage(formType.edit);
+ if (this.boardsStore) {
+ this.boardsStore.showPage(formType.edit);
+ }
},
},
};
diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue
deleted file mode 100644
index 8505ea39a6b..00000000000
--- a/app/assets/javascripts/boards/components/filtered_search.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<script>
-import { mapActions } from 'vuex';
-import { historyPushState } from '~/lib/utils/common_utils';
-import { setUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-
-export default {
- i18n: {
- search: __('Search'),
- },
- components: { FilteredSearch },
- props: {
- search: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- initialSearch() {
- return [{ type: 'filtered-search-term', value: { data: this.search } }];
- },
- },
- methods: {
- ...mapActions(['performSearch']),
- handleSearch(filters) {
- let itemValue = '';
- const [item] = filters;
-
- if (filters.length === 0) {
- itemValue = '';
- } else {
- itemValue = item?.value?.data;
- }
-
- historyPushState(setUrlParams({ search: itemValue }, window.location.href));
-
- this.performSearch();
- },
- },
-};
-</script>
-
-<template>
- <filtered-search
- class="gl-w-full"
- namespace=""
- :tokens="[]"
- :search-input-placeholder="$options.i18n.search"
- :initial-filter-value="initialSearch"
- @onFilter="handleSearch"
- />
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
deleted file mode 100644
index 486b012e3d2..00000000000
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ /dev/null
@@ -1,84 +0,0 @@
-<script>
-import { GlButton, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
-import modalMixin from '../../mixins/modal_mixins';
-import ModalStore from '../../stores/modal_store';
-
-export default {
- components: {
- GlButton,
- GlSprintf,
- },
- mixins: [modalMixin],
- props: {
- newIssuePath: {
- type: String,
- required: true,
- },
- emptyStateSvg: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- contents() {
- const obj = {
- title: __("You haven't added any issues to your project yet"),
- content: __(
- 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.',
- ),
- };
-
- if (this.activeTab === 'selected') {
- obj.title = __("You haven't selected any issues yet");
- obj.content = __(
- 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.',
- );
- }
-
- return obj;
- },
- },
-};
-</script>
-
-<template>
- <section class="empty-state d-flex mt-0 h-100">
- <div class="row w-100 my-auto mx-0">
- <div class="col-12 col-md-6 order-md-last">
- <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside>
- </div>
- <div class="col-12 col-md-6 order-md-first">
- <div class="text-content">
- <h4>{{ contents.title }}</h4>
- <p>
- <gl-sprintf :message="contents.content">
- <template #tag="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <gl-button
- v-if="activeTab === 'all'"
- :href="newIssuePath"
- category="secondary"
- variant="success"
- >
- {{ __('New issue') }}
- </gl-button>
- <gl-button
- v-if="activeTab === 'selected'"
- category="primary"
- variant="default"
- @click="changeTab('all')"
- >
- {{ __('Open issues') }}
- </gl-button>
- </div>
- </div>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js
deleted file mode 100644
index 2fb38a549f3..00000000000
--- a/app/assets/javascripts/boards/components/modal/filters.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import FilteredSearchContainer from '../../../filtered_search/container';
-import FilteredSearchBoards from '../../filtered_search_boards';
-
-export default {
- name: 'modal-filters',
- props: {
- store: {
- type: Object,
- required: true,
- },
- },
- mounted() {
- FilteredSearchContainer.container = this.$el;
-
- this.filteredSearch = new FilteredSearchBoards(this.store);
- this.filteredSearch.setup();
- this.filteredSearch.removeTokens();
- this.filteredSearch.handleInputPlaceholder();
- this.filteredSearch.toggleClearSearchButton();
- },
- destroyed() {
- this.filteredSearch.cleanup();
- FilteredSearchContainer.container = document;
- this.store.path = '';
- },
- template: '#js-board-modal-filter',
-};
diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue
deleted file mode 100644
index 05e1219bc70..00000000000
--- a/app/assets/javascripts/boards/components/modal/footer.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
-import { __, n__ } from '../../../locale';
-import modalMixin from '../../mixins/modal_mixins';
-import boardsStore from '../../stores/boards_store';
-import ModalStore from '../../stores/modal_store';
-import ListsDropdown from './lists_dropdown.vue';
-
-export default {
- components: {
- ListsDropdown,
- GlButton,
- },
- mixins: [modalMixin, footerEEMixin],
- data() {
- return {
- modal: ModalStore.store,
- state: boardsStore.state,
- };
- },
- computed: {
- submitDisabled() {
- return !ModalStore.selectedCount();
- },
- submitText() {
- const count = ModalStore.selectedCount();
- if (!count) return __('Add issues');
- return n__(`Add %d issue`, `Add %d issues`, count);
- },
- },
- methods: {
- buildUpdateRequest(list) {
- return {
- add_label_ids: [list.label.id],
- };
- },
- addIssues() {
- const firstListIndex = 1;
- const list = this.modal.selectedList || this.state.lists[firstListIndex];
- const selectedIssues = ModalStore.getSelectedIssues();
- const issueIds = selectedIssues.map((issue) => issue.id);
- const req = this.buildUpdateRequest(list);
-
- // Post the data to the backend
- boardsStore.bulkUpdate(issueIds, req).catch(() => {
- Flash(__('Failed to update issues, please try again.'));
-
- selectedIssues.forEach((issue) => {
- list.removeIssue(issue);
- list.issuesSize -= 1;
- });
- });
-
- // Add the issues on the frontend
- selectedIssues.forEach((issue) => {
- list.addIssue(issue);
- list.issuesSize += 1;
- });
-
- this.toggleModal(false);
- },
- },
-};
-</script>
-<template>
- <footer class="form-actions add-issues-footer">
- <div class="float-left">
- <gl-button :disabled="submitDisabled" category="primary" variant="success" @click="addIssues">
- {{ submitText }}
- </gl-button>
- <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span>
- <lists-dropdown />
- </div>
- <gl-button class="float-right" @click="toggleModal(false)">
- {{ __('Cancel') }}
- </gl-button>
- </footer>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
deleted file mode 100644
index c3a71e7177a..00000000000
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlButton } from '@gitlab/ui';
-import { __ } from '~/locale';
-import modalMixin from '../../mixins/modal_mixins';
-import ModalStore from '../../stores/modal_store';
-import ModalFilters from './filters';
-import ModalTabs from './tabs.vue';
-
-export default {
- components: {
- ModalTabs,
- ModalFilters,
- GlButton,
- },
- mixins: [modalMixin],
- props: {
- projectId: {
- type: Number,
- required: true,
- },
- labelPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- selectAllText() {
- if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
- return __('Select all');
- }
-
- return __('Deselect all');
- },
- showSearch() {
- return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
- },
- },
- methods: {
- toggleAll() {
- this.$refs.selectAllBtn.$el.blur();
-
- ModalStore.toggleAll();
- },
- },
-};
-</script>
-<template>
- <div>
- <header class="add-issues-header border-top-0 form-actions">
- <h2 class="m-0">
- Add issues
- <gl-button
- category="tertiary"
- icon="close"
- class="close"
- data-dismiss="modal"
- :aria-label="__('Close')"
- @click="toggleModal(false)"
- />
- </h2>
- </header>
- <modal-tabs v-if="!loading && issuesCount > 0" />
- <div v-if="showSearch" class="d-flex gl-mb-3">
- <modal-filters :store="filter" />
- <gl-button
- ref="selectAllBtn"
- category="secondary"
- variant="success"
- class="gl-ml-3"
- @click="toggleAll"
- >
- {{ selectAllText }}
- </gl-button>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
deleted file mode 100644
index 5af90c1ee66..00000000000
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ /dev/null
@@ -1,151 +0,0 @@
-<script>
-/* global ListIssue */
-import { GlLoadingIcon } from '@gitlab/ui';
-import boardsStore from '~/boards/stores/boards_store';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
-import ModalStore from '../../stores/modal_store';
-import EmptyState from './empty_state.vue';
-import ModalFooter from './footer.vue';
-import ModalHeader from './header.vue';
-import ModalList from './list.vue';
-
-export default {
- components: {
- EmptyState,
- ModalHeader,
- ModalList,
- ModalFooter,
- GlLoadingIcon,
- },
- props: {
- newIssuePath: {
- type: String,
- required: true,
- },
- emptyStateSvg: {
- type: String,
- required: true,
- },
- projectId: {
- type: Number,
- required: true,
- },
- labelPath: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- showList() {
- if (this.activeTab === 'selected') {
- return this.selectedIssues.length > 0;
- }
-
- return this.issuesCount > 0;
- },
- showEmptyState() {
- if (!this.loading && this.issuesCount === 0) {
- return true;
- }
-
- return this.activeTab === 'selected' && this.selectedIssues.length === 0;
- },
- },
- watch: {
- page() {
- this.loadIssues();
- },
- showAddIssuesModal() {
- if (this.showAddIssuesModal && !this.issues.length) {
- this.loading = true;
- const loadingDone = () => {
- this.loading = false;
- };
-
- this.loadIssues().then(loadingDone).catch(loadingDone);
- } else if (!this.showAddIssuesModal) {
- this.issues = [];
- this.selectedIssues = [];
- this.issuesCount = false;
- }
- },
- filter: {
- handler() {
- if (this.$el.tagName) {
- this.page = 1;
- this.filterLoading = true;
- const loadingDone = () => {
- this.filterLoading = false;
- };
-
- this.loadIssues(true).then(loadingDone).catch(loadingDone);
- }
- },
- deep: true,
- },
- },
- created() {
- this.page = 1;
- },
- methods: {
- loadIssues(clearIssues = false) {
- if (!this.showAddIssuesModal) return false;
-
- return boardsStore
- .getBacklog({
- ...urlParamsToObject(this.filter.path),
- page: this.page,
- per: this.perPage,
- })
- .then((res) => res.data)
- .then((data) => {
- if (clearIssues) {
- this.issues = [];
- }
-
- data.issues.forEach((issueObj) => {
- const issue = new ListIssue(issueObj);
- const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
- issue.selected = Boolean(foundSelectedIssue);
-
- this.issues.push(issue);
- });
-
- this.loadingNewPage = false;
-
- if (!this.issuesCount) {
- this.issuesCount = data.size;
- }
- })
- .catch(() => {
- // TODO: handle request error
- });
- },
- },
-};
-</script>
-<template>
- <div
- v-if="showAddIssuesModal"
- class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100"
- >
- <div class="add-issues-container d-flex flex-column m-auto rounded">
- <modal-header :project-id="projectId" :label-path="labelPath" />
- <modal-list v-if="!loading && showList && !filterLoading" :empty-state-svg="emptyStateSvg" />
- <empty-state
- v-if="showEmptyState"
- :new-issue-path="newIssuePath"
- :empty-state-svg="emptyStateSvg"
- />
- <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center">
- <div class="add-issues-list-loading w-100 align-self-center">
- <gl-loading-icon size="md" />
- </div>
- </section>
- <modal-footer />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
deleted file mode 100644
index e66cae0ce18..00000000000
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ /dev/null
@@ -1,141 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import ModalStore from '../../stores/modal_store';
-import BoardCardInner from '../board_card_inner.vue';
-
-export default {
- components: {
- BoardCardInner,
- GlIcon,
- },
- props: {
- emptyStateSvg: {
- type: String,
- required: true,
- },
- },
- data() {
- return ModalStore.store;
- },
- computed: {
- loopIssues() {
- if (this.activeTab === 'all') {
- return this.issues;
- }
-
- return this.selectedIssues;
- },
- groupedIssues() {
- const groups = [];
- this.loopIssues.forEach((issue, i) => {
- const index = i % this.columns;
-
- if (!groups[index]) {
- groups.push([]);
- }
-
- groups[index].push(issue);
- });
-
- return groups;
- },
- },
- watch: {
- activeTab() {
- if (this.activeTab === 'all') {
- ModalStore.purgeUnselectedIssues();
- }
- },
- },
- mounted() {
- this.scrollHandlerWrapper = this.scrollHandler.bind(this);
- this.setColumnCountWrapper = this.setColumnCount.bind(this);
- this.setColumnCount();
-
- this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper);
- window.addEventListener('resize', this.setColumnCountWrapper);
- },
- beforeDestroy() {
- this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper);
- window.removeEventListener('resize', this.setColumnCountWrapper);
- },
- methods: {
- scrollHandler() {
- const currentPage = Math.floor(this.issues.length / this.perPage);
-
- if (
- this.scrollTop() > this.scrollHeight() - 100 &&
- !this.loadingNewPage &&
- currentPage === this.page
- ) {
- this.loadingNewPage = true;
- this.page += 1;
- }
- },
- toggleIssue(e, issue) {
- if (e.target.tagName !== 'A') {
- ModalStore.toggleIssue(issue);
- }
- },
- listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
- },
- scrollHeight() {
- return this.$refs.list.scrollHeight;
- },
- scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
- },
- showIssue(issue) {
- if (this.activeTab === 'all') return true;
-
- const index = ModalStore.selectedIssueIndex(issue);
-
- return index !== -1;
- },
- setColumnCount() {
- const breakpoint = bp.getBreakpointSize();
-
- if (breakpoint === 'xl' || breakpoint === 'lg') {
- this.columns = 3;
- } else if (breakpoint === 'md') {
- this.columns = 2;
- } else {
- this.columns = 1;
- }
- },
- },
-};
-</script>
-<template>
- <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100">
- <div
- v-if="issuesCount > 0 && issues.length === 0"
- class="empty-state add-issues-empty-state-filter text-center"
- >
- <div class="svg-content"><img :src="emptyStateSvg" /></div>
- <div class="text-content">
- <h4>{{ __('There are no issues to show.') }}</h4>
- </div>
- </div>
- <div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column">
- <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent">
- <div
- :class="{ 'is-active': issue.selected }"
- class="board-card position-relative p-3 rounded"
- @click="toggleIssue($event, issue)"
- >
- <board-card-inner :item="issue" />
- <gl-icon
- v-if="issue.selected"
- :aria-label="'Issue #' + issue.id + ' selected'"
- name="mobile-issue-close"
- aria-checked="true"
- class="issue-card-selected text-center"
- />
- </div>
- </div>
- </div>
- </section>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
deleted file mode 100644
index 2065568d275..00000000000
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-import boardsStore from '../../stores/boards_store';
-import ModalStore from '../../stores/modal_store';
-
-export default {
- components: {
- GlLink,
- GlIcon,
- },
- data() {
- return {
- modal: ModalStore.store,
- state: boardsStore.state,
- };
- },
- computed: {
- selected() {
- return this.modal.selectedList || this.state.lists[1];
- },
- },
- destroyed() {
- this.modal.selectedList = null;
- },
-};
-</script>
-<template>
- <div class="dropdown inline">
- <button class="dropdown-menu-toggle" type="button" data-toggle="dropdown" aria-expanded="false">
- <span :style="{ backgroundColor: selected.label.color }" class="dropdown-label-box"> </span>
- {{ selected.title }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" />
- </button>
- <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up">
- <ul>
- <li v-for="(list, i) in state.lists" v-if="list.type == 'label'" :key="i">
- <gl-link
- :class="{ 'is-active': list.id == selected.id }"
- href="#"
- role="button"
- @click.prevent="modal.selectedList = list"
- >
- <span :style="{ backgroundColor: list.label.color }" class="dropdown-label-box"> </span>
- {{ list.title }}
- </gl-link>
- </li>
- </ul>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue
deleted file mode 100644
index 0b717f516db..00000000000
--- a/app/assets/javascripts/boards/components/modal/tabs.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
-import modalMixin from '../../mixins/modal_mixins';
-import ModalStore from '../../stores/modal_store';
-
-export default {
- components: {
- GlTabs,
- GlTab,
- GlBadge,
- },
- mixins: [modalMixin],
- data() {
- return ModalStore.store;
- },
- computed: {
- selectedCount() {
- return ModalStore.selectedCount();
- },
- },
- destroyed() {
- this.activeTab = 'all';
- },
-};
-</script>
-<template>
- <gl-tabs class="gl-mt-3">
- <gl-tab @click.prevent="changeTab('all')">
- <template slot="title">
- <span>Open issues</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge>
- </template>
- </gl-tab>
- <gl-tab @click.prevent="changeTab('selected')">
- <template slot="title">
- <span>Selected issues</span>
- <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge>
- </template>
- </gl-tab>
- </gl-tabs>
-</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
index 61863bbe2a9..352a25ef6d9 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -98,14 +98,14 @@ export default {
<gl-button
v-if="canUpdate"
variant="link"
- class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle"
+ class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link"
data-testid="edit-button"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</header>
- <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
+ <div v-show="!edit" class="gl-text-gray-500 value" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
index 6d928337396..13e1e232676 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue
@@ -18,16 +18,16 @@ export default {
};
},
computed: {
- ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
hasDueDate() {
- return this.activeIssue.dueDate != null;
+ return this.activeBoardItem.dueDate != null;
},
parsedDueDate() {
if (!this.hasDueDate) {
return null;
}
- return parsePikadayDate(this.activeIssue.dueDate);
+ return parsePikadayDate(this.activeBoardItem.dueDate);
},
formattedDueDate() {
if (!this.hasDueDate) {
@@ -69,6 +69,7 @@ export default {
<board-editable-item
ref="sidebarItem"
class="board-sidebar-due-date"
+ data-testid="sidebar-due-date"
:title="$options.i18n.dueDate"
:loading="loading"
@open="openDatePicker"
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
index 55b1596ee18..f78be83cd82 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue
@@ -21,9 +21,9 @@ export default {
};
},
computed: {
- ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
selectedLabels() {
- const { labels = [] } = this.activeIssue;
+ const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
@@ -31,7 +31,7 @@ export default {
}));
},
issueLabels() {
- const { labels = [] } = this.activeIssue;
+ const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
@@ -40,7 +40,7 @@ export default {
},
},
methods: {
- ...mapActions(['setActiveIssueLabels']),
+ ...mapActions(['setActiveBoardItemLabels']),
async setLabels(payload) {
this.loading = true;
this.$refs.sidebarItem.collapse();
@@ -52,7 +52,7 @@ export default {
.map((label) => label.id);
const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue };
- await this.setActiveIssueLabels(input);
+ await this.setActiveBoardItemLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred while updating labels.') });
} finally {
@@ -65,7 +65,7 @@ export default {
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
- await this.setActiveIssueLabels(input);
+ await this.setActiveBoardItemLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred when removing the label.') });
} finally {
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
index 829f1c72806..ad225c7bf5c 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
@@ -56,20 +56,20 @@ export default {
},
},
computed: {
- ...mapGetters(['activeIssue']),
+ ...mapGetters(['activeBoardItem']),
hasMilestone() {
- return this.activeIssue.milestone !== null;
+ return this.activeBoardItem.milestone !== null;
},
groupFullPath() {
- const { referencePath = '' } = this.activeIssue;
+ const { referencePath = '' } = this.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPath() {
- const { referencePath = '' } = this.activeIssue;
+ const { referencePath = '' } = this.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('#'));
},
dropdownText() {
- return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone;
+ return this.activeBoardItem.milestone?.title ?? this.$options.i18n.noMilestone;
},
},
methods: {
@@ -113,11 +113,12 @@ export default {
ref="sidebarItem"
:title="$options.i18n.milestone"
:loading="loading"
- @open="handleOpen()"
+ data-testid="sidebar-milestones"
+ @open="handleOpen"
@close="handleClose"
>
<template v-if="hasMilestone" #collapsed>
- <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong>
+ <strong class="gl-text-gray-900">{{ activeBoardItem.milestone.title }}</strong>
</template>
<gl-dropdown
ref="dropdown"
@@ -130,7 +131,7 @@ export default {
<gl-dropdown-item
data-testid="no-milestone-item"
:is-check-item="true"
- :is-checked="!activeIssue.milestone"
+ :is-checked="!activeBoardItem.milestone"
@click="setMilestone(null)"
>
{{ $options.i18n.noMilestone }}
@@ -142,7 +143,7 @@ export default {
v-for="milestone in milestones"
:key="milestone.id"
:is-check-item="true"
- :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id"
+ :is-checked="activeBoardItem.milestone && milestone.id === activeBoardItem.milestone.id"
data-testid="milestone-item"
@click="setMilestone(milestone.id)"
>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
index f01c8e8fa20..376985f7cb6 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue
@@ -21,27 +21,31 @@ export default {
components: {
GlToggle,
},
+ inject: ['emailsDisabled'],
data() {
return {
loading: false,
};
},
computed: {
- ...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
+ ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']),
+ isEmailsDisabled() {
+ return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled;
+ },
notificationText() {
- return this.activeIssue.emailsDisabled
+ return this.isEmailsDisabled
? this.$options.i18n.header.subscribeDisabledDescription
: this.$options.i18n.header.title;
},
},
methods: {
- ...mapActions(['setActiveIssueSubscribed']),
+ ...mapActions(['setActiveItemSubscribed']),
async handleToggleSubscription() {
this.loading = true;
try {
- await this.setActiveIssueSubscribed({
- subscribed: !this.activeIssue.subscribed,
+ await this.setActiveItemSubscribed({
+ subscribed: !this.activeBoardItem.subscribed,
projectPath: this.projectPathForActiveIssue,
});
} catch (error) {
@@ -61,8 +65,8 @@ export default {
>
<span data-testid="notification-header-text"> {{ notificationText }} </span>
<gl-toggle
- v-if="!activeIssue.emailsDisabled"
- :value="activeIssue.subscribed"
+ v-if="!isEmailsDisabled"
+ :value="activeBoardItem.subscribed"
:is-loading="loading"
:label="$options.i18n.header.title"
label-position="hidden"
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue
new file mode 100644
index 00000000000..96d444980a8
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue
@@ -0,0 +1,25 @@
+<script>
+import { mapGetters } from 'vuex';
+import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
+
+export default {
+ components: {
+ IssuableTimeTracker,
+ },
+ inject: ['timeTrackingLimitToHours'],
+ computed: {
+ ...mapGetters(['activeBoardItem']),
+ },
+};
+</script>
+
+<template>
+ <issuable-time-tracker
+ :time-estimate="activeBoardItem.timeEstimate"
+ :time-spent="activeBoardItem.totalTimeSpent"
+ :human-time-estimate="activeBoardItem.humanTimeEstimate"
+ :human-time-spent="activeBoardItem.humanTotalTimeSpent"
+ :limit-to-hours="timeTrackingLimitToHours"
+ :show-collapsed="false"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
index 95864bd62a7..b8d3107c377 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue
@@ -27,12 +27,12 @@ export default {
};
},
computed: {
- ...mapGetters({ issue: 'activeIssue' }),
+ ...mapGetters({ item: 'activeBoardItem' }),
pendingChangesStorageKey() {
- return this.getPendingChangesKey(this.issue);
+ return this.getPendingChangesKey(this.item);
},
projectPath() {
- const referencePath = this.issue.referencePath || '';
+ const referencePath = this.item.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
validationState() {
@@ -40,29 +40,29 @@ export default {
},
},
watch: {
- issue: {
- handler(updatedIssue, formerIssue) {
- if (formerIssue?.title !== this.title) {
- localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title);
+ item: {
+ handler(updatedItem, formerItem) {
+ if (formerItem?.title !== this.title) {
+ localStorage.setItem(this.getPendingChangesKey(formerItem), this.title);
}
- this.title = updatedIssue.title;
+ this.title = updatedItem.title;
this.setPendingState();
},
immediate: true,
},
},
methods: {
- ...mapActions(['setActiveIssueTitle']),
- getPendingChangesKey(issue) {
- if (!issue) {
+ ...mapActions(['setActiveItemTitle']),
+ getPendingChangesKey(item) {
+ if (!item) {
return '';
}
return joinPaths(
window.location.pathname.slice(1),
- String(issue.id),
- 'issue-title-pending-changes',
+ String(item.id),
+ 'item-title-pending-changes',
);
},
async setPendingState() {
@@ -78,7 +78,7 @@ export default {
}
},
cancel() {
- this.title = this.issue.title;
+ this.title = this.item.title;
this.$refs.sidebarItem.collapse();
this.showChangesAlert = false;
localStorage.removeItem(this.pendingChangesStorageKey);
@@ -86,24 +86,24 @@ export default {
async setTitle() {
this.$refs.sidebarItem.collapse();
- if (!this.title || this.title === this.issue.title) {
+ if (!this.title || this.title === this.item.title) {
return;
}
try {
this.loading = true;
- await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath });
+ await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath });
localStorage.removeItem(this.pendingChangesStorageKey);
this.showChangesAlert = false;
} catch (e) {
- this.title = this.issue.title;
+ this.title = this.item.title;
createFlash({ message: this.$options.i18n.updateTitleError });
} finally {
this.loading = false;
}
},
handleOffClick() {
- if (this.title !== this.issue.title) {
+ if (this.title !== this.item.title) {
this.showChangesAlert = true;
localStorage.setItem(this.pendingChangesStorageKey, this.title);
} else {
@@ -112,11 +112,11 @@ export default {
},
},
i18n: {
- issueTitlePlaceholder: __('Issue title'),
+ titlePlaceholder: __('Title'),
submitButton: __('Save changes'),
cancelButton: __('Cancel'),
- updateTitleError: __('An error occurred when updating the issue title'),
- invalidFeedback: __('An issue title is required'),
+ updateTitleError: __('An error occurred when updating the title'),
+ invalidFeedback: __('A title is required'),
reviewYourChanges: __('Changes to the title have not been saved'),
},
};
@@ -131,10 +131,10 @@ export default {
@off-click="handleOffClick"
>
<template #title>
- <span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span>
+ <span class="gl-font-weight-bold" data-testid="item-title">{{ item.title }}</span>
</template>
<template #collapsed>
- <span class="gl-text-gray-800">{{ issue.referencePath }}</span>
+ <span class="gl-text-gray-800">{{ item.referencePath }}</span>
</template>
<gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false">
{{ $options.i18n.reviewYourChanges }}
@@ -144,7 +144,7 @@ export default {
<gl-form-input
v-model="title"
v-autofocusonshow
- :placeholder="$options.i18n.issueTitlePlaceholder"
+ :placeholder="$options.i18n.titlePlaceholder"
:state="validationState"
/>
</gl-form-group>
diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue
index 74805f8a681..49f5e7d20a9 100644
--- a/app/assets/javascripts/boards/components/toggle_focus.vue
+++ b/app/assets/javascripts/boards/components/toggle_focus.vue
@@ -38,14 +38,16 @@ export default {
</script>
<template>
- <div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center">
+ <div class="gl-ml-3 gl-display-none gl-md-display-flex gl-align-items-center">
<gl-button
ref="toggleFocusModeButton"
v-gl-tooltip
+ category="tertiary"
:icon="isFullscreen ? 'minimize' : 'maximize'"
class="js-focus-mode-btn"
data-qa-selector="focus_mode_button"
:title="$options.i18n.toggleFocusMode"
+ :aria-label="$options.i18n.toggleFocusMode"
@click="toggleFocusMode"
/>
</div>
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 7f327c5764d..41938d8e284 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -2,14 +2,15 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ConfigToggle from './components/config_toggle.vue';
-export default (boardsStore) => {
+export default (boardsStore = undefined) => {
const el = document.querySelector('.js-board-config');
if (!el) {
return;
}
- gl.boardConfigToggle = new Vue({
+ // eslint-disable-next-line no-new
+ new Vue({
el,
render(h) {
return h(ConfigToggle, {
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 65ebfe7be6c..4ebd30fe67b 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,4 +1,9 @@
import { __ } from '~/locale';
+import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
+import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
+import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
+import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
+import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
export const issuableTypes = {
issue: 'issue',
@@ -45,3 +50,27 @@ export default {
BoardType,
ListType,
};
+
+export const blockingIssuablesQueries = {
+ [issuableTypes.issue]: {
+ query: boardBlockingIssuesQuery,
+ },
+};
+
+export const titleQueries = {
+ [issuableTypes.issue]: {
+ mutation: issueSetTitleMutation,
+ },
+ [issuableTypes.epic]: {
+ mutation: updateEpicTitleMutation,
+ },
+};
+
+export const subscriptionQueries = {
+ [issuableTypes.issue]: {
+ mutation: issueSetSubscriptionMutation,
+ },
+ [issuableTypes.epic]: {
+ mutation: updateEpicSubscriptionMutation,
+ },
+};
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
index b6b34556663..62a0d930ec0 100644
--- a/app/assets/javascripts/boards/ee_functions.js
+++ b/app/assets/javascripts/boards/ee_functions.js
@@ -2,4 +2,3 @@ export const setWeightFetchingState = () => {};
export const setEpicFetchingState = () => {};
export const getMilestoneTitle = () => ({});
-export const getBoardsModalData = () => ({});
diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js
deleted file mode 100644
index 182a2cf3724..00000000000
--- a/app/assets/javascripts/boards/filtered_search.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import Vue from 'vue';
-import store from '~/boards/stores';
-import { queryToObject } from '~/lib/utils/url_utility';
-import FilteredSearch from './components/filtered_search.vue';
-
-export default () => {
- const queryParams = queryToObject(window.location.search);
- const el = document.getElementById('js-board-filtered-search');
-
- /*
- When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed
- we can remove apolloProvider option from here. Currently without it its causing
- an error
- */
-
- return new Vue({
- el,
- store,
- apolloProvider: {},
- render: (createElement) =>
- createElement(FilteredSearch, {
- props: { search: queryParams.search },
- }),
- });
-};
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
new file mode 100644
index 00000000000..4dc245660a4
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
@@ -0,0 +1,16 @@
+query BoardBlockingIssues($id: IssueID!) {
+ issuable: issue(id: $id) {
+ __typename
+ id
+ blockingIssuables: blockedByIssues {
+ __typename
+ nodes {
+ id
+ iid
+ title
+ reference(full: true)
+ webUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 1395bef39ed..7ecf9261214 100644
--- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -7,6 +7,10 @@ fragment IssueNode on Issue {
referencePath: reference(full: true)
dueDate
timeEstimate
+ totalTimeSpent
+ humanTimeEstimate
+ humanTotalTimeSpent
+ emailsDisabled
confidential
webUrl
subscribed
diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
index 1f383245ac2..bfb87758e17 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
@@ -1,5 +1,5 @@
mutation issueSetSubscription($input: IssueSetSubscriptionInput!) {
- issueSetSubscription(input: $input) {
+ updateIssuableSubscription: issueSetSubscription(input: $input) {
issue {
subscribed
}
diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
index 62e6c1352a6..6ad12d982e0 100644
--- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql
@@ -1,5 +1,5 @@
mutation issueSetTitle($input: UpdateIssueInput!) {
- updateIssue(input: $input) {
+ updateIssuableTitle: updateIssue(input: $input) {
issue {
title
}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index ceca5b0a451..e3f9d2f24c2 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -10,26 +10,21 @@ import {
setWeightFetchingState,
setEpicFetchingState,
getMilestoneTitle,
- getBoardsModalData,
} from 'ee_else_ce/boards/ee_functions';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
import BoardContent from '~/boards/components/board_content.vue';
-import BoardExtraActions from '~/boards/components/board_extra_actions.vue';
import './models/label';
import './models/assignee';
import '~/boards/models/milestone';
import '~/boards/models/project';
import '~/boards/filters/due_date_filters';
-import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
-import modalMixin from '~/boards/mixins/modal_mixins';
import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
-import ModalStore from '~/boards/stores/modal_store';
import toggleFocusMode from '~/boards/toggle_focus';
import { deprecatedCreateFlash as Flash } from '~/flash';
import createDefaultClient from '~/lib/graphql';
@@ -72,21 +67,12 @@ export default () => {
boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
}
- if (gon?.features?.boardsFilteredSearch) {
- import('~/boards/filtered_search')
- .then(({ default: initFilteredSearch }) => {
- initFilteredSearch(apolloProvider);
- })
- .catch(() => {});
- }
-
// eslint-disable-next-line @gitlab/no-runtime-template-compiler
issueBoardsApp = new Vue({
el: $boardApp,
components: {
BoardContent,
BoardSidebar,
- BoardAddIssuesModal,
BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'),
},
provide: {
@@ -95,6 +81,7 @@ export default () => {
rootPath: $boardApp.dataset.rootPath,
currentUserId: gon.current_user_id || null,
canUpdate: parseBoolean($boardApp.dataset.canUpdate),
+ canAdminList: parseBoolean($boardApp.dataset.canAdminList),
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
@@ -107,6 +94,8 @@ export default () => {
milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
+ issuableType: issuableTypes.issue,
+ emailsDisabled: parseBoolean($boardApp.dataset.emailsDisabled),
},
store,
apolloProvider,
@@ -174,15 +163,9 @@ export default () => {
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
- if (!gon.features?.boardsFilteredSearch) {
- this.filterManager = new FilteredSearchBoards(
- boardsStore.filter,
- true,
- boardsStore.cantEdit,
- );
+ this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit);
- this.filterManager.setup();
- }
+ this.filterManager.setup();
this.performSearch();
@@ -323,49 +306,7 @@ export default () => {
boardConfigToggle(boardsStore);
- const issueBoardsModal = document.getElementById('js-add-issues-btn');
-
- if (issueBoardsModal && gon.features.addIssuesButton) {
- // eslint-disable-next-line no-new
- new Vue({
- el: issueBoardsModal,
- mixins: [modalMixin],
- data() {
- return {
- modal: ModalStore.store,
- store: boardsStore.state,
- ...getBoardsModalData(),
- canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
- };
- },
- computed: {
- disabled() {
- if (!this.store) {
- return true;
- }
- return !this.store.lists.filter((list) => !list.preset).length;
- },
- },
- methods: {
- openModal() {
- if (!this.disabled) {
- this.toggleModal(true);
- }
- },
- },
- render(createElement) {
- return createElement(BoardExtraActions, {
- props: {
- canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
- openModal: this.openModal,
- disabled: this.disabled,
- },
- });
- },
- });
- }
-
- toggleFocusMode(ModalStore, boardsStore);
+ toggleFocusMode();
toggleLabels();
if (gon.licensed_features?.swimlanes) {
diff --git a/app/assets/javascripts/boards/mixins/board_new_issue.js b/app/assets/javascripts/boards/mixins/board_new_issue.js
new file mode 100644
index 00000000000..d4b74544735
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/board_new_issue.js
@@ -0,0 +1,6 @@
+export default {
+ // EE-only
+ methods: {
+ extraIssueInput: () => {},
+ },
+};
diff --git a/app/assets/javascripts/boards/mixins/modal_footer.js b/app/assets/javascripts/boards/mixins/modal_footer.js
deleted file mode 100644
index ff8b4c56321..00000000000
--- a/app/assets/javascripts/boards/mixins/modal_footer.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js
deleted file mode 100644
index 6c97e1629bf..00000000000
--- a/app/assets/javascripts/boards/mixins/modal_mixins.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import ModalStore from '../stores/modal_store';
-
-export default {
- methods: {
- toggleModal(toggle) {
- ModalStore.store.showAddIssuesModal = toggle;
- },
- changeTab(tab) {
- ModalStore.store.activeTab = tab;
- },
- },
-};
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 19b31ee7291..8005414962c 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,16 +1,21 @@
+import * as Sentry from '@sentry/browser';
import { pick } from 'lodash';
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
+import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import {
BoardType,
ListType,
inactiveId,
flashAnimationDuration,
ISSUABLE,
+ titleQueries,
+ subscriptionQueries,
} from '~/boards/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
import {
formatBoardLists,
formatListIssues,
@@ -20,18 +25,17 @@ import {
formatIssueInput,
updateListPosition,
transformNotFilters,
+ moveItemListHelper,
+ getMoveData,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
-import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql';
import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql';
-import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql';
-import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
import * as types from './mutation_types';
@@ -68,6 +72,7 @@ export default {
'milestoneTitle',
'releaseTag',
'search',
+ 'myReactionEmoji',
]);
filterParams.not = transformNotFilters(filters);
commit(types.SET_FILTERS, filterParams);
@@ -326,63 +331,155 @@ export default {
commit(types.RESET_ISSUES);
},
- moveItem: ({ dispatch }) => {
- dispatch('moveIssue');
+ moveItem: ({ dispatch }, payload) => {
+ dispatch('moveIssue', payload);
},
- moveIssue: (
- { state, commit },
- { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId },
+ moveIssue: ({ dispatch, state }, params) => {
+ const moveData = getMoveData(state, params);
+
+ dispatch('moveIssueCard', moveData);
+ dispatch('updateMovedIssue', moveData);
+ dispatch('updateIssueOrder', { moveData });
+ },
+
+ moveIssueCard: ({ commit }, moveData) => {
+ const {
+ reordering,
+ shouldClone,
+ itemNotInToList,
+ originalIndex,
+ itemId,
+ fromListId,
+ toListId,
+ moveBeforeId,
+ moveAfterId,
+ } = moveData;
+
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+
+ if (reordering) {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, {
+ itemId,
+ listId: toListId,
+ moveBeforeId,
+ moveAfterId,
+ });
+
+ return;
+ }
+
+ if (itemNotInToList) {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, {
+ itemId,
+ listId: toListId,
+ moveBeforeId,
+ moveAfterId,
+ });
+ }
+
+ if (shouldClone) {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
+ }
+ },
+
+ updateMovedIssue: (
+ { commit, state: { boardItems, boardLists } },
+ { itemId, fromListId, toListId },
) => {
- const originalIssue = state.boardItems[itemId];
- const fromList = state.boardItemsByListId[fromListId];
- const originalIndex = fromList.indexOf(Number(itemId));
- commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId });
+ const updatedIssue = moveItemListHelper(
+ boardItems[itemId],
+ boardLists[fromListId],
+ boardLists[toListId],
+ );
- const { boardId } = state;
- const [fullProjectPath] = itemPath.split(/[#]/);
+ commit(types.UPDATE_BOARD_ITEM, updatedIssue);
+ },
- gqlClient
- .mutate({
+ undoMoveIssueCard: ({ commit }, moveData) => {
+ const {
+ reordering,
+ shouldClone,
+ itemNotInToList,
+ itemId,
+ fromListId,
+ toListId,
+ originalIssue,
+ originalIndex,
+ } = moveData;
+
+ commit(types.UPDATE_BOARD_ITEM, originalIssue);
+
+ if (reordering) {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
+ return;
+ }
+
+ if (shouldClone) {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+ }
+ if (itemNotInToList) {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: toListId });
+ }
+
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex });
+ },
+
+ updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
+ try {
+ const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData;
+ const {
+ boardId,
+ boardItems: {
+ [itemId]: { iid, referencePath },
+ },
+ } = state;
+
+ const { data } = await gqlClient.mutate({
mutation: issueMoveListMutation,
variables: {
- projectPath: fullProjectPath,
+ iid,
+ projectPath: referencePath.split(/[#]/)[0],
boardId: fullBoardId(boardId),
- iid: itemIid,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
moveAfterId,
+ // 'mutationVariables' allows EE code to pass in extra parameters.
+ ...mutationVariables,
},
- })
- .then(({ data }) => {
- if (data?.issueMoveList?.errors.length) {
- throw new Error();
- } else {
- const issue = data.issueMoveList?.issue;
- commit(types.MOVE_ISSUE_SUCCESS, { issue });
- }
- })
- .catch(() =>
- commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }),
+ });
+
+ if (data?.issueMoveList?.errors.length || !data.issueMoveList) {
+ throw new Error('issueMoveList empty');
+ }
+
+ commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue });
+ } catch {
+ commit(
+ types.SET_ERROR,
+ s__('Boards|An error occurred while moving the issue. Please try again.'),
);
+ dispatch('undoMoveIssueCard', moveData);
+ }
},
setAssignees: ({ commit, getters }, assigneeUsernames) => {
- commit('UPDATE_ISSUE_BY_ID', {
- issueId: getters.activeIssue.id,
+ commit('UPDATE_BOARD_ITEM_BY_ID', {
+ itemId: getters.activeBoardItem.id,
prop: 'assignees',
value: assigneeUsernames,
});
},
setActiveIssueMilestone: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetMilestoneMutation,
variables: {
input: {
- iid: String(activeIssue.iid),
+ iid: String(activeBoardItem.iid),
milestoneId: getIdFromGraphQLId(input.milestoneId),
projectPath: input.projectPath,
},
@@ -393,65 +490,71 @@ export default {
throw new Error(data.updateIssue.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'milestone',
value: data.updateIssue.issue.milestone,
});
},
- createNewIssue: ({ commit, state }, issueInput) => {
- const { boardConfig } = state;
+ addListItem: ({ commit }, { list, item, position }) => {
+ commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, atIndex: position });
+ commit(types.UPDATE_BOARD_ITEM, item);
+ },
+
+ removeListItem: ({ commit }, { listId, itemId }) => {
+ commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { listId, itemId });
+ commit(types.REMOVE_BOARD_ITEM, itemId);
+ },
+ addListNewIssue: (
+ { state: { boardConfig, boardType, fullPath }, dispatch, commit },
+ { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` },
+ ) => {
const input = formatIssueInput(issueInput, boardConfig);
- const { boardType, fullPath } = state;
if (boardType === BoardType.project) {
input.projectPath = fullPath;
}
- return gqlClient
+ const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId });
+ dispatch('addListItem', { list, item: placeholderIssue, position: 0 });
+
+ gqlClient
.mutate({
mutation: issueCreateMutation,
variables: { input },
})
.then(({ data }) => {
if (data.createIssue.errors.length) {
- commit(types.CREATE_ISSUE_FAILURE);
- } else {
- return data.createIssue?.issue;
+ throw new Error();
}
- return null;
- })
- .catch(() => commit(types.CREATE_ISSUE_FAILURE));
- },
- addListIssue: ({ commit }, { list, issue, position }) => {
- commit(types.ADD_ISSUE_TO_LIST, { list, issue, position });
+ const rawIssue = data.createIssue?.issue;
+ const formattedIssue = formatIssue({ ...rawIssue, id: getIdFromGraphQLId(rawIssue.id) });
+ dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
+ dispatch('addListItem', { list, item: formattedIssue, position: 0 });
+ })
+ .catch(() => {
+ dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
+ commit(
+ types.SET_ERROR,
+ s__('Boards|An error occurred while creating the issue. Please try again.'),
+ );
+ });
},
- addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => {
- const issue = formatIssue({ ...issueInput, id: 'tmp' });
- commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 });
-
- dispatch('createNewIssue', issueInput)
- .then((res) => {
- commit(types.ADD_ISSUE_TO_LIST, {
- list,
- issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }),
- });
- commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue });
- })
- .catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id }));
+ setActiveBoardItemLabels: ({ dispatch }, params) => {
+ dispatch('setActiveIssueLabels', params);
},
setActiveIssueLabels: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetLabelsMutation,
variables: {
input: {
- iid: String(activeIssue.iid),
+ iid: String(activeBoardItem.iid),
addLabelIds: input.addLabelIds ?? [],
removeLabelIds: input.removeLabelIds ?? [],
projectPath: input.projectPath,
@@ -463,20 +566,20 @@ export default {
throw new Error(data.updateIssue.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'labels',
value: data.updateIssue.issue.labels.nodes,
});
},
setActiveIssueDueDate: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ const { activeBoardItem } = getters;
const { data } = await gqlClient.mutate({
mutation: issueSetDueDateMutation,
variables: {
input: {
- iid: String(activeIssue.iid),
+ iid: String(activeBoardItem.iid),
projectPath: input.projectPath,
dueDate: input.dueDate,
},
@@ -487,57 +590,66 @@ export default {
throw new Error(data.updateIssue.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'dueDate',
value: data.updateIssue.issue.dueDate,
});
},
- setActiveIssueSubscribed: async ({ commit, getters }, input) => {
+ setActiveItemSubscribed: async ({ commit, getters, state }, input) => {
+ const { activeBoardItem, isEpicBoard } = getters;
+ const { fullPath, issuableType } = state;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: input.projectPath };
const { data } = await gqlClient.mutate({
- mutation: issueSetSubscriptionMutation,
+ mutation: subscriptionQueries[issuableType].mutation,
variables: {
input: {
- iid: String(getters.activeIssue.iid),
- projectPath: input.projectPath,
+ ...workspacePath,
+ iid: String(activeBoardItem.iid),
subscribedState: input.subscribed,
},
},
});
- if (data.issueSetSubscription?.errors?.length > 0) {
- throw new Error(data.issueSetSubscription.errors);
+ if (data.updateIssuableSubscription?.errors?.length > 0) {
+ throw new Error(data.updateIssuableSubscription[issuableType].errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: getters.activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'subscribed',
- value: data.issueSetSubscription.issue.subscribed,
+ value: data.updateIssuableSubscription[issuableType].subscribed,
});
},
- setActiveIssueTitle: async ({ commit, getters }, input) => {
- const { activeIssue } = getters;
+ setActiveItemTitle: async ({ commit, getters, state }, input) => {
+ const { activeBoardItem, isEpicBoard } = getters;
+ const { fullPath, issuableType } = state;
+ const workspacePath = isEpicBoard
+ ? { groupPath: fullPath }
+ : { projectPath: input.projectPath };
const { data } = await gqlClient.mutate({
- mutation: issueSetTitleMutation,
+ mutation: titleQueries[issuableType].mutation,
variables: {
input: {
- iid: String(activeIssue.iid),
- projectPath: input.projectPath,
+ ...workspacePath,
+ iid: String(activeBoardItem.iid),
title: input.title,
},
},
});
- if (data.updateIssue?.errors?.length > 0) {
- throw new Error(data.updateIssue.errors);
+ if (data.updateIssuableTitle?.errors?.length > 0) {
+ throw new Error(data.updateIssuableTitle.errors);
}
- commit(types.UPDATE_ISSUE_BY_ID, {
- issueId: activeIssue.id,
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
prop: 'title',
- value: data.updateIssue.issue.title,
+ value: data.updateIssuableTitle[issuableType].title,
});
},
@@ -576,10 +688,10 @@ export default {
const { selectedBoardItems } = state;
const index = selectedBoardItems.indexOf(boardItem);
- // If user already selected an item (activeIssue) without using mult-select,
+ // If user already selected an item (activeBoardItem) without using mult-select,
// include that item in the selection and unset state.ActiveId to hide the sidebar.
- if (getters.activeIssue) {
- commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue);
+ if (getters.activeBoardItem) {
+ commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeBoardItem);
dispatch('unsetActiveId');
}
@@ -608,6 +720,18 @@ export default {
}
},
+ setError: ({ commit }, { message, error, captureError = false }) => {
+ commit(types.SET_ERROR, message);
+
+ if (captureError) {
+ Sentry.captureException(error);
+ }
+ },
+
+ unsetError: ({ commit }) => {
+ commit(types.SET_ERROR, undefined);
+ },
+
fetchBacklog: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index caa518f91ce..0589851c658 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -1,5 +1,5 @@
import { find } from 'lodash';
-import { BoardType, inactiveId } from '../constants';
+import { BoardType, inactiveId, issuableTypes } from '../constants';
export default {
isGroupBoard: (state) => state.boardType === BoardType.group,
@@ -15,17 +15,17 @@ export default {
return listItemsIds.map((id) => getters.getBoardItemById(id));
},
- activeIssue: (state) => {
+ activeBoardItem: (state) => {
return state.boardItems[state.activeId] || {};
},
groupPathForActiveIssue: (_, getters) => {
- const { referencePath = '' } = getters.activeIssue;
+ const { referencePath = '' } = getters.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('/'));
},
projectPathForActiveIssue: (_, getters) => {
- const { referencePath = '' } = getters.activeIssue;
+ const { referencePath = '' } = getters.activeBoardItem;
return referencePath.slice(0, referencePath.indexOf('#'));
},
@@ -44,6 +44,10 @@ export default {
return find(state.boardLists, (l) => l.title === title);
},
+ isIssueBoard: (state) => {
+ return state.issuableType === issuableTypes.issue;
+ },
+
isEpicBoard: () => {
return false;
},
diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js
deleted file mode 100644
index 8a8fa61361c..00000000000
--- a/app/assets/javascripts/boards/stores/modal_store.js
+++ /dev/null
@@ -1,95 +0,0 @@
-class ModalStore {
- constructor() {
- this.store = {
- columns: 3,
- issues: [],
- issuesCount: false,
- selectedIssues: [],
- showAddIssuesModal: false,
- activeTab: 'all',
- selectedList: null,
- searchTerm: '',
- loading: false,
- loadingNewPage: false,
- filterLoading: false,
- page: 1,
- perPage: 50,
- filter: {
- path: '',
- },
- };
- }
-
- selectedCount() {
- return this.getSelectedIssues().length;
- }
-
- toggleIssue(issueObj) {
- const issue = issueObj;
- const { selected } = issue;
-
- issue.selected = !selected;
-
- if (!selected) {
- this.addSelectedIssue(issue);
- } else {
- this.removeSelectedIssue(issue);
- }
- }
-
- toggleAll() {
- const select = this.selectedCount() !== this.store.issues.length;
-
- this.store.issues.forEach((issue) => {
- const issueUpdate = issue;
-
- if (issueUpdate.selected !== select) {
- issueUpdate.selected = select;
-
- if (select) {
- this.addSelectedIssue(issue);
- } else {
- this.removeSelectedIssue(issue);
- }
- }
- });
- }
-
- getSelectedIssues() {
- return this.store.selectedIssues.filter((issue) => issue.selected);
- }
-
- addSelectedIssue(issue) {
- const index = this.selectedIssueIndex(issue);
-
- if (index === -1) {
- this.store.selectedIssues.push(issue);
- }
- }
-
- removeSelectedIssue(issue, forcePurge = false) {
- if (this.store.activeTab === 'all' || forcePurge) {
- this.store.selectedIssues = this.store.selectedIssues.filter(
- (fIssue) => fIssue.id !== issue.id,
- );
- }
- }
-
- purgeUnselectedIssues() {
- this.store.selectedIssues.forEach((issue) => {
- if (!issue.selected) {
- this.removeSelectedIssue(issue, true);
- }
- });
- }
-
- selectedIssueIndex(issue) {
- return this.store.selectedIssues.indexOf(issue);
- }
-
- findSelectedIssue(issue) {
- return this.store.selectedIssues.filter((filteredIssue) => filteredIssue.id === issue.id)[0];
- }
-}
-
-export default new ModalStore();
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index e7c034fb087..22b9905ee62 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -20,23 +20,21 @@ export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS';
-export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE';
export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
-export const MOVE_ISSUE = 'MOVE_ISSUE';
-export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS';
-export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE';
+export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM';
+export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM';
export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE';
+export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS';
export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
-export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST';
-export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE';
-export const REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST';
+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_CURRENT_PAGE = 'SET_CURRENT_PAGE';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
export const SET_ACTIVE_ID = 'SET_ACTIVE_ID';
-export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID';
+export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS';
@@ -49,3 +47,4 @@ export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE';
export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
+export const SET_ERROR = 'SET_ERROR';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 75b60366b6a..561c21b78c1 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -2,7 +2,7 @@ import { pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
-import { formatIssue, moveItemListHelper } from '../boards_util';
+import { formatIssue } from '../boards_util';
import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
@@ -158,13 +158,13 @@ export default {
});
},
- [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => {
- if (!state.boardItems[issueId]) {
+ [mutationTypes.UPDATE_BOARD_ITEM_BY_ID]: (state, { itemId, prop, value }) => {
+ if (!state.boardItems[itemId]) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
throw new Error('No issue found.');
}
- Vue.set(state.boardItems[issueId], prop, value);
+ Vue.set(state.boardItems[itemId], prop, value);
},
[mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
@@ -183,40 +183,11 @@ export default {
notImplemented();
},
- [mutationTypes.MOVE_ISSUE]: (
- state,
- { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId },
- ) => {
- const fromList = state.boardLists[fromListId];
- const toList = state.boardLists[toListId];
-
- const issue = moveItemListHelper(originalIssue, fromList, toList);
- Vue.set(state.boardItems, issue.id, issue);
-
- removeItemFromList({ state, listId: fromListId, itemId: issue.id });
- addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId });
- },
-
- [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => {
+ [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => {
const issueId = getIdFromGraphQLId(issue.id);
Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId }));
},
- [mutationTypes.MOVE_ISSUE_FAILURE]: (
- state,
- { originalIssue, fromListId, toListId, originalIndex },
- ) => {
- state.error = s__('Boards|An error occurred while moving the issue. Please try again.');
- Vue.set(state.boardItems, originalIssue.id, originalIssue);
- removeItemFromList({ state, listId: toListId, itemId: originalIssue.id });
- addItemToList({
- state,
- listId: fromListId,
- itemId: originalIssue.id,
- atIndex: originalIndex,
- });
- },
-
[mutationTypes.REQUEST_UPDATE_ISSUE]: () => {
notImplemented();
},
@@ -229,28 +200,23 @@ export default {
notImplemented();
},
- [mutationTypes.CREATE_ISSUE_FAILURE]: (state) => {
- state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
+ [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
+ state,
+ { itemId, listId, moveBeforeId, moveAfterId, atIndex },
+ ) => {
+ addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex });
},
- [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => {
- addItemToList({
- state,
- listId: list.id,
- itemId: issue.id,
- atIndex: position,
- });
- Vue.set(state.boardItems, issue.id, issue);
+ [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => {
+ removeItemFromList({ state, listId, itemId });
},
- [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => {
- state.error = s__('Boards|An error occurred while creating the issue. Please try again.');
- removeItemFromList({ state, listId: list.id, itemId: issueId });
+ [mutationTypes.UPDATE_BOARD_ITEM]: (state, item) => {
+ Vue.set(state.boardItems, item.id, item);
},
- [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => {
- removeItemFromList({ state, listId: list.id, itemId: issue.id });
- Vue.delete(state.boardItems, issue.id);
+ [mutationTypes.REMOVE_BOARD_ITEM]: (state, itemId) => {
+ Vue.delete(state.boardItems, itemId);
},
[mutationTypes.SET_CURRENT_PAGE]: () => {
@@ -309,4 +275,8 @@ export default {
[mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => {
state.selectedBoardItems = [];
},
+
+ [mutationTypes.SET_ERROR]: (state, error) => {
+ state.error = error;
+ },
};
diff --git a/app/assets/javascripts/branches/branch_sort_dropdown.js b/app/assets/javascripts/branches/branch_sort_dropdown.js
new file mode 100644
index 00000000000..9914ce05a95
--- /dev/null
+++ b/app/assets/javascripts/branches/branch_sort_dropdown.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import SortDropdown from './components/sort_dropdown.vue';
+
+const mountDropdownApp = (el) => {
+ const { mode, projectBranchesFilteredPath, sortOptions } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'SortBranchesDropdownApp',
+ components: {
+ SortDropdown,
+ },
+ provide: {
+ mode,
+ projectBranchesFilteredPath,
+ sortOptions: JSON.parse(sortOptions),
+ },
+ render: (createElement) => createElement(SortDropdown),
+ });
+};
+
+export default () => {
+ const el = document.getElementById('js-branches-sort-dropdown');
+ return el ? mountDropdownApp(el) : null;
+};
diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue
new file mode 100644
index 00000000000..ddb4c5c0015
--- /dev/null
+++ b/app/assets/javascripts/branches/components/sort_dropdown.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
+import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+const OVERVIEW_MODE = 'overview';
+
+export default {
+ i18n: {
+ searchPlaceholder: s__('Branches|Filter by branch name'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByClick,
+ },
+ inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'],
+ data() {
+ return {
+ selectedKey: 'updated_desc',
+ searchTerm: '',
+ };
+ },
+ computed: {
+ shouldShowDropdown() {
+ return this.mode !== OVERVIEW_MODE;
+ },
+ selectedSortMethodName() {
+ return this.sortOptions[this.selectedKey];
+ },
+ },
+ created() {
+ const sortValue = getParameterValues('sort');
+ const searchValue = getParameterValues('search');
+
+ if (sortValue.length > 0) {
+ [this.selectedKey] = sortValue;
+ }
+
+ if (searchValue.length > 0) {
+ [this.searchTerm] = searchValue;
+ }
+ },
+ methods: {
+ isSortMethodSelected(sortKey) {
+ return sortKey === this.selectedKey;
+ },
+ visitUrlFromOption(sortKey) {
+ this.selectedKey = sortKey;
+ const urlParams = {};
+
+ if (this.mode !== OVERVIEW_MODE) {
+ urlParams.sort = sortKey;
+ }
+
+ urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null;
+
+ const newUrl = mergeUrlParams(urlParams, this.projectBranchesFilteredPath);
+ visitUrl(newUrl);
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-pr-4">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ :placeholder="$options.i18n.searchPlaceholder"
+ class="gl-pr-4"
+ data-testid="branch-search"
+ @submit="visitUrlFromOption(selectedKey)"
+ />
+ <gl-dropdown
+ v-if="shouldShowDropdown"
+ :text="selectedSortMethodName"
+ data-testid="branches-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="(value, key) in sortOptions"
+ :key="key"
+ :is-checked="isSortMethodSelected(key)"
+ is-check-item
+ @click="visitUrlFromOption(key)"
+ >{{ value }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index ca019bc4178..66e8d982113 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -4,13 +4,13 @@ import axios from '../lib/utils/axios_utils';
import { __ } from '../locale';
import DivergenceGraph from './components/divergence_graph.vue';
-export function createGraphVueApp(el, data, maxCommits) {
+export function createGraphVueApp(el, data, maxCommits, defaultBranch) {
return new Vue({
el,
render(h) {
return h(DivergenceGraph, {
props: {
- defaultBranch: 'master',
+ defaultBranch,
distance: data.distance ? parseInt(data.distance, 10) : null,
aheadCount: parseInt(data.ahead, 10),
behindCount: parseInt(data.behind, 10),
@@ -21,7 +21,7 @@ export function createGraphVueApp(el, data, maxCommits) {
});
}
-export default (endpoint) => {
+export default (endpoint, defaultBranch) => {
const names = [...document.querySelectorAll('.js-branch-item')].map(
({ dataset }) => dataset.name,
);
@@ -47,7 +47,7 @@ export default (endpoint) => {
if (!el) return;
- createGraphVueApp(el, val, maxCommits);
+ createGraphVueApp(el, val, maxCommits, defaultBranch);
});
})
.catch(() =>
diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js
new file mode 100644
index 00000000000..e49abc10b29
--- /dev/null
+++ b/app/assets/javascripts/captcha/apollo_captcha_link.js
@@ -0,0 +1,37 @@
+import { ApolloLink, Observable } from 'apollo-link';
+
+export const apolloCaptchaLink = new ApolloLink((operation, forward) =>
+ forward(operation).flatMap((result) => {
+ const { errors = [] } = result;
+
+ // Our API will return with a top-level GraphQL error with extensions
+ // in case a captcha is required.
+ const captchaError = errors.find((e) => e?.extensions?.needs_captcha_response);
+ if (captchaError) {
+ const captchaSiteKey = captchaError.extensions.captcha_site_key;
+ const spamLogId = captchaError.extensions.spam_log_id;
+
+ return new Observable((observer) => {
+ import('~/captcha/wait_for_captcha_to_be_solved')
+ .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey))
+ .then((captchaResponse) => {
+ // If the captcha was solved correctly, we re-do our action while setting
+ // captcha response headers.
+ operation.setContext({
+ headers: {
+ 'X-GitLab-Captcha-Response': captchaResponse,
+ 'X-GitLab-Spam-Log-Id': spamLogId,
+ },
+ });
+ forward(operation).subscribe(observer);
+ })
+ .catch((error) => {
+ observer.error(error);
+ observer.complete();
+ });
+ });
+ }
+
+ return Observable.of(result);
+ }),
+);
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index 9a55177b15f..ced07dea7be 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -32,7 +32,7 @@ export default {
return {
content: '',
loading: false,
- valid: false,
+ isValid: false,
errors: null,
warnings: null,
jobs: [],
@@ -61,7 +61,7 @@ export default {
});
this.showingResults = true;
- this.valid = valid;
+ this.isValid = valid;
this.errors = errors;
this.warnings = warnings;
this.jobs = jobs;
@@ -120,7 +120,7 @@ export default {
<ci-lint-results
v-if="showingResults"
class="col-sm-12 gl-mt-5"
- :valid="valid"
+ :is-valid="isValid"
:jobs="jobs"
:errors="errors"
:warnings="warnings"
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 0233ffaccdc..bc1e401d373 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -7,6 +7,10 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
+ i18n: {
+ editButton: s__('Pipelines|Edit'),
+ revokeButton: s__('Pipelines|Revoke'),
+ },
components: {
GlTable,
GlButton,
@@ -108,13 +112,15 @@ export default {
</template>
<template #cell(actions)="{ item }">
<gl-button
- :title="s__('Pipelines|Edit')"
+ :title="$options.i18n.editButton"
+ :aria-label="$options.i18n.editButton"
icon="pencil"
data-testid="edit-btn"
:href="item.editProjectTriggerPath"
/>
<gl-button
- :title="s__('Pipelines|Revoke')"
+ :title="$options.i18n.revokeButton"
+ :aria-label="$options.i18n.revokeButton"
icon="remove"
variant="warning"
:data-confirm="
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index be7c0b68b4c..12def6e7eef 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -7,6 +7,7 @@ import {
GlFormCombobox,
GlFormGroup,
GlFormSelect,
+ GlFormInput,
GlFormTextarea,
GlIcon,
GlLink,
@@ -41,6 +42,7 @@ export default {
GlFormCombobox,
GlFormGroup,
GlFormSelect,
+ GlFormInput,
GlFormTextarea,
GlIcon,
GlLink,
@@ -128,6 +130,9 @@ export default {
return true;
},
+ scopedVariablesAvailable() {
+ return !this.isGroup || this.glFeatures.groupScopedCiVariables;
+ },
variableValidationFeedback() {
return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
},
@@ -222,28 +227,25 @@ export default {
</gl-form-group>
<div class="d-flex">
- <gl-form-group
- :label="__('Type')"
- label-for="ci-variable-type"
- class="w-50 gl-mr-5"
- :class="{ 'w-100': isGroup }"
- >
+ <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
<gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" />
</gl-form-group>
<gl-form-group
- v-if="!isGroup"
:label="__('Environment scope')"
label-for="ci-variable-env"
class="w-50"
data-testid="environment-scope"
>
<ci-environments-dropdown
+ v-if="scopedVariablesAvailable"
class="w-100"
:value="environment_scope"
@selectEnvironment="setEnvironmentScope"
@createClicked="addWildCardScope"
/>
+
+ <gl-form-input v-else v-model="environment_scope" class="w-100" readonly />
</gl-form-group>
</div>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
index 6e6527df63f..605da5d9352 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue
@@ -37,7 +37,7 @@ export default {
<template>
<div id="popover-container">
- <gl-popover :target="target" triggers="hover" placement="top" container="popover-container">
+ <gl-popover :target="target" placement="top" container="popover-container">
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-word-break-all"
>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index c9943052356..e5923124653 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -2,6 +2,7 @@
import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { s__, __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
import CiVariablePopover from './ci_variable_popover.vue';
@@ -59,8 +60,9 @@ export default {
directives: {
GlModalDirective,
},
+ mixins: [glFeatureFlagsMixin()],
computed: {
- ...mapState(['variables', 'valuesHidden', 'isGroup', 'isLoading', 'isDeleting']),
+ ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']),
valuesButtonText() {
return this.valuesHidden ? __('Reveal values') : __('Hide values');
},
@@ -68,9 +70,6 @@ export default {
return this.variables && this.variables.length > 0;
},
fields() {
- if (this.isGroup) {
- return this.$options.fields.filter((field) => field.key !== 'environment_scope');
- }
return this.$options.fields;
},
},
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 37b5f7e6df7..50856ca9533 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -3,8 +3,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import CiVariableSettings from './components/ci_variable_settings.vue';
import createStore from './store';
-export default (containerId = 'js-ci-project-variables') => {
- const containerEl = document.getElementById(containerId);
+const mountCiVariableListApp = (containerEl) => {
const {
endpoint,
projectId,
@@ -43,3 +42,9 @@ export default (containerId = 'js-ci-project-variables') => {
},
});
};
+
+export default (containerId = 'js-ci-project-variables') => {
+ const el = document.getElementById(containerId);
+
+ return !el ? {} : mountCiVariableListApp(el);
+};
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 76fe076d4ff..a53b63ea592 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -141,6 +141,9 @@ export default {
isInstalling() {
return this.status === APPLICATION_STATUS.INSTALLING;
},
+ isExternallyInstalled() {
+ return this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED;
+ },
canInstall() {
return (
this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
@@ -193,10 +196,17 @@ export default {
label = __('Installing');
} else if (this.installed) {
label = __('Installed');
+ } else if (this.isExternallyInstalled) {
+ label = __('Externally installed');
}
return label;
},
+ buttonGridCellClass() {
+ return this.showManageButton || this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED
+ ? 'section-25'
+ : 'section-15';
+ },
showManageButton() {
return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED;
},
@@ -427,8 +437,7 @@ export default {
</div>
</div>
<div
- :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }"
- class="table-section table-button-footer section-align-top"
+ :class="[buttonGridCellClass, 'table-section', 'table-button-footer', 'section-align-top']"
role="gridcell"
>
<div v-if="showManageButton" class="btn-group table-action-buttons">
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index e2227c61cee..90ec3f2377c 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -26,6 +26,7 @@ export const APPLICATION_STATUS = {
ERROR: 'errored',
PRE_INSTALLED: 'pre_installed',
UNINSTALLED: 'uninstalled',
+ EXTERNALLY_INSTALLED: 'externally_installed',
};
/*
diff --git a/app/assets/javascripts/clusters/forms/show/index.js b/app/assets/javascripts/clusters/forms/show/index.js
index 47a3016c777..102b240042f 100644
--- a/app/assets/javascripts/clusters/forms/show/index.js
+++ b/app/assets/javascripts/clusters/forms/show/index.js
@@ -1,9 +1,12 @@
import Vue from 'vue';
+import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import IntegrationForm from '../components/integration_form.vue';
import { createStore } from '../stores';
export default () => {
- const entryPoint = document.querySelector('#js-cluster-integration-form');
+ dirtySubmitFactory(document.querySelectorAll('.js-cluster-integrations-form'));
+
+ const entryPoint = document.querySelector('#js-cluster-details-form');
if (!entryPoint) {
return;
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 1dd815ae44d..2ff604af9a7 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -15,6 +15,7 @@ const {
UNINSTALL_ERRORED,
PRE_INSTALLED,
UNINSTALLED,
+ EXTERNALLY_INSTALLED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
@@ -71,6 +72,9 @@ const applicationStateMachine = {
[UNINSTALLED]: {
target: UNINSTALLED,
},
+ [EXTERNALLY_INSTALLED]: {
+ target: EXTERNALLY_INSTALLED,
+ },
},
},
[NOT_INSTALLABLE]: {
diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
index 1a396694bc8..9903a1bdb3e 100644
--- a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
+++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue
@@ -34,7 +34,7 @@ export default {
<gl-icon name="status_warning" :size="24" class="gl-p-2" />
- <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus">
+ <gl-popover :container="popoverId" :target="popoverId" placement="top">
<template #title>
<span class="gl-display-block gl-text-left">{{ errorContent.title }}</span>
</template>
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 2e050c066f1..6f496ffc6ae 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -5,8 +5,8 @@ import CommitPipelinesTable from './pipelines_table.vue';
* Used in:
* - Project Pipelines List (projects:pipelines:index)
* - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines)
- * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show)
- * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new)
+ * - Merge request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show)
+ * - New merge request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new)
*/
export default () => {
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index e1cca5adc73..ddca5bc7d4f 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,7 +1,6 @@
<script>
-import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
import { getParameterByName } from '~/lib/utils/common_utils';
-import SvgBlankState from '~/pipelines/components/pipelines_list/blank_state.vue';
import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import eventHub from '~/pipelines/event_hub';
import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin';
@@ -13,12 +12,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
GlButton,
+ GlEmptyState,
GlLink,
GlLoadingIcon,
GlModal,
PipelinesTableComponent,
TablePagination,
- SvgBlankState,
},
mixins: [PipelinesMixin, glFeatureFlagMixin()],
props: {
@@ -82,7 +81,7 @@ export default {
return this.hasError && !this.isLoading;
},
/**
- * The Run Pipeline button can only be rendered when:
+ * The "Run pipeline" button can only be rendered when:
* - In MR view - we use `canCreatePipelineInTargetProject` for that purpose
* - If the latest pipeline has the `detached_merge_request_pipeline` flag
*
@@ -91,9 +90,6 @@ export default {
canRenderPipelineButton() {
return this.latestPipelineDetachedFlag;
},
- pipelineButtonClass() {
- return !this.glFeatures.newPipelinesTable ? 'gl-md-display-none' : 'gl-lg-display-none';
- },
isForkMergeRequest() {
return this.sourceProjectFullPath !== this.targetProjectFullPath;
},
@@ -149,7 +145,7 @@ export default {
}
},
/**
- * When the user clicks on the Run Pipeline button
+ * When the user clicks on the "Run pipeline" button
* we need to make a post request and
* to update the table content once the request is finished.
*
@@ -178,17 +174,17 @@ export default {
<div class="content-list pipelines">
<gl-loading-icon
v-if="isLoading"
- :label="s__('Pipelines|Loading Pipelines')"
+ :label="s__('Pipelines|Loading pipelines')"
size="lg"
class="prepend-top-20"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="shouldRenderErrorState"
:svg-path="errorStateSvgPath"
- :message="
+ :title="
s__(`Pipelines|There was an error fetching the pipelines.
- Try again in a few moments or contact your support team.`)
+ Try again in a few moments or contact your support team.`)
"
/>
@@ -196,14 +192,13 @@ export default {
<gl-button
v-if="canRenderPipelineButton"
block
- class="gl-mt-3 gl-mb-3"
- :class="pipelineButtonClass"
- variant="success"
+ class="gl-mt-3 gl-mb-3 gl-lg-display-none"
+ variant="confirm"
data-testid="run_pipeline_button_mobile"
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipelines|Run Pipeline') }}
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
<pipelines-table-component
@@ -214,12 +209,12 @@ export default {
<template #table-header-actions>
<div v-if="canRenderPipelineButton" class="gl-text-right">
<gl-button
- variant="success"
+ variant="confirm"
data-testid="run_pipeline_button"
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipelines|Run Pipeline') }}
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
</div>
</template>
@@ -232,7 +227,7 @@ export default {
ref="modal"
:modal-id="modalId"
:title="s__('Pipelines|Are you sure you want to run this pipeline?')"
- :ok-title="s__('Pipelines|Run Pipeline')"
+ :ok-title="s__('Pipeline|Run pipeline')"
ok-variant="danger"
@ok="onClickRunPipeline"
>
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index 5cbe9a24fc4..da7fc88d8ac 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -10,7 +10,7 @@ export default class CommitsList {
this.$contentList = $('.content_list');
- Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this));
+ Pager.init({ limit: parseInt(limit, 10), prepareData: this.processCommits.bind(this) });
this.content = $('#commits-list');
this.searchField = $('#commits-search');
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
new file mode 100644
index 00000000000..839d4de912d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -0,0 +1,18 @@
+<script>
+import { EditorContent } from 'tiptap';
+import createEditor from '../services/create_editor';
+
+export default {
+ components: {
+ EditorContent,
+ },
+ data() {
+ return {
+ editor: createEditor(),
+ };
+ },
+};
+</script>
+<template>
+ <editor-content :editor="editor" />
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
new file mode 100644
index 00000000000..eb6deff434d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -0,0 +1,5 @@
+import { s__ } from '~/locale';
+
+export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
+ 'ContentEditor|You have to provide a renderMarkdown function or a custom serializer',
+);
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
new file mode 100644
index 00000000000..1d050ed208b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -0,0 +1,38 @@
+import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions';
+
+export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight {
+ get schema() {
+ const baseSchema = super.schema;
+
+ return {
+ ...baseSchema,
+ attrs: {
+ params: {
+ default: null,
+ },
+ },
+ parseDOM: [
+ {
+ tag: 'pre',
+ preserveWhitespace: 'full',
+ getAttrs: (node) => {
+ const code = node.querySelector('code');
+
+ if (!code) {
+ return null;
+ }
+
+ return {
+ /* `params` is the name of the attribute that
+ prosemirror-markdown uses to extract the language
+ of a codeblock.
+ https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62
+ */
+ params: code.getAttribute('lang'),
+ };
+ },
+ },
+ ],
+ };
+ }
+}
diff --git a/app/assets/javascripts/content_editor/index.js b/app/assets/javascripts/content_editor/index.js
new file mode 100644
index 00000000000..e6ef3965da1
--- /dev/null
+++ b/app/assets/javascripts/content_editor/index.js
@@ -0,0 +1,2 @@
+export { default as createEditor } from './services/create_editor';
+export { default as ContentEditor } from './components/content_editor.vue';
diff --git a/app/assets/javascripts/content_editor/services/create_editor.js b/app/assets/javascripts/content_editor/services/create_editor.js
new file mode 100644
index 00000000000..128d332b0a2
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/create_editor.js
@@ -0,0 +1,60 @@
+import { isFunction, isString } from 'lodash';
+import { Editor } from 'tiptap';
+import {
+ Bold,
+ Italic,
+ Code,
+ Link,
+ Image,
+ Heading,
+ Blockquote,
+ HorizontalRule,
+ BulletList,
+ OrderedList,
+ ListItem,
+} from 'tiptap-extensions';
+import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
+import CodeBlockHighlight from '../extensions/code_block_highlight';
+import createMarkdownSerializer from './markdown_serializer';
+
+const createEditor = async ({ content, renderMarkdown, serializer: customSerializer } = {}) => {
+ if (!customSerializer && !isFunction(renderMarkdown)) {
+ throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
+ }
+
+ const editor = new Editor({
+ extensions: [
+ new Bold(),
+ new Italic(),
+ new Code(),
+ new Link(),
+ new Image(),
+ new Heading({ levels: [1, 2, 3, 4, 5, 6] }),
+ new Blockquote(),
+ new HorizontalRule(),
+ new BulletList(),
+ new ListItem(),
+ new OrderedList(),
+ new CodeBlockHighlight(),
+ ],
+ });
+ const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown });
+
+ editor.setSerializedContent = async (serializedContent) => {
+ editor.setContent(
+ await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
+ );
+ };
+
+ editor.getSerializedContent = () => {
+ return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
+ };
+
+ if (isString(content)) {
+ await editor.setSerializedContent(content);
+ }
+
+ return editor;
+};
+
+export default createEditor;
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
new file mode 100644
index 00000000000..e3b5775e320
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -0,0 +1,73 @@
+import {
+ MarkdownSerializer as ProseMirrorMarkdownSerializer,
+ defaultMarkdownSerializer,
+} from 'prosemirror-markdown';
+import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+
+const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
+
+/**
+ * A markdown serializer converts arbitrary Markdown content
+ * into a ProseMirror document and viceversa. To convert Markdown
+ * into a ProseMirror document, the Markdown should be rendered.
+ *
+ * The client should provide a render function to allow flexibility
+ * on the desired rendering approach.
+ *
+ * @param {Function} params.render Render function
+ * that parses the Markdown and converts it into HTML.
+ * @returns a markdown serializer
+ */
+const create = ({ render = () => null }) => {
+ return {
+ /**
+ * Converts a Markdown string into a ProseMirror JSONDocument based
+ * on a ProseMirror schema.
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content An arbitrary markdown string
+ * @returns A ProseMirror JSONDocument
+ */
+ deserialize: async ({ schema, content }) => {
+ const html = await render(content);
+
+ if (!html) {
+ return null;
+ }
+
+ const parser = new DOMParser();
+ const {
+ body: { firstElementChild },
+ } = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
+ const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
+
+ return state.toJSON();
+ },
+
+ /**
+ * Converts a ProseMirror JSONDocument based
+ * on a ProseMirror schema into Markdown
+ * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
+ * the types of content supported in the document
+ * @param {String} params.content A ProseMirror JSONDocument
+ * @returns A Markdown string
+ */
+ serialize: ({ schema, content }) => {
+ const document = schema.nodeFromJSON(content);
+ const serializer = new ProseMirrorMarkdownSerializer(defaultMarkdownSerializer.nodes, {
+ ...defaultMarkdownSerializer.marks,
+ bold: {
+ // creates a bold alias for the strong mark converter
+ ...defaultMarkdownSerializer.marks.strong,
+ },
+ italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
+ });
+
+ return serializer.serialize(document, {
+ tightLists: true,
+ });
+ },
+ };
+};
+
+export default create;
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
index 7426e570864..25ce6500094 100644
--- a/app/assets/javascripts/contributors/components/contributors.vue
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -201,11 +201,12 @@ export default {
</div>
<div v-else-if="showChart" class="contributors-charts">
- <h4>{{ __('Commits to') }} {{ branch }}</h4>
+ <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4>
<span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
<resizable-chart-container>
<gl-area-chart
slot-scope="{ width }"
+ class="gl-mb-5"
:width="width"
:data="masterChartData"
:option="masterChartOptions"
@@ -218,10 +219,12 @@ export default {
<div
v-for="(contributor, index) in individualChartsData"
:key="index"
- class="col-lg-6 col-12"
+ class="col-lg-6 col-12 gl-my-5"
>
- <h4>{{ contributor.name }}</h4>
- <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p>
+ <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4>
+ <p class="gl-mb-3">
+ {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})
+ </p>
<resizable-chart-container>
<gl-area-chart
slot-scope="{ width }"
diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js
index b6063589734..f66133a074d 100644
--- a/app/assets/javascripts/contributors/index.js
+++ b/app/assets/javascripts/contributors/index.js
@@ -1,12 +1,15 @@
import Vue from 'vue';
import ContributorsGraphs from './components/contributors.vue';
-import store from './stores';
+import { createStore } from './stores';
export default () => {
const el = document.querySelector('.js-contributors-graph');
if (!el) return null;
+ const { projectGraphPath, projectBranch, defaultBranch } = el.dataset;
+ const store = createStore(defaultBranch);
+
return new Vue({
el,
store,
@@ -14,8 +17,8 @@ export default () => {
render(createElement) {
return createElement(ContributorsGraphs, {
props: {
- endpoint: el.dataset.projectGraphPath,
- branch: el.dataset.projectBranch,
+ endpoint: projectGraphPath,
+ branch: projectBranch,
},
});
},
diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js
index 38259f46d4c..a4d0004cee5 100644
--- a/app/assets/javascripts/contributors/stores/index.js
+++ b/app/assets/javascripts/contributors/stores/index.js
@@ -7,12 +7,12 @@ import state from './state';
Vue.use(Vuex);
-export const createStore = () =>
+export const createStore = (defaultBranch) =>
new Vuex.Store({
actions,
mutations,
getters,
- state: state(),
+ state: state(defaultBranch),
});
-export default createStore();
+export default createStore;
diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js
index 1dc1a3c7b75..9c6b993e5cb 100644
--- a/app/assets/javascripts/contributors/stores/state.js
+++ b/app/assets/javascripts/contributors/stores/state.js
@@ -1,5 +1,5 @@
-export default () => ({
+export default (branch) => ({
loading: false,
chartData: null,
- branch: 'master',
+ branch,
});
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 35176c19f69..000faacb7d7 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-new */
import { debounce } from 'lodash';
import {
init as initConfidentialMergeRequest,
@@ -8,7 +7,7 @@ import {
import confidentialMergeRequestState from './confidential_merge_request/state';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { __, sprintf } from './locale';
@@ -36,6 +35,7 @@ export default class CreateMergeRequestDropdown {
this.branchInput = this.wrapperEl.querySelector('.js-branch-name');
this.branchMessage = this.wrapperEl.querySelector('.js-branch-message');
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
+ this.createMergeRequestLoading = this.createMergeRequestButton.querySelector('.js-spinner');
this.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
@@ -132,7 +132,9 @@ export default class CreateMergeRequestDropdown {
.catch(() => {
this.unavailable();
this.disable();
- Flash(__('Failed to check related branches.'));
+ createFlash({
+ message: __('Failed to check related branches.'),
+ });
});
}
@@ -147,7 +149,11 @@ export default class CreateMergeRequestDropdown {
this.branchCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash(__('Failed to create a branch for this issue. Please try again.')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to create a branch for this issue. Please try again.'),
+ }),
+ );
}
createMergeRequest() {
@@ -163,13 +169,21 @@ export default class CreateMergeRequestDropdown {
this.mergeRequestCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash(__('Failed to create Merge Request. Please try again.')));
+ .catch(() =>
+ createFlash({
+ message: __('Failed to create merge request. Please try again.'),
+ }),
+ );
}
disable() {
this.disableCreateAction();
}
+ setLoading(loading) {
+ this.createMergeRequestLoading.classList.toggle('gl-display-none', !loading);
+ }
+
disableCreateAction() {
this.createMergeRequestButton.classList.add('disabled');
this.createMergeRequestButton.setAttribute('disabled', 'disabled');
@@ -256,7 +270,9 @@ export default class CreateMergeRequestDropdown {
.catch(() => {
this.unavailable();
this.disable();
- new Flash(__('Failed to get ref.'));
+ createFlash({
+ message: __('Failed to get ref.'),
+ });
this.isGettingRef = false;
@@ -376,8 +392,10 @@ export default class CreateMergeRequestDropdown {
this.isCreatingBranch = false;
this.enable();
+ this.setLoading(false);
});
+ this.setLoading(true);
this.disable();
}
diff --git a/app/assets/javascripts/delete_label_modal.js b/app/assets/javascripts/delete_label_modal.js
new file mode 100644
index 00000000000..cf7c9e7734f
--- /dev/null
+++ b/app/assets/javascripts/delete_label_modal.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue';
+
+const mountDeleteLabelModal = (optionalProps) =>
+ new Vue({
+ render(h) {
+ return h(DeleteLabelModal, {
+ props: {
+ selector: '.js-delete-label-modal-button',
+ ...optionalProps,
+ },
+ });
+ },
+ }).$mount();
+
+export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps);
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 d05a0761ae3..051ab710e5f 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -18,7 +18,6 @@ export default {
modalOptions: {
ref: 'modal',
modalId: 'deploy-freeze-modal',
- title: __('Add deploy freeze'),
actionCancel: {
text: __('Cancel'),
},
@@ -30,10 +29,13 @@ export default {
cronSyntaxInstructions: __(
'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
),
+ addTitle: __('Add deploy freeze'),
+ editTitle: __('Edit deploy freeze'),
},
computed: {
...mapState([
'projectId',
+ 'selectedId',
'selectedTimezone',
'timezoneData',
'freezeStartCron',
@@ -45,9 +47,9 @@ export default {
]),
addDeployFreezeButton() {
return {
- text: __('Add deploy freeze'),
+ text: this.isEditing ? __('Save deploy freeze') : __('Add deploy freeze'),
attributes: [
- { variant: 'success' },
+ { variant: 'confirm' },
{
disabled:
!isValidCron(this.freezeStartCron) ||
@@ -77,9 +79,17 @@ export default {
this.setSelectedTimezone(selectedTimezone);
},
},
+ isEditing() {
+ return Boolean(this.selectedId);
+ },
+ modalTitle() {
+ return this.isEditing
+ ? this.$options.translations.editTitle
+ : this.$options.translations.addTitle;
+ },
},
methods: {
- ...mapActions(['addFreezePeriod', 'setSelectedTimezone', 'resetModal']),
+ ...mapActions(['addFreezePeriod', 'updateFreezePeriod', 'setSelectedTimezone', 'resetModal']),
resetModalHandler() {
this.resetModal();
},
@@ -89,6 +99,13 @@ export default {
}
return '';
},
+ submit() {
+ if (this.isEditing) {
+ this.updateFreezePeriod();
+ } else {
+ this.addFreezePeriod();
+ }
+ },
},
};
</script>
@@ -96,8 +113,9 @@ export default {
<template>
<gl-modal
v-bind="$options.modalOptions"
+ :title="modalTitle"
:action-primary="addDeployFreezeButton"
- @primary="addFreezePeriod"
+ @primary="submit"
@canceled="resetModalHandler"
>
<p>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
index 0d6657973c3..8282f1d910a 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -1,7 +1,7 @@
<script>
import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { s__, __ } from '~/locale';
+import { s__ } from '~/locale';
export default {
fields: [
@@ -17,9 +17,16 @@ export default {
key: 'cronTimezone',
label: s__('DeployFreeze|Time zone'),
},
+ {
+ key: 'edit',
+ label: s__('DeployFreeze|Edit'),
+ },
],
translations: {
- addDeployFreeze: __('Add deploy freeze'),
+ addDeployFreeze: s__('DeployFreeze|Add deploy freeze'),
+ emptyStateText: s__(
+ 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}',
+ ),
},
components: {
GlTable,
@@ -39,7 +46,7 @@ export default {
this.fetchFreezePeriods();
},
methods: {
- ...mapActions(['fetchFreezePeriods']),
+ ...mapActions(['fetchFreezePeriods', 'setFreezePeriod']),
},
};
</script>
@@ -53,15 +60,21 @@ export default {
show-empty
stacked="lg"
>
+ <template #cell(cronTimezone)="{ item }">
+ {{ item.cronTimezone.formattedTimezone }}
+ </template>
+ <template #cell(edit)="{ item }">
+ <gl-button
+ v-gl-modal.deploy-freeze-modal
+ icon="pencil"
+ data-testid="edit-deploy-freeze"
+ :aria-label="__('Edit deploy freeze')"
+ @click="setFreezePeriod(item)"
+ />
+ </template>
<template #empty>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
- <gl-sprintf
- :message="
- s__(
- 'DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}',
- )
- "
- >
+ <gl-sprintf :message="$options.translations.emptyStateText">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
@@ -73,7 +86,7 @@ export default {
v-gl-modal.deploy-freeze-modal
data-testid="add-deploy-freeze"
category="primary"
- variant="success"
+ variant="confirm"
>
{{ $options.translations.addDeployFreeze }}
</gl-button>
diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js
index 62045d2517d..56e45595dc5 100644
--- a/app/assets/javascripts/deploy_freeze/store/actions.js
+++ b/app/assets/javascripts/deploy_freeze/store/actions.js
@@ -3,37 +3,53 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types';
-export const requestAddFreezePeriod = ({ commit }) => {
+export const requestFreezePeriod = ({ commit }) => {
commit(types.REQUEST_ADD_FREEZE_PERIOD);
};
-export const receiveAddFreezePeriodSuccess = ({ commit }) => {
+export const receiveFreezePeriodSuccess = ({ commit }) => {
commit(types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS);
};
-export const receiveAddFreezePeriodError = ({ commit }, error) => {
+export const receiveFreezePeriodError = ({ commit }, error) => {
commit(types.RECEIVE_ADD_FREEZE_PERIOD_ERROR, error);
};
-export const addFreezePeriod = ({ state, dispatch, commit }) => {
- dispatch('requestAddFreezePeriod');
+const receiveFreezePeriod = (store, request) => {
+ const { dispatch, commit } = store;
+ dispatch('requestFreezePeriod');
- return Api.createFreezePeriod(state.projectId, {
- freeze_start: state.freezeStartCron,
- freeze_end: state.freezeEndCron,
- cron_timezone: state.selectedTimezoneIdentifier,
- })
+ request(store)
.then(() => {
- dispatch('receiveAddFreezePeriodSuccess');
+ dispatch('receiveFreezePeriodSuccess');
commit(types.RESET_MODAL);
dispatch('fetchFreezePeriods');
})
.catch((error) => {
createFlash(__('Error: Unable to create deploy freeze'));
- dispatch('receiveAddFreezePeriodError', error);
+ dispatch('receiveFreezePeriodError', error);
});
};
+export const addFreezePeriod = (store) =>
+ receiveFreezePeriod(store, ({ state }) =>
+ Api.createFreezePeriod(state.projectId, {
+ freeze_start: state.freezeStartCron,
+ freeze_end: state.freezeEndCron,
+ cron_timezone: state.selectedTimezoneIdentifier,
+ }),
+ );
+
+export const updateFreezePeriod = (store) =>
+ receiveFreezePeriod(store, ({ state }) =>
+ Api.updateFreezePeriod(state.projectId, {
+ id: state.selectedId,
+ freeze_start: state.freezeStartCron,
+ freeze_end: state.freezeEndCron,
+ cron_timezone: state.selectedTimezoneIdentifier,
+ }),
+ );
+
export const fetchFreezePeriods = ({ commit, state }) => {
commit(types.REQUEST_FREEZE_PERIODS);
@@ -46,6 +62,13 @@ export const fetchFreezePeriods = ({ commit, state }) => {
});
};
+export const setFreezePeriod = ({ commit }, freezePeriod) => {
+ commit(types.SET_SELECTED_ID, freezePeriod.id);
+ commit(types.SET_SELECTED_TIMEZONE, freezePeriod.cronTimezone);
+ commit(types.SET_FREEZE_START_CRON, freezePeriod.freezeStart);
+ commit(types.SET_FREEZE_END_CRON, freezePeriod.freezeEnd);
+};
+
export const setSelectedTimezone = ({ commit }, timezone) => {
commit(types.SET_SELECTED_TIMEZONE, timezone);
};
diff --git a/app/assets/javascripts/deploy_freeze/store/mutation_types.js b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
index 47a4874a5cf..8e6fdfd4443 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutation_types.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
@@ -6,6 +6,7 @@ export const RECEIVE_ADD_FREEZE_PERIOD_SUCCESS = 'RECEIVE_ADD_FREEZE_PERIOD_SUCC
export const RECEIVE_ADD_FREEZE_PERIOD_ERROR = 'RECEIVE_ADD_FREEZE_PERIOD_ERROR';
export const SET_SELECTED_TIMEZONE = 'SET_SELECTED_TIMEZONE';
+export const SET_SELECTED_ID = 'SET_SELECTED_ID';
export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js
index 3b34f3950e6..e62000c007c 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutations.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutations.js
@@ -4,7 +4,11 @@ import * as types from './mutation_types';
const formatTimezoneName = (freezePeriod, timezoneList) =>
convertObjectPropsToCamelCase({
...freezePeriod,
- cron_timezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)?.name,
+ cron_timezone: {
+ formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)
+ ?.name,
+ identifier: freezePeriod.cronTimezone,
+ },
});
export default {
@@ -45,10 +49,15 @@ export default {
state.freezeEndCron = freezeEndCron;
},
+ [types.SET_SELECTED_ID](state, id) {
+ state.selectedId = id;
+ },
+
[types.RESET_MODAL](state) {
state.freezeStartCron = '';
state.freezeEndCron = '';
state.selectedTimezone = '';
state.selectedTimezoneIdentifier = '';
+ state.selectedId = '';
},
};
diff --git a/app/assets/javascripts/deploy_freeze/store/state.js b/app/assets/javascripts/deploy_freeze/store/state.js
index 4cc38c097b6..1b16b4c645b 100644
--- a/app/assets/javascripts/deploy_freeze/store/state.js
+++ b/app/assets/javascripts/deploy_freeze/store/state.js
@@ -6,6 +6,7 @@ export default ({
selectedTimezoneIdentifier = '',
freezeStartCron = '',
freezeEndCron = '',
+ selectedId = '',
}) => ({
projectId,
freezePeriods,
@@ -14,4 +15,5 @@ export default ({
selectedTimezoneIdentifier,
freezeStartCron,
freezeEndCron,
+ selectedId,
});
diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
new file mode 100644
index 00000000000..e026391ae22
--- /dev/null
+++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: {
+ token: {
+ default: null,
+ },
+ revokePath: {
+ default: '',
+ },
+ buttonClass: {
+ default: '',
+ },
+ },
+ computed: {
+ modalId() {
+ return `revoke-modal-${this.token.id}`;
+ },
+ },
+ methods: {
+ cancelHandler() {
+ this.$refs.modal.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ v-gl-modal="modalId"
+ :class="buttonClass"
+ category="primary"
+ variant="danger"
+ class="float-right"
+ data-testid="revoke-button"
+ >{{ s__('DeployTokens|Revoke') }}</gl-button
+ >
+ <gl-modal ref="modal" :modal-id="modalId">
+ <template #modal-title>
+ <gl-sprintf :message="s__(`DeployTokens|Revoke %{boldStart}${token.name}%{boldEnd}?`)">
+ <template #bold="{ content }"
+ ><b>{{ content }}</b></template
+ >
+ </gl-sprintf>
+ </template>
+ <gl-sprintf
+ :message="s__(`DeployTokens|You are about to revoke %{boldStart}${token.name}%{boldEnd}.`)"
+ >
+ <template #bold="{ content }">
+ <b>{{ content }}</b>
+ </template>
+ </gl-sprintf>
+ {{ s__('DeployTokens|This action cannot be undone.') }}
+ <template #modal-footer>
+ <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button>
+ <gl-button
+ category="primary"
+ variant="danger"
+ :href="revokePath"
+ data-method="put"
+ class="text-truncate"
+ data-testid="primary-revoke-btn"
+ >
+ <gl-sprintf :message="s__('DeployTokens|Revoke %{name}')">
+ <template #name>{{ token.name }}</template>
+ </gl-sprintf>
+ </gl-button>
+ </template>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_tokens/init_revoke_button.js b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
new file mode 100644
index 00000000000..20187150a60
--- /dev/null
+++ b/app/assets/javascripts/deploy_tokens/init_revoke_button.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import RevokeButton from './components/revoke_button.vue';
+
+export default () => {
+ const containers = document.querySelectorAll('.js-deploy-token-revoke-button');
+
+ if (!containers.length) {
+ return false;
+ }
+
+ return containers.forEach((el) => {
+ const { token, revokePath, buttonClass } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ token: JSON.parse(token),
+ revokePath,
+ buttonClass,
+ },
+ render(h) {
+ return h(RevokeButton);
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
index b1d486c5d66..8ca4dc587a8 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js
@@ -2,6 +2,7 @@
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import $ from 'jquery';
+import { debounce } from 'lodash';
import { isObject } from '~/lib/utils/type_utility';
const BLUR_KEYCODES = [27, 40];
@@ -11,13 +12,21 @@ const HAS_VALUE_CLASS = 'has-value';
export class GitLabDropdownFilter {
constructor(input, options) {
let ref;
- let timeout;
this.input = input;
this.options = options;
// eslint-disable-next-line no-cond-assign
this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
const $inputContainer = this.input.parent();
const $clearButton = $inputContainer.find('.js-dropdown-input-clear');
+ const filterRemoteDebounced = debounce(() => {
+ $inputContainer.parent().addClass('is-loading');
+
+ return this.options.query(this.input.val(), (data) => {
+ $inputContainer.parent().removeClass('is-loading');
+ return this.options.callback(data);
+ });
+ }, 500);
+
$clearButton.on('click', (e) => {
// Clear click
e.preventDefault();
@@ -25,7 +34,6 @@ export class GitLabDropdownFilter {
return this.input.val('').trigger('input').focus();
});
// Key events
- timeout = '';
this.input
.on('keydown', (e) => {
const keyCode = e.which;
@@ -41,16 +49,7 @@ export class GitLabDropdownFilter {
}
// Only filter asynchronously only if option remote is set
if (this.options.remote) {
- clearTimeout(timeout);
- // eslint-disable-next-line no-return-assign
- return (timeout = setTimeout(() => {
- $inputContainer.parent().addClass('is-loading');
-
- return this.options.query(this.input.val(), (data) => {
- $inputContainer.parent().removeClass('is-loading');
- return this.options.callback(data);
- });
- }, 250));
+ return filterRemoteDebounced();
}
return this.filter(this.input.val());
});
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index fbcce22ec1e..ae2ce7c3e5e 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -63,7 +63,7 @@ export default {
title: s__('DesignManagement|Are you sure you want to archive the selected designs?'),
actionPrimary: {
text: s__('DesignManagement|Archive designs'),
- attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' },
+ attributes: { variant: 'confirm', 'data-qa-selector': 'confirm_archiving_button' },
},
actionCancel: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 2b867217327..833d7081a2c 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
+import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -10,6 +11,9 @@ import { findNoteId, extractDesignNoteId } from '../../utils/design_management_u
import DesignReplyForm from './design_reply_form.vue';
export default {
+ i18n: {
+ editCommentLabel: __('Edit comment'),
+ },
components: {
UserAvatarLink,
TimelineEntryItem,
@@ -113,7 +117,8 @@ export default {
v-if="isEditButtonVisible"
v-gl-tooltip
type="button"
- :title="__('Edit comment')"
+ :title="$options.i18n.editCommentLabel"
+ :aria-label="$options.i18n.editCommentLabel"
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="isEditing = true"
>
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
index 85c6bd4d79e..c9273f97bed 100644
--- a/app/assets/javascripts/design_management/components/design_scaler.vue
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -51,8 +51,18 @@ export default {
<template>
<gl-button-group class="gl-z-index-1">
- <gl-button icon="dash" :disabled="disableDecrease" @click="decrementScale" />
- <gl-button icon="redo" :disabled="disableReset" @click="resetScale" />
- <gl-button icon="plus" :disabled="disableIncrease" @click="incrementScale" />
+ <gl-button
+ icon="dash"
+ :disabled="disableDecrease"
+ :aria-label="__('Decrease')"
+ @click="decrementScale"
+ />
+ <gl-button icon="redo" :disabled="disableReset" :aria-label="__('Reset')" @click="resetScale" />
+ <gl-button
+ icon="plus"
+ :disabled="disableIncrease"
+ :aria-label="__('Increase')"
+ @click="incrementScale"
+ />
</gl-button-group>
</template>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 2169c9111d2..b6163491abc 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -137,8 +137,7 @@ export default {
<span :title="icon.tooltip" :aria-label="icon.tooltip">
<gl-icon
:name="icon.name"
- :size="18"
- use-deprecated-sizes
+ :size="16"
:class="icon.classes"
data-qa-selector="design_status_icon"
:data-qa-status="icon.name"
diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
index 6091a3183ac..3ebcde817f9 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -2,11 +2,20 @@
/* global Mousetrap */
import 'mousetrap';
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui';
+import {
+ keysFor,
+ ISSUE_PREVIOUS_DESIGN,
+ ISSUE_NEXT_DESIGN,
+} from '~/behaviors/shortcuts/keybindings';
import { s__, sprintf } from '~/locale';
import allDesignsMixin from '../../mixins/all_designs';
import { DESIGN_ROUTE_NAME } from '../../router/constants';
export default {
+ i18n: {
+ nextButton: s__('DesignManagement|Go to next design'),
+ previousButton: s__('DesignManagement|Go to previous design'),
+ },
components: {
GlButton,
GlButtonGroup,
@@ -46,11 +55,14 @@ export default {
},
},
mounted() {
- Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
- Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
+ Mousetrap.bind(keysFor(ISSUE_PREVIOUS_DESIGN), () =>
+ this.navigateToDesign(this.previousDesign),
+ );
+ Mousetrap.bind(keysFor(ISSUE_NEXT_DESIGN), () => this.navigateToDesign(this.nextDesign));
},
beforeDestroy() {
- Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
+ Mousetrap.unbind(keysFor(ISSUE_PREVIOUS_DESIGN));
+ Mousetrap.unbind(keysFor(ISSUE_NEXT_DESIGN));
},
methods: {
navigateToDesign(design) {
@@ -73,7 +85,8 @@ export default {
<gl-button
v-gl-tooltip.bottom
:disabled="!previousDesign"
- :title="s__('DesignManagement|Go to previous design')"
+ :title="$options.i18n.previousButton"
+ :aria-label="$options.i18n.previousButton"
icon="angle-left"
class="js-previous-design"
@click="navigateToDesign(previousDesign)"
@@ -81,7 +94,8 @@ export default {
<gl-button
v-gl-tooltip.bottom
:disabled="!nextDesign"
- :title="s__('DesignManagement|Go to next design')"
+ :title="$options.i18n.nextButton"
+ :aria-label="$options.i18n.nextButton"
icon="angle-right"
class="js-next-design"
@click="navigateToDesign(nextDesign)"
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index 8abf1529f3c..b84fe45b77e 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -1,13 +1,16 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
-import { __, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
import DeleteButton from '../delete_button.vue';
import DesignNavigation from './design_navigation.vue';
export default {
+ i18n: {
+ downloadButtonLabel: s__('DesignManagement|Download design'),
+ },
components: {
GlButton,
GlIcon,
@@ -119,7 +122,8 @@ export default {
v-gl-tooltip.bottom
:href="image"
icon="download"
- :title="s__('DesignManagement|Download design')"
+ :title="$options.i18n.downloadButtonLabel"
+ :aria-label="$options.i18n.downloadButtonLabel"
/>
<delete-button
v-if="isLatestVersion && canDeleteDesign"
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
index 394ccb3c483..98b7ab5c094 100644
--- a/app/assets/javascripts/design_management/components/upload/button.vue
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -38,7 +38,8 @@ export default {
"
:disabled="isSaving"
:loading="isSaving"
- variant="default"
+ category="secondary"
+ variant="confirm"
size="small"
@click="openFileUpload"
>
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
index f0930ade1b5..aa9f377ef16 100644
--- a/app/assets/javascripts/design_management/index.js
+++ b/app/assets/javascripts/design_management/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import App from './components/app.vue';
import apolloProvider from './graphql';
+import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
import createRouter from './router';
export default () => {
@@ -8,7 +9,8 @@ export default () => {
const { issueIid, projectPath, issuePath } = el.dataset;
const router = createRouter(issuePath);
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: activeDiscussionQuery,
data: {
activeDiscussion: {
__typename: 'ActiveDiscussion',
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 8a11c25a795..ad78433c7ce 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { ApolloMutation } from 'vue-apollo';
+import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -171,7 +172,7 @@ export default {
},
},
mounted() {
- Mousetrap.bind('esc', this.closeDesign);
+ Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign);
this.trackPageViewEvent();
// Set active discussion immediately.
@@ -180,7 +181,7 @@ export default {
this.updateActiveDiscussionFromUrl();
},
beforeDestroy() {
- Mousetrap.unbind('esc', this.closeDesign);
+ Mousetrap.unbind(keysFor(ISSUE_CLOSE_DESIGN));
},
methods: {
addImageDiffNoteToStore(store, { data: { createImageDiffNote } }) {
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 99ac38fc554..04d80dc0069 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -379,8 +379,7 @@ export default {
<delete-button
v-if="isLatestVersion"
:is-deleting="loading"
- button-variant="warning"
- button-category="secondary"
+ button-variant="default"
button-class="gl-mr-3"
button-size="small"
data-qa-selector="archive_button"
@@ -485,9 +484,7 @@ export default {
<template #upload-text="{ openFileUpload }">
<gl-sprintf :message="$options.i18n.dropzoneDescriptionText">
<template #link="{ content }">
- <gl-link @click.stop="openFileUpload">
- {{ content }}
- </gl-link>
+ <gl-link @click.stop="openFileUpload">{{ content }}</gl-link>
</template>
</gl-sprintf>
</template>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 253e1e3b70e..7c610968209 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -3,6 +3,13 @@ import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Mousetrap from 'mousetrap';
import { mapState, mapGetters, mapActions } from 'vuex';
+import {
+ keysFor,
+ MR_PREVIOUS_FILE_IN_DIFF,
+ MR_NEXT_FILE_IN_DIFF,
+ MR_COMMITS_NEXT_COMMIT,
+ MR_COMMITS_PREVIOUS_COMMIT,
+} from '~/behaviors/shortcuts/keybindings';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
@@ -77,6 +84,16 @@ export default {
required: false,
default: '',
},
+ endpointCodequality: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ endpointUpdateUser: {
+ type: String,
+ required: false,
+ default: '',
+ },
projectPath: {
type: String,
required: true,
@@ -153,6 +170,7 @@ export default {
plainDiffPath: (state) => state.diffs.plainDiffPath,
emailPatchPath: (state) => state.diffs.emailPatchPath,
retrievingBatches: (state) => state.diffs.retrievingBatches,
+ codequalityDiff: (state) => state.diffs.codequalityDiff,
}),
...mapState('diffs', [
'showTreeList',
@@ -167,6 +185,7 @@ export default {
'mrReviews',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
+ ...mapGetters('batchComments', ['draftsCount']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
if (!this.viewDiffsFileByFile) {
@@ -264,6 +283,7 @@ export default {
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
+ endpointUpdateUser: this.endpointUpdateUser,
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
@@ -272,6 +292,10 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
+ if (this.endpointCodequality) {
+ this.setCodequalityEndpoint(this.endpointCodequality);
+ }
+
if (this.shouldShow) {
this.fetchData();
}
@@ -316,9 +340,11 @@ export default {
...mapActions('diffs', [
'moveToNeighboringCommit',
'setBaseConfig',
+ 'setCodequalityEndpoint',
'fetchDiffFilesMeta',
'fetchDiffFilesBatch',
'fetchCoverageFiles',
+ 'fetchCodequality',
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
@@ -342,14 +368,6 @@ export default {
refetchDiffData() {
this.fetchData(false);
},
- startDiffRendering() {
- requestIdleCallback(
- () => {
- this.startRenderDiffsQueue();
- },
- { timeout: 1000 },
- );
- },
needsReload() {
return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]);
},
@@ -361,8 +379,6 @@ export default {
.then(({ real_size }) => {
this.diffFilesLength = parseInt(real_size, 10);
if (toggleTree) this.setTreeDisplay();
-
- this.startDiffRendering();
})
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
@@ -377,7 +393,6 @@ export default {
// change when loading the other half of the diff files.
this.setDiscussions();
})
- .then(() => this.startDiffRendering())
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
});
@@ -386,6 +401,10 @@ export default {
this.fetchCoverageFiles();
}
+ if (this.endpointCodequality) {
+ this.fetchCodequality();
+ }
+
if (!this.isNotesFetched) {
notesEventHub.$emit('fetchNotesData');
}
@@ -406,30 +425,23 @@ export default {
}
},
setEventListeners() {
- Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => {
- switch (combo) {
- case '[':
- case 'k':
- this.jumpToFile(-1);
- break;
- case ']':
- case 'j':
- this.jumpToFile(+1);
- break;
- default:
- break;
- }
- });
+ Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1));
+ Mousetrap.bind(keysFor(MR_NEXT_FILE_IN_DIFF), () => this.jumpToFile(+1));
if (this.commit) {
- Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' }));
- Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' }));
+ Mousetrap.bind(keysFor(MR_COMMITS_NEXT_COMMIT), () =>
+ this.moveToNeighboringCommit({ direction: 'next' }),
+ );
+ Mousetrap.bind(keysFor(MR_COMMITS_PREVIOUS_COMMIT), () =>
+ this.moveToNeighboringCommit({ direction: 'previous' }),
+ );
}
},
removeEventListeners() {
- Mousetrap.unbind(['[', 'k', ']', 'j']);
- Mousetrap.unbind('c');
- Mousetrap.unbind('x');
+ Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF));
+ Mousetrap.unbind(keysFor(MR_NEXT_FILE_IN_DIFF));
+ Mousetrap.unbind(keysFor(MR_COMMITS_NEXT_COMMIT));
+ Mousetrap.unbind(keysFor(MR_COMMITS_PREVIOUS_COMMIT));
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
@@ -489,6 +501,7 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
+ :class="{ 'review-bar-visible': draftsCount > 0 }"
class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
>
<panel-resizer
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 92b317eb3f0..bc0f2fb0b69 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,7 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { mapActions } from 'vuex';
+import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -9,7 +8,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { setUrlParams } from '../../lib/utils/url_utility';
import initUserPopovers from '../../user_popovers';
/**
@@ -24,14 +22,6 @@ import initUserPopovers from '../../user_popovers';
* coexist, but there is an issue to remove the duplication.
* https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
*
- * EXCEPTION WARNING
- * 1. The commit navigation buttons (next neighbor, previous neighbor)
- * are not duplicated because:
- * - We don't have the same data available on the Rails side (yet,
- * without backend work)
- * - This Vue component should always be what's used when in the
- * context of an MR diff, so the HAML should never have any idea
- * about navigating among commits.
*/
export default {
@@ -42,7 +32,6 @@ export default {
CommitPipelineStatus,
GlButtonGroup,
GlButton,
- GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -94,28 +83,12 @@ export default {
// Strip the newline at the beginning
return this.commit.description_html.replace(/^&#x000A;/, '');
},
- nextCommitUrl() {
- return this.commit.next_commit_id
- ? setUrlParams({ commit_id: this.commit.next_commit_id })
- : '';
- },
- previousCommitUrl() {
- return this.commit.prev_commit_id
- ? setUrlParams({ commit_id: this.commit.prev_commit_id })
- : '';
- },
- hasNeighborCommits() {
- return this.commit.next_commit_id || this.commit.prev_commit_id;
- },
},
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
- methods: {
- ...mapActions('diffs', ['moveToNeighboringCommit']),
- },
};
</script>
@@ -146,38 +119,6 @@ export default {
class="input-group-text"
/>
</gl-button-group>
- <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3">
- <gl-button-group>
- <gl-button
- :href="previousCommitUrl"
- :disabled="!commit.prev_commit_id"
- @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
- >
- <span
- v-if="!commit.prev_commit_id"
- v-gl-tooltip
- class="h-100 w-100 position-absolute"
- :title="__('You\'re at the first commit')"
- ></span>
- <gl-icon name="chevron-left" />
- {{ __('Prev') }}
- </gl-button>
- <gl-button
- :href="nextCommitUrl"
- :disabled="!commit.next_commit_id"
- @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
- >
- <span
- v-if="!commit.next_commit_id"
- v-gl-tooltip
- class="h-100 w-100 position-absolute"
- :title="__('You\'re at the last commit')"
- ></span>
- {{ __('Next') }}
- <gl-icon name="chevron-right" />
- </gl-button>
- </gl-button-group>
- </div>
</div>
<div>
<div class="d-flex float-left align-items-center align-self-start">
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 6b1e2bfb34e..7526c5347f7 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -1,7 +1,8 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
+import { setUrlParams } from '../../lib/utils/url_utility';
import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants';
import eventHub from '../event_hub';
import CompareDropdownLayout from './compare_dropdown_layout.vue';
@@ -11,7 +12,9 @@ import SettingsDropdown from './settings_dropdown.vue';
export default {
components: {
CompareDropdownLayout,
+ GlIcon,
GlLink,
+ GlButtonGroup,
GlButton,
GlSprintf,
SettingsDropdown,
@@ -56,6 +59,19 @@ export default {
hasSourceVersions() {
return this.diffCompareDropdownSourceVersions.length > 0;
},
+ nextCommitUrl() {
+ return this.commit.next_commit_id
+ ? setUrlParams({ commit_id: this.commit.next_commit_id })
+ : '';
+ },
+ previousCommitUrl() {
+ return this.commit.prev_commit_id
+ ? setUrlParams({ commit_id: this.commit.prev_commit_id })
+ : '';
+ },
+ hasNeighborCommits() {
+ return this.commit && (this.commit.next_commit_id || this.commit.prev_commit_id);
+ },
},
created() {
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
@@ -65,6 +81,7 @@ export default {
expandAllFiles() {
eventHub.$emit(EVT_EXPAND_ALL_FILES);
},
+ ...mapActions('diffs', ['moveToNeighboringCommit']),
},
};
</script>
@@ -84,6 +101,7 @@ export default {
icon="file-tree"
class="gl-mr-3 js-toggle-tree-list"
:title="toggleFileBrowserTitle"
+ :aria-label="toggleFileBrowserTitle"
:selected="showTreeList"
@click="setShowTreeList({ showTreeList: !showTreeList })"
/>
@@ -91,6 +109,38 @@ export default {
{{ __('Viewing commit') }}
<gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link>
</div>
+ <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3">
+ <gl-button-group>
+ <gl-button
+ :href="previousCommitUrl"
+ :disabled="!commit.prev_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
+ >
+ <span
+ v-if="!commit.prev_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute position-top-0 position-left-0"
+ :title="__('You\'re at the first commit')"
+ ></span>
+ <gl-icon name="chevron-left" />
+ {{ __('Prev') }}
+ </gl-button>
+ <gl-button
+ :href="nextCommitUrl"
+ :disabled="!commit.next_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
+ >
+ <span
+ v-if="!commit.next_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute position-top-0 position-left-0"
+ :title="__('You\'re at the last commit')"
+ ></span>
+ {{ __('Next') }}
+ <gl-icon name="chevron-right" />
+ </gl-button>
+ </gl-button-group>
+ </div>
<gl-sprintf
v-else-if="hasSourceVersions"
class="d-flex align-items-center compare-versions-container"
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index d0d457d8582..5e05ec87f84 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -68,6 +68,7 @@ export default {
}"
type="button"
class="js-diff-notes-toggle"
+ :aria-label="__('Show comments')"
@click="toggleDiscussion({ discussionId: discussion.id })"
>
<gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" />
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index ca4543f7002..bdbc13a38c4 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -80,7 +80,7 @@ export default {
genericError: GENERIC_ERROR,
},
computed: {
- ...mapState('diffs', ['currentDiffFileId']),
+ ...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']),
...mapGetters(['isNotesFetched']),
...mapGetters('diffs', ['getDiffFileDiscussions']),
viewBlobHref() {
@@ -148,6 +148,11 @@ export default {
return loggedIn && featureOn;
},
+ hasCodequalityChanges() {
+ return (
+ this.codequalityDiff?.files && this.codequalityDiff?.files[this.file.file_path]?.length > 0
+ );
+ },
},
watch: {
'file.id': {
@@ -294,6 +299,7 @@ export default {
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
:show-local-file-reviews="showLocalFileReviews"
+ :has-codequality-changes="hasCodequalityChanges"
class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
:class="hasBodyClasses.header"
@toggleFile="handleToggle"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 1f50b3a38a6..3b4e21ab61b 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -41,6 +41,7 @@ export default {
GlDropdownDivider,
GlFormCheckbox,
GlLoadingIcon,
+ CodeQualityBadge: () => import('ee_component/diffs/components/code_quality_badge.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -49,6 +50,7 @@ export default {
mixins: [glFeatureFlagsMixin()],
i18n: {
...DIFF_FILE_HEADER,
+ compareButtonLabel: s__('Compare submodule commit revisions'),
},
props: {
discussionPath: {
@@ -94,6 +96,11 @@ export default {
required: false,
default: false,
},
+ hasCodequalityChanges: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -192,6 +199,9 @@ export default {
isReviewable() {
return reviewable(this.diffFile);
},
+ externalUrlLabel() {
+ return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url });
+ },
},
methods: {
...mapActions('diffs', [
@@ -323,6 +333,8 @@ export default {
data-track-property="diff_copy_file"
/>
+ <code-quality-badge v-if="hasCodequalityChanges" class="gl-mr-2" />
+
<small v-if="isModeChanged" ref="fileMode" class="mr-1">
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -352,7 +364,8 @@ export default {
ref="externalLink"
v-gl-tooltip.hover
:href="diffFile.external_url"
- :title="`View on ${diffFile.formatted_external_url}`"
+ :title="externalUrlLabel"
+ :aria-label="externalUrlLabel"
target="_blank"
data-track-event="click_toggle_external_button"
data-track-label="diff_toggle_external_button"
@@ -444,7 +457,8 @@ export default {
v-gl-tooltip.hover
v-safe-html="submoduleDiffCompareLinkText"
class="submodule-compare"
- :title="s__('Compare submodule commit revisions')"
+ :title="$options.i18n.compareButtonLabel"
+ :aria-label="$options.i18n.compareButtonLabel"
:href="diffFile.submodule_compare.url"
/>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 2f09f2e24b2..51da1966630 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -10,7 +10,12 @@ import {
} from '../../notes/components/multiline_comment_utils';
import noteForm from '../../notes/components/note_form.vue';
import autosave from '../../notes/mixins/autosave';
-import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import {
+ DIFF_NOTE_TYPE,
+ INLINE_DIFF_LINES_KEY,
+ PARALLEL_DIFF_VIEW_TYPE,
+ OLD_LINE_TYPE,
+} from '../constants';
export default {
components: {
@@ -113,6 +118,34 @@ export default {
const lines = getDiffLines();
return commentLineOptions(lines, this.line, this.line.line_code, side);
},
+ commentLines() {
+ if (!this.selectedCommentPosition) return [];
+
+ const lines = [];
+ const { start, end } = this.selectedCommentPosition;
+ const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY];
+ let isAdding = false;
+
+ for (let i = 0, diffLinesLength = diffLines.length - 1; i <= diffLinesLength; i += 1) {
+ const line = diffLines[i];
+
+ if (start.line_code === line.line_code) {
+ isAdding = true;
+ }
+
+ if (isAdding) {
+ if (line.type !== OLD_LINE_TYPE) {
+ lines.push(line);
+ }
+
+ if (end.line_code === line.line_code) {
+ break;
+ }
+ }
+ }
+
+ return lines;
+ },
},
mounted() {
if (this.isLoggedIn) {
@@ -177,6 +210,7 @@ export default {
:is-editing="true"
:line-code="line.line_code"
:line="line"
+ :lines="commentLines"
:help-page-path="helpPagePath"
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index ab6890d66b5..8d398a2ded4 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -1,5 +1,6 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+/* eslint-disable vue/no-v-html */
+import { GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -12,17 +13,20 @@ import {
CONFLICT_THEIR,
CONFLICT_MARKER,
} from '../constants';
+import {
+ getInteropInlineAttributes,
+ getInteropOldSideAttributes,
+ getInteropNewSideAttributes,
+} from '../utils/interoperability';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import * as utils from './diff_row_utils';
export default {
components: {
- GlIcon,
DiffGutterAvatars,
},
directives: {
GlTooltip: GlTooltipDirective,
- SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -117,6 +121,16 @@ export default {
isLeftConflictMarker() {
return [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(this.line.left?.type);
},
+ interopLeftAttributes() {
+ if (this.inline) {
+ return getInteropInlineAttributes(this.line.left);
+ }
+
+ return getInteropOldSideAttributes(this.line.left);
+ },
+ interopRightAttributes() {
+ return getInteropNewSideAttributes(this.line.right);
+ },
},
mounted() {
this.scrollToLineIfNeededParallel(this.line);
@@ -182,6 +196,7 @@ export default {
<div
data-testid="left-side"
class="diff-grid-left left-side"
+ v-bind="interopLeftAttributes"
@dragover.prevent
@dragenter="onDragEnter(line.left, index)"
@dragend="onDragEnd"
@@ -203,14 +218,13 @@ export default {
<button
:draggable="glFeatures.dragCommentSelection"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment"
+ data-qa-selector="diff_comment_button"
:class="{ 'gl-cursor-grab': dragging }"
:disabled="line.left.commentsDisabled"
@click="handleCommentButton(line.left)"
@dragstart="onDragStart({ ...line.left, index })"
- >
- <gl-icon :size="12" name="comment" />
- </button>
+ ></button>
</span>
</template>
<a
@@ -258,7 +272,7 @@ export default {
@mousedown="handleParallelLineMouseDown"
>
<strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong>
- <span v-else v-safe-html="line.left.rich_text"></span>
+ <span v-else v-html="line.left.rich_text"></span>
</div>
</template>
<template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)">
@@ -288,6 +302,7 @@ export default {
v-if="!inline"
data-testid="right-side"
class="diff-grid-right right-side"
+ v-bind="interopRightAttributes"
@dragover.prevent
@dragenter="onDragEnter(line.right, index)"
@dragend="onDragEnd"
@@ -305,14 +320,12 @@ export default {
<button
:draggable="glFeatures.dragCommentSelection"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment"
:class="{ 'gl-cursor-grab': dragging }"
:disabled="line.right.commentsDisabled"
@click="handleCommentButton(line.right)"
@dragstart="onDragStart({ ...line.right, index })"
- >
- <gl-icon :size="12" name="comment" />
- </button>
+ ></button>
</span>
</template>
<a
@@ -349,7 +362,6 @@ export default {
<div
:id="line.right.line_code"
:key="line.right.rich_text"
- v-safe-html="line.right.rich_text"
:class="[
line.right.type,
{
@@ -364,7 +376,7 @@ export default {
<strong v-if="line.right.type === $options.CONFLICT_MARKER_THEIR">{{
conflictText(line.right)
}}</strong>
- <span v-else v-safe-html="line.right.rich_text"></span>
+ <span v-else v-html="line.right.rich_text"></span>
</div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index 3d05202fb2d..5572338908f 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -122,6 +122,7 @@ export default {
:disabled="!shouldToggleDiscussion"
class="js-image-badge"
type="button"
+ :aria-label="__('Show comments')"
@click="clickedToggle(discussion)"
>
<gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" />
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index fb9202c5aab..25403b1547e 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -2,6 +2,7 @@
import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { CONTEXT_LINE_CLASS_NAME } from '../constants';
+import { getInteropInlineAttributes } from '../utils/interoperability';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import {
isHighlighted,
@@ -96,6 +97,9 @@ export default {
shouldShowAvatarsOnGutter() {
return this.line.hasDiscussions;
},
+ interopAttrs() {
+ return getInteropInlineAttributes(this.line);
+ },
},
mounted() {
this.scrollToLineIfNeededInline(this.line);
@@ -124,6 +128,7 @@ export default {
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
+ v-bind="interopAttrs"
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
@@ -140,8 +145,8 @@ export default {
ref="addDiffNoteButton"
type="button"
class="add-diff-note note-button js-add-diff-note-button"
- data-qa-selector="diff_comment_button"
:disabled="line.commentsDisabled"
+ :aria-label="addCommentTooltip"
@click="handleCommentButton"
>
<gl-icon :size="12" name="comment" />
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index 3d20dfd0c9b..96946d0fd88 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -3,6 +3,10 @@ import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gi
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import {
+ getInteropOldSideAttributes,
+ getInteropNewSideAttributes,
+} from '../utils/interoperability';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import * as utils from './diff_row_utils';
@@ -108,6 +112,12 @@ export default {
this.line.hasDiscussionsRight,
);
},
+ interopLeftAttributes() {
+ return getInteropOldSideAttributes(this.line.left);
+ },
+ interopRightAttributes() {
+ return getInteropNewSideAttributes(this.line.right);
+ },
},
mounted() {
this.scrollToLineIfNeededParallel(this.line);
@@ -185,6 +195,7 @@ export default {
type="button"
class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
:disabled="line.left.commentsDisabled"
+ :aria-label="addCommentTooltipLeft"
@click="handleCommentButton(line.left)"
>
<gl-icon :size="12" name="comment" />
@@ -217,6 +228,7 @@ export default {
:key="line.left.line_code"
v-safe-html="line.left.rich_text"
:class="parallelViewLeftLineType"
+ v-bind="interopLeftAttributes"
class="line_content with-coverage parallel left-side"
@mousedown="handleParallelLineMouseDown"
></td>
@@ -241,6 +253,7 @@ export default {
type="button"
class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
:disabled="line.right.commentsDisabled"
+ :aria-label="addCommentTooltipRight"
@click="handleCommentButton(line.right)"
>
<gl-icon :size="12" name="comment" />
@@ -283,6 +296,7 @@ export default {
hll: isHighlighted,
},
]"
+ v-bind="interopRightAttributes"
class="line_content with-coverage parallel right-side"
@mousedown="handleParallelLineMouseDown"
></td>
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 87e9af174e5..5a8862c2b70 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -73,6 +73,8 @@ export default function initDiffsApp(store) {
endpointMetadata: dataset.endpointMetadata || '',
endpointBatch: dataset.endpointBatch || '',
endpointCoverage: dataset.endpointCoverage || '',
+ endpointCodequality: dataset.endpointCodequality || '',
+ endpointUpdateUser: dataset.updateCurrentUserPath,
projectPath: dataset.projectPath,
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
@@ -114,6 +116,8 @@ export default function initDiffsApp(store) {
endpointMetadata: this.endpointMetadata,
endpointBatch: this.endpointBatch,
endpointCoverage: this.endpointCoverage,
+ endpointCodequality: this.endpointCodequality,
+ endpointUpdateUser: this.endpointUpdateUser,
currentUser: this.currentUser,
projectPath: this.projectPath,
helpPagePath: this.helpPagePath,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 8796016def9..428faf693b0 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -49,7 +49,6 @@ import {
convertExpandLines,
idleCallback,
allDiscussionWrappersExpanded,
- prepareDiffData,
prepareLineForRenamedFile,
} from './utils';
@@ -59,6 +58,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -71,6 +71,7 @@ export const setBaseConfig = ({ commit }, options) => {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -163,7 +164,15 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
return pagination.next_page;
})
- .then((nextPage) => nextPage && getBatch(nextPage))
+ .then((nextPage) => {
+ dispatch('startRenderDiffsQueue');
+
+ if (nextPage) {
+ return getBatch(nextPage);
+ }
+
+ return null;
+ })
.catch(() => commit(types.SET_RETRIEVING_BATCHES, false));
return getBatch()
@@ -197,13 +206,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []);
commit(types.SET_DIFF_METADATA, strippedData);
- worker.postMessage(
- prepareDiffData({
- diff: data,
- priorFiles: state.diffFiles,
- meta: true,
- }),
- );
+ worker.postMessage(data.diff_files);
return data;
})
@@ -304,33 +307,41 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
};
export const startRenderDiffsQueue = ({ state, commit }) => {
- const checkItem = () =>
- new Promise((resolve) => {
- const nextFile = state.diffFiles.find(
- (file) =>
- !file.renderIt &&
- file.viewer &&
- (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text),
- );
-
- if (nextFile) {
- requestAnimationFrame(() => {
- commit(types.RENDER_FILE, nextFile);
+ const diffFilesToRender = state.diffFiles.filter(
+ (file) =>
+ !file.renderIt &&
+ file.viewer &&
+ (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text),
+ );
+ let currentDiffFileIndex = 0;
+
+ const checkItem = () => {
+ const nextFile = diffFilesToRender[currentDiffFileIndex];
+
+ if (nextFile) {
+ let retryCount = 0;
+ currentDiffFileIndex += 1;
+ commit(types.RENDER_FILE, nextFile);
+
+ const requestIdle = () =>
+ requestIdleCallback((idleDeadline) => {
+ // Wait for at least 5ms before trying to render
+ // or for 5 tries and then force render the file
+ if (idleDeadline.timeRemaining() >= 5 || retryCount > 4) {
+ checkItem();
+ } else {
+ requestIdle();
+ retryCount += 1;
+ }
});
- requestIdleCallback(
- () => {
- checkItem()
- .then(resolve)
- .catch(() => {});
- },
- { timeout: 1000 },
- );
- } else {
- resolve();
- }
- });
- return checkItem();
+ requestIdle();
+ }
+ };
+
+ if (diffFilesToRender.length) {
+ checkItem();
+ }
};
export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file);
@@ -738,10 +749,22 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
commit(types.VIEW_DIFF_FILE, fileHash);
};
-export const setFileByFile = ({ commit }, { fileByFile }) => {
+export const setFileByFile = ({ state, commit }, { fileByFile }) => {
const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
+
+ return axios
+ .put(state.endpointUpdateUser, {
+ view_diffs_file_by_file: fileByFile,
+ })
+ .then(() => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/326961
+ // We can't even do a simple console warning here because
+ // the pipeline will fail. However, the issue above will
+ // eventually handle errors appropriately.
+ // console.warn('Saving the file-by-fil user preference failed.');
+ });
};
export function reviewFile({ commit, state }, { file, reviewed = true }) {
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 1fc2a684e95..dec3f87b03e 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -156,16 +156,16 @@ export const diffLines = (state) => (file, unifiedDiffComponents) => {
);
};
-export function suggestionCommitMessage(state) {
+export function suggestionCommitMessage(state, _, rootState) {
return (values = {}) =>
computeSuggestionCommitMessage({
message: state.defaultSuggestionCommitMessage,
values: {
- branch_name: state.branchName,
- project_path: state.projectPath,
- project_name: state.projectName,
- username: state.username,
- user_full_name: state.userFullName,
+ branch_name: rootState.page.mrMetadata.branch_name,
+ project_path: rootState.page.mrMetadata.project_path,
+ project_name: rootState.page.mrMetadata.project_name,
+ username: rootState.page.mrMetadata.username,
+ user_full_name: rootState.page.mrMetadata.user_full_name,
...values,
},
});
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index f93435363ec..1674d3d3b5a 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -9,7 +9,8 @@ import {
import { fileByFile } from '../../utils/preferences';
import { getDefaultWhitespace } from '../utils';
-const viewTypeFromQueryString = getParameterValues('view')[0];
+const getViewTypeFromQueryString = () => getParameterValues('view')[0];
+
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
const whiteSpaceFromQueryString = getParameterValues('w')[0];
@@ -23,6 +24,7 @@ export default () => ({
addedLines: null,
removedLines: null,
endpoint: '',
+ endpointUpdateUser: '',
basePath: '',
commit: null,
startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff
@@ -30,7 +32,7 @@ export default () => ({
coverageFiles: {},
mergeRequestDiffs: [],
mergeRequestDiff: null,
- diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
+ diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType,
tree: [],
treeEntries: {},
showTreeList: true,
diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js
index 6860e24db6b..03d11e60745 100644
--- a/app/assets/javascripts/diffs/store/modules/index.js
+++ b/app/assets/javascripts/diffs/store/modules/index.js
@@ -1,7 +1,7 @@
-import * as actions from '../actions';
+import * as actions from 'ee_else_ce/diffs/store/actions';
+import createState from 'ee_else_ce/diffs/store/modules/diff_state';
+import mutations from 'ee_else_ce/diffs/store/mutations';
import * as getters from '../getters';
-import mutations from '../mutations';
-import createState from './diff_state';
export default () => ({
namespaced: true,
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index d06793c05af..9ff9a02d444 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -33,6 +33,7 @@ export default {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -45,6 +46,7 @@ export default {
endpointMetadata,
endpointBatch,
endpointCoverage,
+ endpointUpdateUser,
projectPath,
dismissEndpoint,
showSuggestPopover,
@@ -77,15 +79,10 @@ export default {
},
[types.SET_DIFF_DATA_BATCH](state, data) {
- const files = prepareDiffData({
+ state.diffFiles = prepareDiffData({
diff: data,
priorFiles: state.diffFiles,
});
-
- Object.assign(state, {
- ...convertObjectPropsToCamelCase(data),
- });
- updateDiffFilesInState(state, files);
},
[types.SET_COVERAGE_DATA](state, coverageFiles) {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index b37a75eb2a3..7fa51b9ddea 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -381,22 +381,13 @@ function prepareDiffFileLines(file) {
inlineLines.forEach((line) => prepareLine(line, file)); // WARNING: In-Place Mutations!
- Object.assign(file, {
- inlineLinesCount: inlineLines.length,
- });
-
return file;
}
-function getVisibleDiffLines(file) {
- return file.inlineLinesCount;
-}
-
-function finalizeDiffFile(file) {
- const lines = getVisibleDiffLines(file);
-
+function finalizeDiffFile(file, index) {
Object.assign(file, {
- renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY,
+ renderIt:
+ index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false,
isShowingFullFile: false,
isLoadingFullFile: false,
discussions: [],
@@ -424,7 +415,7 @@ export function prepareDiffData({ diff, priorFiles = [], meta = false }) {
.map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta }))
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
- .map(finalizeDiffFile);
+ .map((file, index) => finalizeDiffFile(file, priorFiles.length + index));
return deduplicateFilesList([...priorFiles, ...cleanedFiles]);
}
diff --git a/app/assets/javascripts/diffs/utils/interoperability.js b/app/assets/javascripts/diffs/utils/interoperability.js
new file mode 100644
index 00000000000..a52e8fd25f5
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/interoperability.js
@@ -0,0 +1,49 @@
+const OLD = 'old';
+const NEW = 'new';
+const ATTR_PREFIX = 'data-interop-';
+
+export const ATTR_TYPE = `${ATTR_PREFIX}type`;
+export const ATTR_LINE = `${ATTR_PREFIX}line`;
+export const ATTR_NEW_LINE = `${ATTR_PREFIX}new-line`;
+export const ATTR_OLD_LINE = `${ATTR_PREFIX}old-line`;
+
+export const getInteropInlineAttributes = (line) => {
+ if (!line) {
+ return null;
+ }
+
+ const interopType = line.type?.startsWith(OLD) ? OLD : NEW;
+
+ const interopLine = interopType === OLD ? line.old_line : line.new_line;
+
+ return {
+ [ATTR_TYPE]: interopType,
+ [ATTR_LINE]: interopLine,
+ [ATTR_NEW_LINE]: line.new_line,
+ [ATTR_OLD_LINE]: line.old_line,
+ };
+};
+
+export const getInteropOldSideAttributes = (line) => {
+ if (!line) {
+ return null;
+ }
+
+ return {
+ [ATTR_TYPE]: OLD,
+ [ATTR_LINE]: line.old_line,
+ [ATTR_OLD_LINE]: line.old_line,
+ };
+};
+
+export const getInteropNewSideAttributes = (line) => {
+ if (!line) {
+ return null;
+ }
+
+ return {
+ [ATTR_TYPE]: NEW,
+ [ATTR_LINE]: line.new_line,
+ [ATTR_NEW_LINE]: line.new_line,
+ };
+};
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
index 74fa6887ba5..6f068aaa800 100644
--- a/app/assets/javascripts/droplab/drop_lab.js
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -60,21 +60,24 @@ class DropLab {
addEvents() {
this.eventWrapper.documentClicked = this.documentClicked.bind(this);
- document.addEventListener('mousedown', this.eventWrapper.documentClicked);
+ document.addEventListener('click', this.eventWrapper.documentClicked);
}
documentClicked(e) {
- let thisTag = e.target;
+ if (e.defaultPrevented) return;
- if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL');
- if (utils.isDropDownParts(thisTag, this.hooks)) return;
- if (utils.isDropDownParts(e.target, this.hooks)) return;
+ if (utils.isDropDownParts(e.target)) return;
+
+ if (e.target.tagName !== 'UL') {
+ const closestUl = utils.closest(e.target, 'UL');
+ if (utils.isDropDownParts(closestUl)) return;
+ }
this.hooks.forEach((hook) => hook.list.hide());
}
removeEvents() {
- document.removeEventListener('mousedown', this.eventWrapper.documentClicked);
+ document.removeEventListener('click', this.eventWrapper.documentClicked);
}
changeHookList(trigger, list, plugins, config) {
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
index c58d0052251..c51d6167fa3 100644
--- a/app/assets/javascripts/droplab/hook_button.js
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -18,6 +18,8 @@ class HookButton extends Hook {
}
clicked(e) {
+ e.preventDefault();
+
const buttonEvent = new CustomEvent('click.dl', {
detail: {
hook: this,
diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
index 8d350068973..3d4f08131c1 100644
--- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
@@ -1,11 +1,85 @@
-import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '../constants';
+import { Range } from 'monaco-editor';
+import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants';
+
+const hashRegexp = new RegExp('#?L', 'g');
+
+const createAnchor = (href) => {
+ const fragment = new DocumentFragment();
+ const el = document.createElement('a');
+ el.classList.add('link-anchor');
+ el.href = href;
+ fragment.appendChild(el);
+ el.addEventListener('contextmenu', (e) => {
+ e.stopPropagation();
+ });
+ return fragment;
+};
export class EditorLiteExtension {
constructor({ instance, ...options } = {}) {
if (instance) {
Object.assign(instance, options);
+ EditorLiteExtension.highlightLines(instance);
+ if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
+ EditorLiteExtension.setupLineLinking(instance);
+ }
} else if (Object.entries(options).length) {
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
}
}
+
+ static highlightLines(instance) {
+ const { hash } = window.location;
+ if (!hash) {
+ return;
+ }
+ const [start, end] = hash.replace(hashRegexp, '').split('-');
+ let startLine = start ? parseInt(start, 10) : null;
+ let endLine = end ? parseInt(end, 10) : startLine;
+ if (endLine < startLine) {
+ [startLine, endLine] = [endLine, startLine];
+ }
+ if (startLine) {
+ window.requestAnimationFrame(() => {
+ instance.revealLineInCenter(startLine);
+ Object.assign(instance, {
+ lineDecorations: instance.deltaDecorations(
+ [],
+ [
+ {
+ range: new Range(startLine, 1, endLine, 1),
+ options: { isWholeLine: true, className: 'active-line-text' },
+ },
+ ],
+ ),
+ });
+ });
+ }
+ }
+
+ static onMouseMoveHandler(e) {
+ const target = e.target.element;
+ if (target.classList.contains('line-numbers')) {
+ const lineNum = e.target.position.lineNumber;
+ const hrefAttr = `#L${lineNum}`;
+ let el = target.querySelector('a');
+ if (!el) {
+ el = createAnchor(hrefAttr);
+ target.appendChild(el);
+ }
+ }
+ }
+
+ static setupLineLinking(instance) {
+ instance.onMouseMove(EditorLiteExtension.onMouseMoveHandler);
+ instance.onMouseDown((e) => {
+ const isCorrectAnchor = e.target.element.classList.contains('link-anchor');
+ if (!isCorrectAnchor) {
+ return;
+ }
+ if (instance.lineDecorations) {
+ instance.deltaDecorations(instance.lineDecorations, []);
+ }
+ });
+ }
}
diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js
new file mode 100644
index 00000000000..16268910f49
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/index.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import { mapActions, mapState } from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
+import createstore from './store';
+
+export default (el) => {
+ const {
+ dataset: { path },
+ } = el;
+ const canAwardEmoji = parseBoolean(el.dataset.canAwardEmoji);
+
+ return new Vue({
+ el,
+ store: createstore(),
+ computed: {
+ ...mapState(['currentUserId', 'canAwardEmoji', 'awards']),
+ },
+ created() {
+ this.setInitialData({ path, currentUserId: window.gon.current_user_id, canAwardEmoji });
+ },
+ mounted() {
+ this.fetchAwards();
+ },
+ methods: {
+ ...mapActions(['setInitialData', 'fetchAwards', 'toggleAward']),
+ },
+ render(createElement) {
+ return createElement(AwardsList, {
+ props: {
+ awards: this.awards,
+ canAwardEmoji: this.canAwardEmoji,
+ currentUserId: this.currentUserId,
+ defaultAwards: ['thumbsup', 'thumbsdown'],
+ selectedClass: 'gl-bg-blue-50! is-active',
+ },
+ on: {
+ award: this.toggleAward,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js
new file mode 100644
index 00000000000..482acc5a3a9
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/actions.js
@@ -0,0 +1,51 @@
+import * as Sentry from '@sentry/browser';
+import axios from '~/lib/utils/axios_utils';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import showToast from '~/vue_shared/plugins/global_toast';
+import {
+ SET_INITIAL_DATA,
+ FETCH_AWARDS_SUCCESS,
+ ADD_NEW_AWARD,
+ REMOVE_AWARD,
+} from './mutation_types';
+
+export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data);
+
+export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => {
+ try {
+ const { data, headers } = await axios.get(state.path, { params: { per_page: 100, page } });
+ const normalizedHeaders = normalizeHeaders(headers);
+ const nextPage = normalizedHeaders['X-NEXT-PAGE'];
+
+ commit(FETCH_AWARDS_SUCCESS, data);
+
+ if (nextPage) {
+ dispatch('fetchAwards', nextPage);
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+};
+
+export const toggleAward = async ({ commit, state }, name) => {
+ const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId);
+
+ try {
+ if (award) {
+ await axios.delete(`${state.path}/${award.id}`);
+
+ commit(REMOVE_AWARD, award.id);
+
+ showToast(__('Award removed'));
+ } else {
+ const { data } = await axios.post(state.path, { name });
+
+ commit(ADD_NEW_AWARD, data);
+
+ showToast(__('Award added'));
+ }
+ } catch (error) {
+ Sentry.captureException(error);
+ }
+};
diff --git a/app/assets/javascripts/emoji/awards_app/store/index.js b/app/assets/javascripts/emoji/awards_app/store/index.js
new file mode 100644
index 00000000000..53ed50f9f5d
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/index.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+const createState = () => ({
+ awards: [],
+ awardPath: '',
+ currentUserId: null,
+ canAwardEmoji: false,
+});
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/emoji/awards_app/store/mutation_types.js b/app/assets/javascripts/emoji/awards_app/store/mutation_types.js
new file mode 100644
index 00000000000..af6289d0943
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+
+export const FETCH_AWARDS_SUCCESS = 'FETCH_AWARDS_SUCCESS';
+
+export const ADD_NEW_AWARD = 'ADD_NEW_AWARD';
+export const REMOVE_AWARD = 'REMOVE_AWARD';
diff --git a/app/assets/javascripts/emoji/awards_app/store/mutations.js b/app/assets/javascripts/emoji/awards_app/store/mutations.js
new file mode 100644
index 00000000000..8edcfa92885
--- /dev/null
+++ b/app/assets/javascripts/emoji/awards_app/store/mutations.js
@@ -0,0 +1,23 @@
+import {
+ SET_INITIAL_DATA,
+ FETCH_AWARDS_SUCCESS,
+ ADD_NEW_AWARD,
+ REMOVE_AWARD,
+} from './mutation_types';
+
+export default {
+ [SET_INITIAL_DATA](state, { path, currentUserId, canAwardEmoji }) {
+ state.path = path;
+ state.currentUserId = currentUserId;
+ state.canAwardEmoji = canAwardEmoji;
+ },
+ [FETCH_AWARDS_SUCCESS](state, data) {
+ state.awards.push(...data);
+ },
+ [ADD_NEW_AWARD](state, data) {
+ state.awards.push(data);
+ },
+ [REMOVE_AWARD](state, awardId) {
+ state.awards = state.awards.filter(({ id }) => id !== awardId);
+ },
+};
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 37f3433b781..cbcc5dcff3a 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -82,6 +82,8 @@ export default {
no-flip
right
lazy
+ @shown="$emit('shown')"
+ @hidden="$emit('hidden')"
>
<template #button-content><slot name="button-content"></slot></template>
<gl-search-box-by-type
@@ -103,6 +105,7 @@ export default {
}"
type="button"
class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab"
+ :aria-label="category.name"
@click="scrollToCategory(category.name)"
>
<gl-icon :name="category.icon" :size="12" />
diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js
new file mode 100644
index 00000000000..5b4d1afc9d0
--- /dev/null
+++ b/app/assets/javascripts/ensure_data.js
@@ -0,0 +1,56 @@
+import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg';
+import { GlEmptyState } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { __ } from '~/locale';
+
+const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly');
+const ERROR_FETCHING_DATA_DESCRIPTION = __(
+ 'Please try and refresh the page. If the problem persists please contact support.',
+);
+
+/**
+ * This function takes a Component and extends it with data from the `parseData` function.
+ * The data will be made available through `props` and `proivde`.
+ * If the `parseData` throws, the `GlEmptyState` will be returned.
+ * @param {Component} Component a component to render
+ * @param {Object} options
+ * @param {Function} options.parseData a function to parse `data`
+ * @param {Object} options.data an object to pass to `parseData`
+ * @param {Boolean} options.shouldLog to tell whether to log any thrown error by `parseData` to Sentry
+ * @param {Object} options.props to override passed `props` data
+ * @param {Object} options.provide to override passed `provide` data
+ * @param {*} ...options the remaining options will be passed as properties to `createElement`
+ * @return {Component} a Vue component to render, either the GlEmptyState or the extended Component
+ */
+export default function ensureData(Component, options = {}) {
+ const { parseData, data, shouldLog = false, props, provide, ...rest } = options;
+ try {
+ const parsedData = parseData(data);
+ return {
+ provide: { ...parsedData, ...provide },
+ render(createElement) {
+ return createElement(Component, {
+ props: { ...parsedData, ...props },
+ ...rest,
+ });
+ },
+ };
+ } catch (error) {
+ if (shouldLog) {
+ Sentry.captureException(error);
+ }
+
+ return {
+ functional: true,
+ render(createElement) {
+ return createElement(GlEmptyState, {
+ props: {
+ title: ERROR_FETCHING_DATA_HEADER,
+ description: ERROR_FETCHING_DATA_DESCRIPTION,
+ svgPath: `data:image/svg+xml;utf8,${encodeURIComponent(emptySvg)}`,
+ },
+ });
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
index 2494968857c..b0c0f83b88a 100644
--- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue
+++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue
@@ -10,6 +10,7 @@ export default {
GlSprintf,
ModalCopyButton,
},
+ inject: ['defaultBranchName'],
props: {
modalId: {
type: String,
@@ -28,7 +29,11 @@ export default {
modalInfo: {
closeText: s__('EnableReviewApp|Close'),
copyToClipboardText: s__('EnableReviewApp|Copy snippet text'),
- copyString: `deploy_review:
+ title: s__('ReviewApp|Enable Review App'),
+ },
+ computed: {
+ modalInfoCopyStr() {
+ return `deploy_review:
stage: deploy
script:
- echo "Deploy a review app"
@@ -38,8 +43,8 @@ export default {
only:
- branches
except:
- - master`,
- title: s__('ReviewApp|Enable Review App'),
+ - ${this.defaultBranchName}`;
+ },
},
};
</script>
@@ -75,7 +80,9 @@ export default {
</gl-sprintf>
</p>
<div class="gl-display-flex align-items-start">
- <pre class="gl-w-full"> {{ $options.modalInfo.copyString }} </pre>
+ <pre class="gl-w-full" data-testid="enable-review-app-copy-string">
+ {{ modalInfoCopyStr }} </pre
+ >
<modal-copy-button
:title="$options.modalInfo.copyToClipboardText"
:text="$options.modalInfo.copyString"
@@ -90,7 +97,9 @@ export default {
<strong>{{ content }}</strong>
</template>
<template #link="{ content }">
- <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="`blob/${defaultBranchName}/.gitlab-ci.yml`" target="_blank">{{
+ content
+ }}</gl-link>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 397616c654f..c0b4e96cea2 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -71,6 +71,7 @@ export default {
class="gl-display-none gl-md-display-block text-secondary"
:loading="isLoading"
:title="title"
+ :aria-label="title"
:icon="isLastDeployment ? 'repeat' : 'redo'"
@click="onClick"
/>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 68348648e61..b99872f7a6c 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -22,6 +22,7 @@ export default () => {
apolloProvider,
provide: {
projectPath: el.dataset.projectPath,
+ defaultBranchName: el.dataset.defaultBranchName,
},
data() {
const environmentsData = el.dataset;
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 03b8df50c54..f05f0cb7c6d 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -322,7 +322,7 @@ export default {
<gl-button
v-if="!error.gitlabIssuePath"
category="primary"
- variant="success"
+ variant="confirm"
:loading="issueCreationInProgress"
data-qa-selector="create_issue_button"
@click="createIssue"
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
index db61957d452..9438900c736 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
@@ -51,6 +51,7 @@ export default {
v-gl-tooltip.hover
class="gl-display-block gl-mb-4 mb-md-0 gl-w-full"
:title="ignoreBtn.title"
+ :aria-label="ignoreBtn.title"
@click="$emit('update-issue-status', { errorId: error.id, status: ignoreBtn.status })"
>
<gl-icon class="gl-display-none d-md-inline gl-m-0" :name="ignoreBtn.icon" :size="12" />
@@ -62,6 +63,7 @@ export default {
v-gl-tooltip.hover
class="gl-display-block gl-mb-4 mb-md-0 gl-w-full"
:title="resolveBtn.title"
+ :aria-label="resolveBtn.title"
@click="$emit('update-issue-status', { errorId: error.id, status: resolveBtn.status })"
>
<gl-icon class="gl-display-none d-md-inline gl-m-0" :name="resolveBtn.icon" :size="12" />
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index dbcda0877b4..4df324b396c 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -33,7 +33,7 @@ export default {
<p class="form-text text-muted">
{{
s__(
- "ErrorTracking|If you self-host Sentry, enter the full URL of your Sentry instance. If you're using Sentry's hosted solution, enter https://sentry.io",
+ "ErrorTracking|If you self-host Sentry, enter your Sentry instance's full URL. If you use Sentry's hosted solution, enter https://sentry.io",
)
}}
</p>
@@ -75,12 +75,12 @@ export default {
</div>
</div>
<p v-if="connectError" class="gl-field-error">
- {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }}
+ {{ s__('ErrorTracking|Connection failed. Check Auth Token and try again.') }}
</p>
<p v-else class="form-text text-muted">
{{
s__(
- "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects",
+ 'ErrorTracking|After adding your Auth Token, select the Connect button to load projects.',
)
}}
</p>
diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js
index 30828778574..f203a259b16 100644
--- a/app/assets/javascripts/error_tracking_settings/store/getters.js
+++ b/app/assets/javascripts/error_tracking_settings/store/getters.js
@@ -34,8 +34,8 @@ export const invalidProjectLabel = (state) => {
export const projectSelectionLabel = (state) => {
if (state.token) {
return s__(
- "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.",
+ 'ErrorTracking|Click Connect to reestablish the connection to Sentry and activate the dropdown.',
);
}
- return s__('ErrorTracking|To enable project selection, enter a valid Auth Token');
+ return s__('ErrorTracking|To enable project selection, enter a valid Auth Token.');
};
diff --git a/app/assets/javascripts/experimentation/components/experiment.vue b/app/assets/javascripts/experimentation/components/experiment.vue
new file mode 100644
index 00000000000..294dbf77991
--- /dev/null
+++ b/app/assets/javascripts/experimentation/components/experiment.vue
@@ -0,0 +1,15 @@
+<script>
+import { getExperimentVariant } from '../utils';
+
+export default {
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ render() {
+ return this.$slots?.[getExperimentVariant(this.name)];
+ },
+};
+</script>
diff --git a/app/assets/javascripts/experimentation/constants.js b/app/assets/javascripts/experimentation/constants.js
index b7e61d43b11..76e8fdb684b 100644
--- a/app/assets/javascripts/experimentation/constants.js
+++ b/app/assets/javascripts/experimentation/constants.js
@@ -1 +1,3 @@
export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0';
+export const DEFAULT_VARIANT = 'control';
+export const CANDIDATE_VARIANT = 'candidate';
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index d3e7800f643..572907f226d 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,5 +1,6 @@
// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
import { get } from 'lodash';
+import { DEFAULT_VARIANT, CANDIDATE_VARIANT } from './constants';
export function getExperimentData(experimentName) {
return get(window, ['gon', 'experiment', experimentName]);
@@ -8,3 +9,20 @@ export function getExperimentData(experimentName) {
export function isExperimentVariant(experimentName, variantName) {
return getExperimentData(experimentName)?.variant === variantName;
}
+
+export function getExperimentVariant(experimentName) {
+ return getExperimentData(experimentName)?.variant || DEFAULT_VARIANT;
+}
+
+export function experiment(experimentName, variants) {
+ const variant = getExperimentVariant(experimentName);
+
+ switch (variant) {
+ case DEFAULT_VARIANT:
+ return variants.use.call();
+ case CANDIDATE_VARIANT:
+ return variants.try.call();
+ default:
+ return variants[variant].call();
+ }
+}
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 222815407ea..9220077af71 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -7,6 +7,8 @@ import { labelForStrategy } from '../utils';
export default {
i18n: {
+ deleteLabel: __('Delete'),
+ editLabel: __('Edit'),
toggleLabel: __('Feature flag status'),
},
components: {
@@ -215,19 +217,21 @@ export default {
<div class="table-action-buttons btn-group">
<template v-if="featureFlag.edit_path">
<gl-button
- v-gl-tooltip.hover.bottom="__('Edit')"
+ v-gl-tooltip.hover.bottom="$options.i18n.editLabel"
class="js-feature-flag-edit-button"
icon="pencil"
+ :aria-label="$options.i18n.editLabel"
:href="featureFlag.edit_path"
/>
</template>
<template v-if="featureFlag.destroy_path">
<gl-button
- v-gl-tooltip.hover.bottom="__('Delete')"
+ v-gl-tooltip.hover.bottom="$options.i18n.deleteLabel"
class="js-feature-flag-delete-button"
variant="danger"
icon="remove"
:disabled="!canDeleteFlag(featureFlag)"
+ :aria-label="$options.i18n.deleteLabel"
@click="setDeleteModalData(featureFlag)"
/>
</template>
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index f6a14d9996f..67ddceaf080 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -29,6 +29,10 @@ import EnvironmentsDropdown from './environments_dropdown.vue';
import Strategy from './strategy.vue';
export default {
+ i18n: {
+ removeLabel: s__('FeatureFlags|Remove'),
+ statusLabel: s__('FeatureFlags|Status'),
+ },
components: {
GlButton,
GlBadge,
@@ -314,7 +318,7 @@ export default {
<h4>{{ s__('FeatureFlags|Strategies') }}</h4>
<div class="flex align-items-baseline justify-content-between">
<p class="mr-3">{{ $options.translations.newHelpText }}</p>
- <gl-button variant="success" category="secondary" @click="addStrategy">
+ <gl-button variant="confirm" category="secondary" @click="addStrategy">
{{ s__('FeatureFlags|Add strategy') }}
</gl-button>
</div>
@@ -396,12 +400,14 @@ export default {
<div class="table-section section-20 text-center" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Status') }}
+ {{ $options.i18n.statusLabel }}
</div>
<div class="table-mobile-content gl-display-flex gl-justify-content-center">
<gl-toggle
:value="scope.active"
:disabled="!active || !canUpdateScope(scope)"
+ :label="$options.i18n.statusLabel"
+ label-position="hidden"
@change="(status) => (scope.active = status)"
/>
</div>
@@ -502,7 +508,8 @@ export default {
<gl-button
v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)"
v-gl-tooltip
- :title="s__('FeatureFlags|Remove')"
+ :title="$options.i18n.removeLabel"
+ :aria-label="$options.i18n.removeLabel"
class="js-delete-scope btn-transparent pr-3 pl-3"
icon="clear"
data-testid="feature-flag-delete"
@@ -529,11 +536,13 @@ export default {
<div class="table-section section-20 text-center" role="gridcell">
<div class="table-mobile-header" role="rowheader">
- {{ s__('FeatureFlags|Status') }}
+ {{ $options.i18n.statusLabel }}
</div>
<div class="table-mobile-content gl-display-flex gl-justify-content-center">
<gl-toggle
:disabled="!active"
+ :label="$options.i18n.statusLabel"
+ label-position="hidden"
:value="false"
@change="createNewScope({ active: true })"
/>
@@ -575,7 +584,7 @@ export default {
ref="submitButton"
:disabled="readOnly"
type="button"
- variant="success"
+ variant="confirm"
class="js-ff-submit col-xs-12"
@click="handleSubmit"
>{{ submitText }}</gl-button
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 170f120b036..3f515dcdf18 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -165,6 +165,7 @@ export default {
data-testid="delete-strategy-button"
variant="danger"
icon="remove"
+ :aria-label="__('Delete')"
@click="$emit('delete')"
/>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
index 0bfd18f992c..765f59228a6 100644
--- a/app/assets/javascripts/feature_flags/components/user_lists_table.vue
+++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue
@@ -7,7 +7,7 @@ import {
GlTooltipDirective,
GlModalDirective,
} from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -24,11 +24,12 @@ export default {
createdTimeagoLabel: s__('UserList|created %{timeago}'),
deleteListTitle: s__('UserList|Delete %{name}?'),
deleteListMessage: s__('User list %{name} will be removed. Are you sure?'),
+ editUserListLabel: s__('FeatureFlags|Edit User List'),
},
modal: {
id: 'deleteListModal',
actionPrimary: {
- text: s__('Delete user list'),
+ text: __('Delete user list'),
attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
},
},
@@ -93,6 +94,7 @@ export default {
:href="list.path"
category="secondary"
icon="pencil"
+ :aria-label="$options.translations.editUserListLabel"
data-testid="edit-user-list"
/>
<gl-button
@@ -100,6 +102,7 @@ export default {
category="secondary"
variant="danger"
icon="remove"
+ :aria-label="$options.modal.actionPrimary.text"
data-testid="delete-user-list"
@click="confirmDeleteList(list)"
/>
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
index 2fd92a1bb11..79d7eb94569 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue
@@ -71,7 +71,6 @@ export default {
ref="popover"
:target="$options.targetId"
:css-classes="['feature-highlight-popover']"
- triggers="hover"
container="body"
placement="right"
boundary="viewport"
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index a22430833a3..91af3a6b812 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -1,7 +1,7 @@
import { __ } from '~/locale';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
@@ -14,9 +14,9 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
method: 'setData',
loadingTemplate: this.loadingTemplate,
onError() {
- /* eslint-disable no-new */
- new Flash(__('An error occurred fetching the dropdown data.'));
- /* eslint-enable no-new */
+ createFlash({
+ message: __('An error occurred fetching the dropdown data.'),
+ });
},
},
Filter: {
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 4df1120f169..93051b00756 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 { __ } from '~/locale';
import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '../flash';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdown from './filtered_search_dropdown';
@@ -17,9 +17,9 @@ export default class DropdownNonUser extends FilteredSearchDropdown {
loadingTemplate: this.loadingTemplate,
preprocessing,
onError() {
- /* eslint-disable no-new */
- new Flash(__('An error occurred fetching the dropdown data.'));
- /* eslint-enable no-new */
+ createFlash({
+ message: __('An error occurred fetching the dropdown data.'),
+ });
},
},
Filter: {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 69d19074cd0..d0996c9200b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -10,7 +10,7 @@ import {
DOWN_KEY_CODE,
} from '~/lib/utils/keycodes';
import { __ } from '~/locale';
-import { deprecatedCreateFlash as Flash } from '../flash';
+import createFlash from '../flash';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
import { visitUrl } from '../lib/utils/url_utility';
import FilteredSearchContainer from './container';
@@ -92,8 +92,9 @@ export default class FilteredSearchManager {
.fetch()
.catch((error) => {
if (error.name === 'RecentSearchesServiceError') return undefined;
- // eslint-disable-next-line no-new
- new Flash(__('An error occurred while parsing recent searches'));
+ createFlash({
+ message: __('An error occurred while parsing recent searches'),
+ });
// Gracefully fail to empty array
return [];
})
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d26a6bc5f6b..2bec39ff4d8 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -66,55 +66,6 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
* along with ability to provide actionConfig which can be used to show
* additional action or link on banner next to message
*
- * @param {String} message Flash message text
- * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
- * @param {Object} parent Reference to parent element under which Flash needs to appear
- * @param {Object} actionConfig Map of config to show action on banner
- * @param {String} href URL to which action config should point to (default: '#')
- * @param {String} title Title of action
- * @param {Function} clickHandler Method to call when action is clicked on
- * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
- */
-const deprecatedCreateFlash = function deprecatedCreateFlash(
- message,
- type = FLASH_TYPES.ALERT,
- parent = document,
- actionConfig = null,
- fadeTransition = true,
- addBodyClass = false,
-) {
- const flashContainer = parent.querySelector('.flash-container');
-
- if (!flashContainer) return null;
-
- flashContainer.innerHTML = createFlashEl(message, type);
-
- const flashEl = flashContainer.querySelector(`.flash-${type}`);
-
- if (actionConfig) {
- flashEl.innerHTML += createAction(actionConfig);
-
- if (actionConfig.clickHandler) {
- flashEl
- .querySelector('.flash-action')
- .addEventListener('click', (e) => actionConfig.clickHandler(e));
- }
- }
-
- removeFlashClickListener(flashEl, fadeTransition);
-
- flashContainer.style.display = 'block';
-
- if (addBodyClass) document.body.classList.add('flash-shown');
-
- return flashContainer;
-};
-
-/*
- * Flash banner supports different types of Flash configurations
- * along with ability to provide actionConfig which can be used to show
- * additional action or link on banner next to message
- *
* @param {Object} options Options to control the flash message
* @param {String} options.message Flash message text
* @param {String} options.type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
@@ -166,6 +117,31 @@ const createFlash = function createFlash({
return flashContainer;
};
+/*
+ * Flash banner supports different types of Flash configurations
+ * along with ability to provide actionConfig which can be used to show
+ * additional action or link on banner next to message
+ *
+ * @param {String} message Flash message text
+ * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
+ * @param {Object} parent Reference to parent element under which Flash needs to appear
+ * @param {Object} actionConfig Map of config to show action on banner
+ * @param {String} href URL to which action config should point to (default: '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
+ */
+const deprecatedCreateFlash = function deprecatedCreateFlash(
+ message,
+ type,
+ parent,
+ actionConfig,
+ fadeTransition,
+ addBodyClass,
+) {
+ return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass });
+};
+
export {
createFlash as default,
deprecatedCreateFlash,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index c5ea4cc92fd..22f88b1caa7 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '~/lib/utils/jquery_at_who';
-import { escape, template } from 'lodash';
+import { escape, sortBy, template } from 'lodash';
import * as Emoji from '~/emoji';
import axios from '~/lib/utils/axios_utils';
import { s__, __, sprintf } from '~/locale';
@@ -325,25 +325,7 @@ class GfmAutoComplete {
return items;
}
- const lowercaseQuery = query.toLowerCase();
- const members = items.slice();
- const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
-
- return members.sort((a, b) => {
- if (nameOrUsernameStartsWith(a, lowercaseQuery)) {
- return -1;
- }
- if (nameOrUsernameStartsWith(b, lowercaseQuery)) {
- return 1;
- }
- if (nameOrUsernameIncludes(a, lowercaseQuery)) {
- return -1;
- }
- if (nameOrUsernameIncludes(b, lowercaseQuery)) {
- return 1;
- }
- return 0;
- });
+ return GfmAutoComplete.Members.sort(query, items);
},
},
});
@@ -837,6 +819,15 @@ GfmAutoComplete.Members = {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.toLowerCase().includes(query);
},
+ sort(query, members) {
+ const lowercaseQuery = query.toLowerCase();
+ const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
+
+ return sortBy(members, [
+ (member) => (nameOrUsernameStartsWith(member, lowercaseQuery) ? -1 : 0),
+ (member) => (nameOrUsernameIncludes(member, lowercaseQuery) ? -1 : 0),
+ ]);
+ },
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index ab13450bb1e..e941318dce0 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -61,7 +61,9 @@ export default {
<template>
<section id="grafana" class="settings no-animate js-grafana-integration">
<div class="settings-header">
- <h4 class="js-section-header">
+ <h4
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ s__('GrafanaIntegration|Grafana authentication') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
new file mode 100644
index 00000000000..7e897be9e9a
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -0,0 +1,2 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+export const IssueType = 'Issue';
diff --git a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
deleted file mode 100644
index b5b4ba4e772..00000000000
--- a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-fragment EpicNode on Epic {
- id
- iid
- title
- state
- reference
- webPath
- webUrl
- createdAt
- closedAt
-}
diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
new file mode 100644
index 00000000000..0b451262b5a
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql
@@ -0,0 +1,5 @@
+fragment UserAvailability on User {
+ status {
+ availability
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index aaaaf3485ad..e18eea33041 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,11 +1,13 @@
#import "../fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query usersSearch($search: String!, $fullPath: ID!) {
workspace: project(fullPath: $fullPath) {
- users: projectMembers(search: $search) {
+ users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
nodes {
user {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 39c8a88d485..c1fc75fbea6 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -16,9 +16,7 @@ export default class Group {
if (groupName.value === '') {
groupName.addEventListener('keyup', this.updateHandler);
- if (!this.parentId.value) {
- groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
- }
+ groupName.addEventListener('blur', this.updateGroupPathSlugHandler);
}
});
@@ -53,7 +51,7 @@ export default class Group {
const slug = this.groupPaths[0]?.value || slugify(value);
if (!slug) return;
- fetchGroupPathAvailability(slug)
+ fetchGroupPathAvailability(slug, this.parentId?.value)
.then(({ data }) => data)
.then(({ exists, suggests }) => {
if (exists && suggests.length) {
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 9d46fcec09b..f2c608a8912 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -180,16 +180,12 @@ export default {
<div v-if="isGroupPendingRemoval">
<gl-badge variant="warning">{{ __('pending removal') }}</gl-badge>
</div>
- <div
- class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"
- >
- <item-actions
- v-if="isGroup"
- :group="group"
- :parent-group="parentGroup"
- :action="action"
+ <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between">
+ <item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" />
+ <item-stats
+ :item="group"
+ class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center"
/>
- <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 22c648a76a7..4fed7f555f6 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -46,6 +46,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
} = setStatusModalWrapperEl.dataset;
return {
@@ -54,6 +55,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
};
},
render(createElement) {
@@ -63,6 +65,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
} = this;
return createElement(SetStatusModalWrapper, {
@@ -72,6 +75,7 @@ function initStatusTriggers() {
currentMessage,
currentAvailability,
canSetUserAvailability,
+ currentClearStatusAfter,
},
});
},
diff --git a/app/assets/javascripts/ide/components/cannot_push_code_alert.vue b/app/assets/javascripts/ide/components/cannot_push_code_alert.vue
new file mode 100644
index 00000000000..d3e51e6e140
--- /dev/null
+++ b/app/assets/javascripts/ide/components/cannot_push_code_alert.vue
@@ -0,0 +1,40 @@
+<script>
+import { GlAlert, GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ hasAction() {
+ return Boolean(this.action?.href);
+ },
+ actionButtonMethod() {
+ return this.action?.isForm ? 'post' : null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert :dismissible="false">
+ {{ message }}
+ <template v-if="hasAction" #actions>
+ <gl-button variant="confirm" :href="action.href" :data-method="actionButtonMethod">
+ {{ action.text }}
+ </gl-button>
+ </template>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index ff2644704d9..0c9fd324f8c 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import {
@@ -14,6 +14,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { modalTypes } from '../constants';
import eventHub from '../eventhub';
import { measurePerformance } from '../utils';
+import CannotPushCodeAlert from './cannot_push_code_alert.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoEditor from './repo_editor.vue';
@@ -29,7 +30,6 @@ export default {
components: {
IdeSidebar,
RepoEditor,
- GlAlert,
GlButton,
GlLoadingIcon,
ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'),
@@ -41,6 +41,7 @@ export default {
import(/* webpackChunkName: 'ide_runtime' */ '~/vue_shared/components/file_finder/index.vue'),
RightPane: () => import(/* webpackChunkName: 'ide_runtime' */ './panes/right.vue'),
NewModal: () => import(/* webpackChunkName: 'ide_runtime' */ './new_dropdown/modal.vue'),
+ CannotPushCodeAlert,
},
mixins: [glFeatureFlagsMixin()],
data() {
@@ -120,9 +121,11 @@ export default {
class="ide position-relative d-flex flex-column align-items-stretch"
:class="{ [`theme-${themeName}`]: themeName }"
>
- <gl-alert v-if="!canPushCodeStatus.isAllowed" :dismissible="false">{{
- canPushCodeStatus.message
- }}</gl-alert>
+ <cannot-push-code-alert
+ v-if="!canPushCodeStatus.isAllowed"
+ :message="canPushCodeStatus.message"
+ :action="canPushCodeStatus.action"
+ />
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
<template v-if="loadDeferred">
diff --git a/app/assets/javascripts/ide/components/ide_status_mr.vue b/app/assets/javascripts/ide/components/ide_status_mr.vue
index a3b26d23a17..d05ca4141c8 100644
--- a/app/assets/javascripts/ide/components/ide_status_mr.vue
+++ b/app/assets/javascripts/ide/components/ide_status_mr.vue
@@ -20,7 +20,7 @@ export default {
</script>
<template>
- <div class="d-flex-center flex-nowrap text-nowrap js-ide-status-mr">
+ <div class="d-flex-center gl-flex-nowrap text-nowrap js-ide-status-mr">
<gl-icon name="merge-request" />
<span class="ml-1 d-none d-sm-block">{{ s__('WebIDE|Merge request') }}</span>
<gl-link class="ml-1" :href="url">{{ text }}</gl-link>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
index f4859b9f312..6e1929a1948 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue
@@ -55,6 +55,7 @@ export default {
:disabled="disabled"
class="btn-scroll btn-transparent btn-blank"
type="button"
+ :aria-label="tooltipTitle"
@click="clickedScroll"
>
<gl-icon :name="iconName" />
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index f7cfe80df5c..829a9d64cb7 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -87,7 +87,7 @@ export default {
@input="searchMergeRequests"
@removeToken="setSearchType(null)"
/>
- <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes />
+ <gl-icon :size="16" name="search" class="ml-3 input-icon" />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
@@ -105,7 +105,7 @@ export default {
@click.stop="setSearchType(searchType)"
>
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon :size="18" name="search" use-deprecated-sizes />
+ <gl-icon :size="16" name="search" />
</span>
<span>{{ searchType.label }}</span>
</button>
diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
index 0db43123562..3699073adb8 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue
@@ -31,12 +31,12 @@ export default {
<template>
<dropdown-button>
- <span class="row flex-nowrap">
+ <span class="row gl-flex-nowrap">
<span class="col-auto flex-fill text-truncate">
<gl-icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }}
</span>
<span v-if="showMergeRequests" class="col-5 pl-0 text-truncate">
- <gl-icon :size="16" :aria-label="__('Merge Request')" name="merge-request" />
+ <gl-icon :size="16" :aria-label="__('Merge request')" name="merge-request" />
{{ mergeRequestLabel }}
</span>
</span>
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 98f0504298b..750c2c3e215 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -26,7 +26,7 @@ export default {
<gl-tab :title="__('Branches')">
<branches-search-list />
</gl-tab>
- <gl-tab :title="__('Merge Requests')">
+ <gl-tab :title="__('Merge requests')">
<merge-request-search-list />
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 056df3739ee..6304423a3c0 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -110,5 +110,5 @@ export const SIDE_RIGHT = 'right';
export const LIVE_PREVIEW_DEBOUNCE = 2000;
// This is the maximum number of files to auto open when opening the Web IDE
-// from a Merge Request
+// from a merge request
export const MAX_MR_FILES_AUTO_OPEN = 10;
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index f4a0f324e4a..2ce5bf7e271 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -54,6 +54,7 @@ export function initIde(el, options = {}) {
});
this.setLinks({
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
+ forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.setInitialData({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index 9f2a9a8cf4a..52da9942efe 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -7,6 +7,7 @@ export const defaultEditorOptions = {
enabled: false,
},
wordWrap: 'on',
+ glyphMargin: true,
};
export const defaultDiffOptions = {
@@ -21,6 +22,7 @@ export const defaultDiffEditorOptions = {
readOnly: false,
renderLineHighlight: 'none',
hideCursorInOverviewRuler: true,
+ glyphMargin: true,
};
export const defaultModelOptions = {
diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md
index c4f3de00783..86c5045ec71 100644
--- a/app/assets/javascripts/ide/lib/languages/README.md
+++ b/app/assets/javascripts/ide/lib/languages/README.md
@@ -1,21 +1,21 @@
# Web IDE Languages
-The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
-The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
+The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting.
+The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository.
## Adding New Languages
-While Monaco supports a wide variety of languages, there's always the chance that it's missing something.
+While Monaco supports a wide variety of languages, there's always the chance that it's missing something.
You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed.
Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so:
1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist.
2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for.
-3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
+3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language.
- Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting.
4. Add tests for the new language implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`.
- Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js).
-5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
+5. Create a [merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language.
Thank you!
diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js
index 4298d4c627c..189226ef835 100644
--- a/app/assets/javascripts/ide/messages.js
+++ b/app/assets/javascripts/ide/messages.js
@@ -1,10 +1,14 @@
import { s__ } from '~/locale';
-export const MSG_CANNOT_PUSH_CODE = s__(
+export const MSG_CANNOT_PUSH_CODE_SHOULD_FORK = s__(
'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.',
);
-export const MSG_CANNOT_PUSH_CODE_SHORT = s__(
+export const MSG_CANNOT_PUSH_CODE_GO_TO_FORK = s__(
+ 'WebIDE|You need permission to edit files directly in this project. Go to your fork to make changes and submit a merge request.',
+);
+
+export const MSG_CANNOT_PUSH_CODE = s__(
'WebIDE|You need permission to edit files directly in this project.',
);
@@ -15,3 +19,7 @@ export const MSG_CANNOT_PUSH_UNSIGNED = s__(
export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__(
'WebIDE|This project does not accept unsigned commits.',
);
+
+export const MSG_FORK = s__('WebIDE|Fork project');
+
+export const MSG_GO_TO_FORK = s__('WebIDE|Go to fork');
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 753f6b9cd47..74423cd7376 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -73,7 +73,7 @@ export const getMergeRequestData = (
actionText: __('Please try again'),
actionPayload: { projectId, mergeRequestId, force },
});
- reject(new Error(`Merge Request not loaded ${projectId}`));
+ reject(new Error(`Merge request not loaded ${projectId}`));
});
} else {
resolve(state.projects[projectId].mergeRequests[mergeRequestId]);
@@ -106,7 +106,7 @@ export const getMergeRequestChanges = (
actionText: __('Please try again'),
actionPayload: { projectId, mergeRequestId, force },
});
- reject(new Error(`Merge Request Changes not loaded ${projectId}`));
+ reject(new Error(`Merge request changes not loaded ${projectId}`));
});
} else {
resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes);
@@ -140,7 +140,7 @@ export const getMergeRequestVersions = (
actionText: __('Please try again'),
actionPayload: { projectId, mergeRequestId, force },
});
- reject(new Error(`Merge Request Versions not loaded ${projectId}`));
+ reject(new Error(`Merge request versions not loaded ${projectId}`));
});
} else {
resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions);
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index a5bb32ec44a..e8b1a0ea494 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -11,12 +11,42 @@ import {
} from '../constants';
import {
MSG_CANNOT_PUSH_CODE,
- MSG_CANNOT_PUSH_CODE_SHORT,
+ MSG_CANNOT_PUSH_CODE_SHOULD_FORK,
+ MSG_CANNOT_PUSH_CODE_GO_TO_FORK,
MSG_CANNOT_PUSH_UNSIGNED,
MSG_CANNOT_PUSH_UNSIGNED_SHORT,
+ MSG_FORK,
+ MSG_GO_TO_FORK,
} from '../messages';
import { getChangesCountForFiles, filePathMatches } from './utils';
+const getCannotPushCodeViewModel = (state) => {
+ const { ide_path: idePath, fork_path: forkPath } = state.links.forkInfo || {};
+
+ if (idePath) {
+ return {
+ message: MSG_CANNOT_PUSH_CODE_GO_TO_FORK,
+ action: {
+ href: idePath,
+ text: MSG_GO_TO_FORK,
+ },
+ };
+ } else if (forkPath) {
+ return {
+ message: MSG_CANNOT_PUSH_CODE_SHOULD_FORK,
+ action: {
+ href: forkPath,
+ isForm: true,
+ text: MSG_FORK,
+ },
+ };
+ }
+
+ return {
+ message: MSG_CANNOT_PUSH_CODE,
+ };
+};
+
export const activeFile = (state) => state.openFiles.find((file) => file.active) || null;
export const addedFiles = (state) => state.changedFiles.filter((f) => f.tempFile);
@@ -178,7 +208,7 @@ export const canPushCodeStatus = (state, getters) => {
PUSH_RULE_REJECT_UNSIGNED_COMMITS
];
- if (rejectUnsignedCommits) {
+ if (window.gon?.features?.rejectUnsignedCommitsByGitlab && rejectUnsignedCommits) {
return {
isAllowed: false,
message: MSG_CANNOT_PUSH_UNSIGNED,
@@ -188,8 +218,8 @@ export const canPushCodeStatus = (state, getters) => {
if (!canPushCode) {
return {
isAllowed: false,
- message: MSG_CANNOT_PUSH_CODE,
- messageShort: MSG_CANNOT_PUSH_CODE_SHORT,
+ messageShort: MSG_CANNOT_PUSH_CODE,
+ ...getCannotPushCodeViewModel(state),
};
}
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 22ff29e8866..76ba8339703 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -10,7 +10,7 @@ export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
-// Merge Request Mutation Types
+// Merge request mutation types
export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST';
export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST';
export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES';
diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 8df51ef7f9b..cc6a057f587 100644
--- a/app/assets/javascripts/import_entities/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
@@ -1,13 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { GlIcon } from '@gitlab/ui';
import STATUS_MAP from '../constants';
export default {
name: 'ImportStatus',
components: {
- CiIcon,
- GlLoadingIcon,
+ GlIcon,
},
props: {
status: {
@@ -20,28 +18,13 @@ export default {
mappedStatus() {
return STATUS_MAP[this.status];
},
-
- ciIconStatus() {
- const { icon } = this.mappedStatus;
-
- return {
- icon: `status_${icon}`,
- group: icon,
- };
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-h-7 gl-align-items-center">
- <gl-loading-icon
- v-if="mappedStatus.loadingIcon"
- :inline="true"
- :class="mappedStatus.textClass"
- class="align-middle mr-2"
- />
- <ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" />
- <span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span>
+ <div>
+ <gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" />
+ <span>{{ mappedStatus.text }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js
index c2f398cb8a8..156e92e2d00 100644
--- a/app/assets/javascripts/import_entities/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
@@ -11,43 +11,43 @@ export const STATUSES = {
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
+ CANCELLED: 'cancelled',
+};
+
+const SCHEDULED_STATUS = {
+ icon: 'status-scheduled',
+ text: __('Pending'),
+ iconClass: 'gl-text-orange-400',
};
const STATUS_MAP = {
+ [STATUSES.NONE]: {
+ icon: 'status-waiting',
+ text: __('Not started'),
+ iconClass: 'gl-text-gray-400',
+ },
+ [STATUSES.SCHEDULING]: SCHEDULED_STATUS,
+ [STATUSES.SCHEDULED]: SCHEDULED_STATUS,
+ [STATUSES.CREATED]: SCHEDULED_STATUS,
+ [STATUSES.STARTED]: {
+ icon: 'status-running',
+ text: __('Importing...'),
+ iconClass: 'gl-text-blue-400',
+ },
[STATUSES.FINISHED]: {
- icon: 'success',
- text: __('Done'),
- textClass: 'text-success',
+ icon: 'status-success',
+ text: __('Complete'),
+ iconClass: 'gl-text-green-400',
},
[STATUSES.FAILED]: {
- icon: 'failed',
+ icon: 'status-failed',
text: __('Failed'),
- textClass: 'text-danger',
- },
- [STATUSES.CREATED]: {
- icon: 'pending',
- text: __('Scheduled'),
- textClass: 'text-warning',
- },
- [STATUSES.SCHEDULED]: {
- icon: 'pending',
- text: __('Scheduled'),
- textClass: 'text-warning',
- },
- [STATUSES.STARTED]: {
- icon: 'running',
- text: __('Running…'),
- textClass: 'text-info',
- },
- [STATUSES.NONE]: {
- icon: 'created',
- text: __('Not started'),
- textClass: 'text-muted',
+ iconClass: 'gl-text-red-600',
},
- [STATUSES.SCHEDULING]: {
- loadingIcon: true,
- text: __('Scheduling'),
- textClass: 'text-warning',
+ [STATUSES.CANCELLED]: {
+ icon: 'status-stopped',
+ text: __('Cancelled'),
+ iconClass: 'gl-text-red-600',
},
};
diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index ebb09947663..a803afeb901 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -111,7 +111,7 @@ export default {
</gl-link>
</td>
<td
- class="gl-display-flex gl-flex-sm-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
+ class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top"
data-testid="fullPath"
data-qa-selector="project_path_content"
>
diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
index 9d5f37dc3b7..0746725153d 100644
--- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
+++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue
@@ -26,7 +26,10 @@ export default {
class="settings no-animate qa-incident-management-settings"
>
<div class="settings-header">
- <h4 ref="sectionHeader">
+ <h4
+ ref="sectionHeader"
+ class="settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ $options.i18n.headerText }}
</h4>
<gl-button ref="toggleBtn" class="js-settings-toggle">{{
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index a4baca20ac9..3655f94f06f 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -3,7 +3,6 @@
import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui';
import { capitalize, lowerCase, isEmpty } from 'lodash';
import { mapGetters } from 'vuex';
-import { __, sprintf } from '~/locale';
import eventHub from '../event_hub';
export default {
@@ -77,14 +76,6 @@ export default {
isNonEmptyPassword() {
return this.isPassword && !isEmpty(this.value);
},
- label() {
- if (this.isNonEmptyPassword) {
- return sprintf(__('Enter new %{field_title}'), {
- field_title: this.humanizedTitle,
- });
- }
- return this.humanizedTitle;
- },
humanizedTitle() {
return this.title || capitalize(lowerCase(this.name));
},
@@ -136,7 +127,7 @@ export default {
<template>
<gl-form-group
- :label="label"
+ :label="humanizedTitle"
:label-for="fieldId"
:invalid-feedback="__('This field is required.')"
:state="valid"
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 d3d1fd8ddc3..aea4a8b1c0b 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,15 +1,8 @@
<script>
-import {
- GlFormGroup,
- GlFormCheckbox,
- GlFormInput,
- GlSprintf,
- GlLink,
- GlButton,
- GlCard,
-} from '@gitlab/ui';
+import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../event_hub';
+import JiraUpgradeCta from './jira_upgrade_cta.vue';
export default {
name: 'JiraIssuesFields',
@@ -19,8 +12,7 @@ export default {
GlFormInput,
GlSprintf,
GlLink,
- GlButton,
- GlCard,
+ JiraUpgradeCta,
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
@@ -84,11 +76,13 @@ export default {
return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
},
showJiraVulnerabilitiesOptions() {
- return (
- this.enableJiraIssues &&
- this.showJiraVulnerabilitiesIntegration &&
- this.glFeatures.jiraForVulnerabilities
- );
+ return this.showJiraVulnerabilitiesIntegration && this.glFeatures.jiraForVulnerabilities;
+ },
+ showUltimateUpgrade() {
+ return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration;
+ },
+ showPremiumUpgrade() {
+ return !this.showJiraIssuesIntegration;
},
},
created() {
@@ -129,33 +123,29 @@ export default {
<template #help>
{{
s__(
- 'JiraService|Warning: All GitLab users that have access to this GitLab project will be able to view all issues from the Jira project specified below.',
+ 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.',
)
}}
</template>
</gl-form-checkbox>
<jira-issue-creation-vulnerabilities
- v-if="showJiraVulnerabilitiesOptions"
+ v-if="enableJiraIssues"
:project-key="projectKey"
:initial-is-enabled="initialEnableJiraVulnerabilities"
:initial-issue-type-id="initialVulnerabilitiesIssuetype"
+ :show-full-feature="showJiraVulnerabilitiesOptions"
data-testid="jira-for-vulnerabilities"
@request-get-issue-types="getJiraIssueTypes"
/>
</template>
- <gl-card v-else class="gl-mt-7">
- <strong>{{ __('This is a Premium feature') }}</strong>
- <p>{{ __('Upgrade your plan to enable this feature of the Jira Integration.') }}</p>
- <gl-button
- v-if="upgradePlanPath"
- category="primary"
- variant="info"
- :href="upgradePlanPath"
- target="_blank"
- >
- {{ __('Upgrade your plan') }}
- </gl-button>
- </gl-card>
+ <jira-upgrade-cta
+ v-if="showUltimateUpgrade || showPremiumUpgrade"
+ class="gl-mt-2"
+ :class="{ 'gl-ml-6': showUltimateUpgrade }"
+ :upgrade-plan-path="upgradePlanPath"
+ :show-ultimate-message="showUltimateUpgrade"
+ :show-premium-message="showPremiumUpgrade"
+ />
</div>
</gl-form-group>
<template v-if="showJiraIssuesIntegration">
@@ -169,7 +159,7 @@ export default {
id="service_project_key"
v-model="projectKey"
name="service[project_key]"
- :placeholder="s__('JiraService|e.g. AB')"
+ :placeholder="s__('JiraService|For example, AB')"
:required="enableJiraIssues"
:state="validProjectKey"
:disabled="!enableJiraIssues"
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 af4e9acf4ba..b0f19e5b585 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -1,7 +1,16 @@
<script>
-import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
+import {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormRadio,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import eventHub from '../event_hub';
const commentDetailOptions = [
{
@@ -18,12 +27,41 @@ const commentDetailOptions = [
},
];
+const ISSUE_TRANSITION_AUTO = true;
+const ISSUE_TRANSITION_CUSTOM = false;
+
+const issueTransitionOptions = [
+ {
+ value: ISSUE_TRANSITION_AUTO,
+ label: s__('JiraService|Move to Done'),
+ help: s__(
+ 'JiraService|Automatically transitions Jira issues to the "Done" category. %{linkStart}Learn more%{linkEnd}',
+ ),
+ link: helpPagePath('user/project/integrations/jira.html', {
+ anchor: 'automatic-issue-transitions',
+ }),
+ },
+ {
+ value: ISSUE_TRANSITION_CUSTOM,
+ label: s__('JiraService|Use custom transitions'),
+ help: s__(
+ 'JiraService|Set a custom final state by using transition IDs. %{linkStart}Learn about transition IDs%{linkEnd}',
+ ),
+ link: helpPagePath('user/project/integrations/jira.html', {
+ anchor: 'custom-issue-transitions',
+ }),
+ },
+];
+
export default {
name: 'JiraTriggerFields',
components: {
GlFormGroup,
GlFormCheckbox,
GlFormRadio,
+ GlFormInput,
+ GlLink,
+ GlSprintf,
},
props: {
initialTriggerCommit: {
@@ -43,21 +81,58 @@ export default {
required: false,
default: 'standard',
},
+ initialJiraIssueTransitionAutomatic: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ initialJiraIssueTransitionId: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
+ validated: false,
triggerCommit: this.initialTriggerCommit,
triggerMergeRequest: this.initialTriggerMergeRequest,
enableComments: this.initialEnableComments,
commentDetail: this.initialCommentDetail,
+ jiraIssueTransitionAutomatic:
+ this.initialJiraIssueTransitionAutomatic || !this.initialJiraIssueTransitionId,
+ jiraIssueTransitionId: this.initialJiraIssueTransitionId,
+ issueTransitionEnabled:
+ this.initialJiraIssueTransitionAutomatic || Boolean(this.initialJiraIssueTransitionId),
commentDetailOptions,
+ issueTransitionOptions,
};
},
computed: {
...mapGetters(['isInheriting']),
- showEnableComments() {
+ showTriggerSettings() {
return this.triggerCommit || this.triggerMergeRequest;
},
+ validIssueTransitionId() {
+ return !this.validated || Boolean(this.jiraIssueTransitionId);
+ },
+ },
+ created() {
+ eventHub.$on('validateForm', this.validateForm);
+ },
+ beforeDestroy() {
+ eventHub.$off('validateForm', this.validateForm);
+ },
+ methods: {
+ validateForm() {
+ this.validated = true;
+ },
+ showCustomIssueTransitions(currentOption) {
+ return (
+ this.jiraIssueTransitionAutomatic === ISSUE_TRANSITION_CUSTOM &&
+ currentOption === ISSUE_TRANSITION_CUSTOM
+ );
+ },
},
};
</script>
@@ -69,7 +144,7 @@ export default {
label-for="service[trigger]"
:description="
s__(
- 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.',
+ 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) is created.',
)
"
>
@@ -89,7 +164,7 @@ export default {
</gl-form-group>
<gl-form-group
- v-show="showEnableComments"
+ v-show="showTriggerSettings"
:label="s__('Integrations|Comment settings:')"
label-for="service[comment_on_event_enabled]"
class="gl-pl-6"
@@ -106,7 +181,7 @@ export default {
</gl-form-group>
<gl-form-group
- v-show="showEnableComments && enableComments"
+ v-show="showTriggerSettings && enableComments"
:label="s__('Integrations|Comment detail:')"
label-for="service[comment_detail]"
class="gl-pl-9"
@@ -126,5 +201,67 @@ export default {
</template>
</gl-form-radio>
</gl-form-group>
+
+ <gl-form-group
+ v-if="showTriggerSettings"
+ :label="s__('JiraService|Transition Jira issues to their final state:')"
+ class="gl-pl-6"
+ data-testid="issue-transition-enabled"
+ >
+ <input type="hidden" name="service[jira_issue_transition_automatic]" value="false" />
+ <input type="hidden" name="service[jira_issue_transition_id]" value="" />
+
+ <gl-form-checkbox
+ v-model="issueTransitionEnabled"
+ :disabled="isInheriting"
+ data-qa-selector="service_jira_issue_transition_enabled_checkbox"
+ >
+ {{ s__('JiraService|Enable Jira transitions') }}
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="showTriggerSettings && issueTransitionEnabled"
+ class="gl-pl-9"
+ data-testid="issue-transition-mode"
+ >
+ <gl-form-radio
+ v-for="issueTransitionOption in issueTransitionOptions"
+ :key="issueTransitionOption.value"
+ v-model="jiraIssueTransitionAutomatic"
+ name="service[jira_issue_transition_automatic]"
+ :value="issueTransitionOption.value"
+ :disabled="isInheriting"
+ :data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`"
+ >
+ {{ issueTransitionOption.label }}
+
+ <template v-if="showCustomIssueTransitions(issueTransitionOption.value)">
+ <gl-form-input
+ v-model="jiraIssueTransitionId"
+ name="service[jira_issue_transition_id]"
+ type="text"
+ class="gl-my-3"
+ data-qa-selector="service_jira_issue_transition_id_field"
+ :placeholder="s__('JiraService|For example, 12, 24')"
+ :disabled="isInheriting"
+ :required="true"
+ :state="validIssueTransitionId"
+ />
+
+ <span class="invalid-feedback">
+ {{ __('This field is required.') }}
+ </span>
+ </template>
+
+ <template #help>
+ <gl-sprintf :message="issueTransitionOption.help">
+ <template #link="{ content }">
+ <gl-link :href="issueTransitionOption.link" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-radio>
+ </gl-form-group>
</div>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue
new file mode 100644
index 00000000000..9164e484440
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlButton, GlCard } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlCard,
+ },
+ props: {
+ upgradePlanPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showPremiumMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showUltimateMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.showUltimateMessage
+ ? this.$options.i18n.titleUltimate
+ : this.$options.i18n.titlePremium;
+ },
+ },
+ i18n: {
+ titleUltimate: s__('JiraService|This is an Ultimate feature'),
+ titlePremium: s__('JiraService|This is a Premium feature'),
+ content: s__('JiraService|Upgrade your plan to enable this feature of the Jira Integration.'),
+ upgrade: __('Upgrade your plan'),
+ },
+};
+</script>
+
+<template>
+ <gl-card>
+ <strong>{{ title }}</strong>
+ <p>{{ $options.i18n.content }}</p>
+ <gl-button v-if="upgradePlanPath" category="primary" variant="info" :href="upgradePlanPath">
+ {{ $options.i18n.upgrade }}
+ </gl-button>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
index 1bbecea05ad..42bc9e4c8a1 100644
--- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -10,8 +10,8 @@ const typeWithPlaceholder = {
};
const placeholderForType = {
- [typeWithPlaceholder.SLACK]: __('Slack channels (e.g. general, development)'),
- [typeWithPlaceholder.MATTERMOST]: __('Channel handle (e.g. town-square)'),
+ [typeWithPlaceholder.SLACK]: __('general, development'),
+ [typeWithPlaceholder.MATTERMOST]: __('my-channel'),
};
export default {
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index ab9bdd9ca2e..792e7d8e85e 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+
import IntegrationForm from './components/integration_form.vue';
import { createStore } from './store';
@@ -28,6 +29,8 @@ function parseDatasetToProps(data) {
testPath,
resetPath,
vulnerabilitiesIssuetype,
+ jiraIssueTransitionAutomatic,
+ jiraIssueTransitionId,
...booleanAttributes
} = data;
const {
@@ -59,6 +62,8 @@ function parseDatasetToProps(data) {
initialTriggerMergeRequest: mergeRequestEvents,
initialEnableComments: enableComments,
initialCommentDetail: commentDetail,
+ initialJiraIssueTransitionAutomatic: jiraIssueTransitionAutomatic,
+ initialJiraIssueTransitionId: jiraIssueTransitionId,
},
jiraIssuesProps: {
showJiraIssuesIntegration,
@@ -73,7 +78,7 @@ function parseDatasetToProps(data) {
},
learnMorePath,
triggerEvents: JSON.parse(triggerEvents),
- fields: JSON.parse(fields),
+ fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }),
inheritFromId: parseInt(inheritFromId, 10),
integrationLevel,
id: parseInt(id, 10),
diff --git a/app/assets/javascripts/integrations/index/components/integrations_list.vue b/app/assets/javascripts/integrations/index/components/integrations_list.vue
new file mode 100644
index 00000000000..7331437d484
--- /dev/null
+++ b/app/assets/javascripts/integrations/index/components/integrations_list.vue
@@ -0,0 +1,59 @@
+<script>
+import { s__ } from '~/locale';
+import IntegrationsTable from './integrations_table.vue';
+
+export default {
+ name: 'IntegrationsList',
+ components: {
+ IntegrationsTable,
+ },
+ props: {
+ integrations: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ integrationsGrouped() {
+ return this.integrations.reduce(
+ (integrations, integration) => {
+ if (integration.active) {
+ integrations.active.push(integration);
+ } else {
+ integrations.inactive.push(integration);
+ }
+
+ return integrations;
+ },
+ { active: [], inactive: [] },
+ );
+ },
+ },
+ i18n: {
+ activeTableEmptyText: s__("Integrations|You haven't activated any integrations yet."),
+ inactiveTableEmptyText: s__("Integrations|You've activated every integration 🎉"),
+ activeIntegrationsHeading: s__('Integrations|Active integrations'),
+ inactiveIntegrationsHeading: s__('Integrations|Add an integration'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4>{{ $options.i18n.activeIntegrationsHeading }}</h4>
+ <integrations-table
+ class="gl-mb-7!"
+ :integrations="integrationsGrouped.active"
+ :empty-text="$options.i18n.activeTableEmptyText"
+ show-updated-at
+ data-testid="active-integrations-table"
+ />
+
+ <h4>{{ $options.i18n.inactiveIntegrationsHeading }}</h4>
+ <integrations-table
+ :integrations="integrationsGrouped.inactive"
+ :empty-text="$options.i18n.inactiveTableEmptyText"
+ data-testid="inactive-integrations-table"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue
new file mode 100644
index 00000000000..439c243f418
--- /dev/null
+++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlTable,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ integrations: {
+ type: Array,
+ required: true,
+ },
+ showUpdatedAt: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ emptyText: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ fields() {
+ return [
+ {
+ key: 'active',
+ label: '',
+ thClass: 'gl-w-10',
+ },
+ {
+ key: 'title',
+ label: __('Integration'),
+ thClass: 'gl-w-quarter',
+ },
+ {
+ key: 'description',
+ label: __('Description'),
+ thClass: 'gl-display-none d-sm-table-cell',
+ tdClass: 'gl-display-none d-sm-table-cell',
+ },
+ {
+ key: 'updated_at',
+ label: this.showUpdatedAt ? __('Last updated') : '',
+ thClass: 'gl-w-20p',
+ },
+ ];
+ },
+ },
+ methods: {
+ getStatusTooltipTitle(integration) {
+ return sprintf(s__('Integrations|%{integrationTitle}: active'), {
+ integrationTitle: integration.title,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed>
+ <template #cell(active)="{ item }">
+ <gl-icon
+ v-if="item.active"
+ v-gl-tooltip
+ name="check"
+ class="gl-text-green-500"
+ :title="getStatusTooltipTitle(item)"
+ />
+ </template>
+
+ <template #cell(title)="{ item }">
+ <gl-link
+ :href="item.edit_path"
+ class="gl-font-weight-bold"
+ :data-qa-selector="`${item.name}_link`"
+ >
+ {{ item.title }}
+ </gl-link>
+ </template>
+
+ <template #cell(updated_at)="{ item }">
+ <time-ago-tooltip v-if="showUpdatedAt && item.updated_at" :time="item.updated_at" />
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/integrations/index/index.js b/app/assets/javascripts/integrations/index/index.js
new file mode 100644
index 00000000000..09dca3a6d02
--- /dev/null
+++ b/app/assets/javascripts/integrations/index/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import IntegrationList from './components/integrations_list.vue';
+
+export default () => {
+ const el = document.querySelector('.js-integrations-list');
+
+ if (!el) {
+ return null;
+ }
+
+ const { integrations } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(IntegrationList, {
+ props: {
+ integrations: JSON.parse(integrations),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
index 144c1a2c22a..ec77e49ae53 100644
--- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue
+++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
@@ -19,8 +19,10 @@ export default {
GlLink,
GlModal,
},
- inject: {
+ props: {
membersPath: {
+ type: String,
+ required: false,
default: '',
},
},
diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
index 56cf1ab2fc2..ee89e0bbf71 100644
--- a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
+++ b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
@@ -7,14 +7,20 @@ export default {
components: {
GlLink,
},
- inject: {
+ props: {
displayText: {
+ type: String,
+ required: false,
default: '',
},
event: {
+ type: String,
+ required: false,
default: '',
},
label: {
+ type: String,
+ required: false,
default: '',
},
},
diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js
index c292bda1931..a50d31c9e7a 100644
--- a/app/assets/javascripts/invite_member/init_invite_member_modal.js
+++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js
@@ -1,13 +1,17 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
+import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils';
import InviteMemberModal from './components/invite_member_modal.vue';
Vue.use(GlToast);
+const isAssigneesWidgetShown =
+ (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+
export default function initInviteMembersModal() {
const el = document.querySelector('.js-invite-member-modal');
- if (!el) {
+ if (!el || isAssigneesWidgetShown) {
return false;
}
@@ -15,7 +19,9 @@ export default function initInviteMembersModal() {
return new Vue({
el,
- provide: { membersPath },
- render: (createElement) => createElement(InviteMemberModal),
+ render: (createElement) =>
+ createElement(InviteMemberModal, {
+ props: { membersPath },
+ }),
});
}
diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js
index 5e763e4f47d..eb765ae83b0 100644
--- a/app/assets/javascripts/invite_member/init_invite_member_trigger.js
+++ b/app/assets/javascripts/invite_member/init_invite_member_trigger.js
@@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() {
return new Vue({
el,
- provide: { ...el.dataset },
- render: (createElement) => createElement(InviteMemberTrigger),
+ render: (createElement) =>
+ createElement(InviteMemberTrigger, {
+ props: { ...el.dataset },
+ }),
});
}
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 47f1405c980..d00a0f1633b 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,10 +11,12 @@ import {
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import Api from '~/api';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
+import { INVITE_MEMBERS_IN_COMMENT } from '../constants';
import eventHub from '../event_hub';
export default {
@@ -122,8 +124,9 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ inviteeType }) {
+ openModal({ inviteeType, source }) {
this.inviteeType = inviteeType;
+ this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -138,6 +141,12 @@ export default {
}
this.closeModal();
},
+ trackInvite() {
+ if (this.source === INVITE_MEMBERS_IN_COMMENT) {
+ const tracking = new ExperimentTracking(INVITE_MEMBERS_IN_COMMENT);
+ tracking.event('comment_invite_success');
+ }
+ },
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
@@ -177,6 +186,8 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
+ this.trackInvite();
+
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {
@@ -211,9 +222,9 @@ export default {
},
labels: {
members: {
- modalTitle: s__('InviteMembersModal|Invite team members'),
- searchField: s__('InviteMembersModal|GitLab member or Email address'),
- placeHolder: s__('InviteMembersModal|Search for members to invite'),
+ modalTitle: s__('InviteMembersModal|Invite members'),
+ searchField: s__('InviteMembersModal|GitLab member or email address'),
+ placeHolder: s__('InviteMembersModal|Select members or type email addresses'),
toGroup: {
introText: s__(
"InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.",
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 666693e934f..e297bb6c806 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,10 +1,11 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlLink } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
export default {
- components: { GlButton },
+ components: { GlButton, GlLink },
props: {
displayText: {
type: String,
@@ -26,10 +27,65 @@ export default {
required: false,
default: undefined,
},
+ triggerSource: {
+ type: String,
+ required: false,
+ default: 'unknown',
+ },
+ trackExperiment: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ triggerElement: {
+ type: String,
+ required: false,
+ default: 'button',
+ },
+ event: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isButton() {
+ return this.triggerElement === 'button';
+ },
+ componentAttributes() {
+ const baseAttributes = {
+ class: this.classes,
+ 'data-qa-selector': 'invite_members_button',
+ };
+
+ if (this.event && this.label) {
+ return {
+ ...baseAttributes,
+ 'data-track-event': this.event,
+ 'data-track-label': this.label,
+ };
+ }
+
+ return baseAttributes;
+ },
+ },
+ mounted() {
+ this.trackExperimentOnShow();
},
methods: {
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'members' });
+ eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
+ },
+ trackExperimentOnShow() {
+ if (this.trackExperiment) {
+ const tracking = new ExperimentTracking(this.trackExperiment);
+ tracking.event('comment_invite_shown');
+ }
},
},
};
@@ -37,12 +93,15 @@ export default {
<template>
<gl-button
- :class="classes"
- :icon="icon"
+ v-if="isButton"
+ v-bind="componentAttributes"
:variant="variant"
- data-qa-selector="invite_members_button"
+ :icon="icon"
@click="openModal"
>
{{ displayText }}
</gl-button>
+ <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal">
+ {{ displayText }}
+ </gl-link>
</template>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 2044dad896f..a651b81c60e 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1 +1,3 @@
export const SEARCH_DELAY = 200;
+
+export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 78987a5c629..7bdd55ddda3 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -12,19 +12,23 @@ export default {
},
inject: {
issuableType: {
- default: '',
- },
- issuableCount: {
- default: 0,
+ default: ISSUABLE_TYPE.issues,
},
email: {
default: '',
},
+ },
+ props: {
exportCsvPath: {
+ type: String,
+ required: false,
default: '',
},
- },
- props: {
+ issuableCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
modalId: {
type: String,
required: true,
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 bbf4160ce35..fb4d5aca2f5 100644
--- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue
@@ -13,6 +13,10 @@ import CsvExportModal from './csv_export_modal.vue';
import CsvImportModal from './csv_import_modal.vue';
export default {
+ i18n: {
+ exportAsCsvButtonText: __('Export as CSV'),
+ importIssuesText: __('Import issues'),
+ },
name: 'CsvImportExportButtons',
components: {
GlButtonGroup,
@@ -49,6 +53,18 @@ export default {
default: false,
},
},
+ props: {
+ exportCsvPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ issuableCount: {
+ type: Number,
+ required: false,
+ default: undefined,
+ },
+ },
computed: {
exportModalId() {
return `${this.issuableType}-export-modal`;
@@ -57,16 +73,15 @@ export default {
return `${this.issuableType}-import-modal`;
},
importButtonText() {
- return this.showLabel ? this.$options.importIssuesText : null;
+ return this.showLabel ? this.$options.i18n.importIssuesText : null;
},
importButtonTooltipText() {
- return this.showLabel ? null : this.$options.importIssuesText;
+ return this.showLabel ? null : this.$options.i18n.importIssuesText;
},
importButtonIcon() {
return this.showLabel ? null : 'import';
},
},
- importIssuesText: __('Import issues'),
};
</script>
@@ -75,9 +90,10 @@ export default {
<gl-button-group>
<gl-button
v-if="showExportButton"
- v-gl-tooltip.hover="__('Export as CSV')"
+ v-gl-tooltip.hover="$options.i18n.exportAsCsvButtonText"
v-gl-modal="exportModalId"
icon="export"
+ :aria-label="$options.i18n.exportAsCsvButtonText"
data-qa-selector="export_as_csv_button"
data-testid="export-csv-button"
/>
@@ -101,7 +117,12 @@ export default {
>
</gl-dropdown>
</gl-button-group>
- <csv-export-modal v-if="showExportButton" :modal-id="exportModalId" />
+ <csv-export-modal
+ v-if="showExportButton"
+ :modal-id="exportModalId"
+ :export-csv-path="exportCsvPath"
+ :issuable-count="issuableCount"
+ />
<csv-import-modal v-if="showImportButton" :modal-id="importModalId" />
</div>
</template>
diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue
index 6d063b59922..d0ce8c2c34b 100644
--- a/app/assets/javascripts/issuable/components/issuable_by_email.vue
+++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue
@@ -14,6 +14,9 @@ import { sprintf, __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default {
+ i18n: {
+ sendEmail: __('Send email'),
+ },
name: 'IssuableByEmail',
components: {
GlButton,
@@ -116,7 +119,8 @@ export default {
<gl-button
v-gl-tooltip.hover
:href="mailToLink"
- :title="__('Send email')"
+ :title="$options.i18n.sendEmail"
+ :aria-label="$options.i18n.sendEmail"
icon="mail"
data-testid="mail-to-btn"
/>
diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
index 5a720b89d33..83163e3c478 100644
--- a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
+++ b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import ImportExportButtons from './components/csv_import_export_buttons.vue';
+import CsvImportExportButtons from './components/csv_import_export_buttons.vue';
export default () => {
const el = document.querySelector('.js-csv-import-export-buttons');
@@ -28,9 +28,7 @@ export default () => {
showExportButton: parseBoolean(showExportButton),
showImportButton: parseBoolean(showImportButton),
issuableType,
- issuableCount,
email,
- exportCsvPath,
importCsvIssuesPath,
containerClass,
canEdit: parseBoolean(canEdit),
@@ -39,7 +37,12 @@ export default () => {
showLabel,
},
render(h) {
- return h(ImportExportButtons);
+ return h(CsvImportExportButtons, {
+ props: {
+ exportCsvPath,
+ issuableCount: parseInt(issuableCount, 10),
+ },
+ });
},
});
};
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index f507f072253..366a9a8a883 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -87,7 +87,7 @@ export default {
// From issuable's initial bulk selection
getOriginalCommonIds() {
const labelIds = [];
- this.getElement('.selected-issuable:checked').each((i, el) => {
+ this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
return intersection.apply(this, labelIds);
@@ -100,7 +100,7 @@ export default {
let issuableLabels = [];
// Collect unique label IDs for all checked issues
- this.getElement('.selected-issuable:checked').each((i, el) => {
+ this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => {
issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
issuableLabels.forEach((labelId) => {
// Store unique IDs
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index ef98db5151a..97d50dde9f7 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -34,7 +34,7 @@ export default class IssuableBulkUpdateSidebar {
this.$otherFilters = $('.issues-other-filters');
this.$checkAllContainer = $('.check-all-holder');
this.$issueChecks = $('.issue-check');
- this.$issuesList = $('.selected-issuable');
+ this.$issuesList = $('.issuable-list input[type="checkbox"]');
this.$issuableIdsInput = $('#update_issuable_ids');
}
@@ -46,16 +46,11 @@ export default class IssuableBulkUpdateSidebar {
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState());
- if (this.vueIssuablesListFeature) {
- issueableEventHub.$on('issuables:updateBulkEdit', () => {
- // Danger! Strong coupling ahead!
- // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue
- // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
- // explicitly, but this component is used in too many places right now to refactor straight away.
-
- this.updateFormState();
- });
- }
+ // The event hub connects this bulk update logic with `issues_list_app.vue`.
+ // We can remove it once we've refactored the issues list page bulk edit sidebar to Vue.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/325874
+ issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true));
+ issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState());
}
initDropdowns() {
@@ -96,7 +91,7 @@ export default class IssuableBulkUpdateSidebar {
}
updateFormState() {
- const noCheckedIssues = !$('.selected-issuable:checked').length;
+ const noCheckedIssues = !$('.issuable-list input[type="checkbox"]:checked').length;
this.toggleSubmitButtonDisabled(noCheckedIssues);
this.updateSelectedIssuableIds();
@@ -112,7 +107,7 @@ export default class IssuableBulkUpdateSidebar {
}
toggleBulkEdit(e, enable) {
- e.preventDefault();
+ e?.preventDefault();
issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
@@ -166,7 +161,7 @@ export default class IssuableBulkUpdateSidebar {
}
static getCheckedIssueIds() {
- const $checkedIssues = $('.selected-issuable:checked');
+ const $checkedIssues = $('.issuable-list input[type="checkbox"]:checked');
if ($checkedIssues.length > 0) {
return $.map($checkedIssues, (value) => $(value).data('id'));
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index 4856f9781ce..cdeee68b762 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,7 +1,7 @@
import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar';
export default class IssuableIndex {
- constructor(pagePrefix) {
+ constructor(pagePrefix = 'issuable_') {
issuableInitBulkUpdateSidebar.init(pagePrefix);
}
}
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 92c527c79ff..5d497369f5a 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -65,6 +65,9 @@ export default {
labels() {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
+ labelIdsString() {
+ return JSON.stringify(this.labels.map((label) => label.id));
+ },
assignees() {
return this.issuable.assignees || [];
},
@@ -149,12 +152,13 @@ export default {
</script>
<template>
- <li class="issue gl-px-5!">
+ <li :id="`issuable_${issuable.id}`" class="issue gl-px-5!" :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="issuable.id"
@input="$emit('checked-input', $event)"
/>
</div>
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 40b0fcbb8c6..6b95c3a578e 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -10,7 +10,14 @@ import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
+const VueDraggable = () => import('vuedraggable');
+
export default {
+ vueDraggableAttributes: {
+ animation: 200,
+ ghostClass: 'gl-visibility-hidden',
+ tag: 'ul',
+ },
components: {
GlSkeletonLoading,
IssuableTabs,
@@ -18,6 +25,7 @@ export default {
IssuableItem,
IssuableBulkEditSidebar,
GlPagination,
+ VueDraggable,
},
props: {
namespace: {
@@ -127,6 +135,11 @@ export default {
required: false,
default: null,
},
+ isManualOrdering: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -159,6 +172,9 @@ export default {
return acc;
}, []);
},
+ issuablesWrapper() {
+ return this.isManualOrdering ? VueDraggable : 'ul';
+ },
},
watch: {
issuables(list) {
@@ -202,11 +218,16 @@ export default {
},
handleIssuableCheckedInput(issuable, value) {
this.checkedIssuables[this.issuableId(issuable)].checked = value;
+ this.$emit('update-legacy-bulk-edit');
},
handleAllIssuablesCheckedInput(value) {
Object.keys(this.checkedIssuables).forEach((issuableId) => {
this.checkedIssuables[issuableId].checked = value;
});
+ this.$emit('update-legacy-bulk-edit');
+ },
+ handleVueDraggableUpdate({ newIndex, oldIndex }) {
+ this.$emit('reorder', { newIndex, oldIndex });
},
},
};
@@ -253,13 +274,18 @@ export default {
<gl-skeleton-loading />
</li>
</ul>
- <ul
+ <component
+ :is="issuablesWrapper"
v-if="!issuablesLoading && issuables.length"
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"
@@ -284,7 +310,7 @@ export default {
<slot name="statistics" :issuable="issuable"></slot>
</template>
</issuable-item>
- </ul>
+ </component>
<slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
<gl-pagination
v-if="showPaginationControls"
diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
index 57da030e22e..6bc621b52e6 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
@@ -26,6 +26,9 @@ export default {
isTabActive(tabName) {
return tabName === this.currentTab;
},
+ isTabCountNumeric(tab) {
+ return Number.isInteger(this.tabCounts[tab.name]);
+ },
},
};
</script>
@@ -44,9 +47,13 @@ export default {
>
<template #title>
<span :title="tab.titleTooltip">{{ tab.title }}</span>
- <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-tab-counter-badge">{{
- tabCounts[tab.name]
- }}</gl-badge>
+ <gl-badge
+ v-if="isTabCountNumeric(tab)"
+ variant="neutral"
+ size="sm"
+ class="gl-tab-counter-badge"
+ >{{ tabCounts[tab.name] }}</gl-badge
+ >
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue
index fe102e942c9..05dc1650379 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_body.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue
@@ -42,6 +42,10 @@ export default {
type: Boolean,
required: true,
},
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
enableTaskList: {
type: Boolean,
required: false,
@@ -144,6 +148,7 @@ export default {
:issuable="issuable"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
:show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
index 6d139541524..33dca3e9332 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
@@ -4,6 +4,7 @@ import $ from 'jquery';
import Autosave from '~/autosave';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import ZenMode from '~/zen_mode';
import eventHub from '../event_hub';
@@ -27,6 +28,10 @@ export default {
type: Boolean,
required: true,
},
+ enableZenMode: {
+ type: Boolean,
+ required: true,
+ },
showFieldTitle: {
type: Boolean,
required: true,
@@ -62,6 +67,9 @@ export default {
},
mounted() {
if (this.enableAutosave) this.initAutosave();
+
+ // eslint-disable-next-line no-new
+ if (this.enableZenMode) new ZenMode();
},
beforeDestroy() {
eventHub.$off('update.issuable', this.resetAutosave);
diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
index b514a6b01d8..ca057094868 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -42,6 +42,11 @@ export default {
required: false,
default: true,
},
+ enableZenMode: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
enableTaskList: {
type: Boolean,
required: false,
@@ -120,6 +125,7 @@ export default {
:enable-edit="enableEdit"
:enable-autocomplete="enableAutocomplete"
:enable-autosave="enableAutosave"
+ :enable-zen-mode="enableZenMode"
:enable-task-list="enableTaskList"
:edit-form-visible="editFormVisible"
:show-field-title="showFieldTitle"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/issuable_show/components/issuable_title.vue
index b7ea4a010a3..b96ce0c43f7 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_title.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_title.vue
@@ -6,8 +6,12 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
components: {
GlIcon,
GlButton,
@@ -58,7 +62,8 @@ export default {
<gl-button
v-if="enableEdit"
v-gl-tooltip.bottom
- :title="__('Edit title and description')"
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
@click="$emit('edit-issuable', $event)"
diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
deleted file mode 100644
index 42e646391a8..00000000000
--- a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
+++ /dev/null
@@ -1,16 +0,0 @@
-#import "~/graphql_shared/fragments/author.fragment.graphql"
-
-query getProjectIssue($iid: String!, $fullPath: ID!) {
- project(fullPath: $fullPath) {
- issue(iid: $iid) {
- id
- assignees {
- nodes {
- ...Author
- id
- state
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue b/app/assets/javascripts/issuable_type_selector/components/info_popover.vue
new file mode 100644
index 00000000000..3a20ccba814
--- /dev/null
+++ b/app/assets/javascripts/issuable_type_selector/components/info_popover.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlIcon, GlPopover } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ issueTypes: __('Issue types'),
+ issue: __('Issue'),
+ incident: __('Incident'),
+ issueHelpText: __('For general work'),
+ incidentHelpText: __('For investigating IT service disruptions or outages'),
+ },
+ components: {
+ GlIcon,
+ GlPopover,
+ },
+};
+</script>
+
+<template>
+ <span id="popovercontainer">
+ <gl-icon id="issuable-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" />
+ <gl-popover
+ target="issuable-type-info"
+ container="popovercontainer"
+ :title="$options.i18n.issueTypes"
+ triggers="focus hover"
+ >
+ <ul class="gl-list-style-none gl-p-0 gl-m-0">
+ <li class="gl-mb-3">
+ <div class="gl-font-weight-bold">{{ $options.i18n.issue }}</div>
+ <span>{{ $options.i18n.issueHelpText }}</span>
+ </li>
+ <li>
+ <div class="gl-font-weight-bold">{{ $options.i18n.incident }}</div>
+ <span>{{ $options.i18n.incidentHelpText }}</span>
+ </li>
+ </ul>
+ </gl-popover>
+ </span>
+</template>
diff --git a/app/assets/javascripts/issuable_type_selector/index.js b/app/assets/javascripts/issuable_type_selector/index.js
new file mode 100644
index 00000000000..433a62d1ae8
--- /dev/null
+++ b/app/assets/javascripts/issuable_type_selector/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import InfoPopover from './components/info_popover.vue';
+
+export default function initIssuableTypeSelector() {
+ const el = document.getElementById('js-type-popover');
+
+ return new Vue({
+ el,
+ components: {
+ InfoPopover,
+ },
+ render(h) {
+ return h(InfoPopover);
+ },
+ });
+}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index 9b978483cc6..d153ff21a35 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -238,7 +238,7 @@ export default {
: '';
},
statusIcon() {
- return this.isClosed ? 'mobile-issue-close' : 'issue-open-m';
+ return this.isClosed ? 'issue-close' : 'issue-open-m';
},
statusText() {
return IssuableStatusText[this.issuableStatus];
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index 20c759cfbbd..7733e366c4f 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -47,7 +47,7 @@ export default {
},
deleteIssuableButtonText() {
return sprintf(__('Delete %{issuableType}'), {
- issuableType: issuableTypes[this.issuableType],
+ issuableType: issuableTypes[this.issuableType].toLowerCase(),
});
},
},
@@ -79,23 +79,23 @@ export default {
:loading="formState.updateLoading"
:disabled="formState.updateLoading || !isSubmitEnabled"
category="primary"
- variant="success"
- class="float-left qa-save-button"
+ variant="confirm"
+ class="float-left qa-save-button gl-mr-3"
type="submit"
@click.prevent="updateIssuable"
>
{{ __('Save changes') }}
</gl-button>
- <gl-button class="float-right" @click="closeForm">
+ <gl-button @click="closeForm">
{{ __('Cancel') }}
</gl-button>
<gl-button
v-if="shouldShowDeleteButton"
:loading="deleteLoading"
:disabled="deleteLoading"
- category="primary"
+ category="secondary"
variant="danger"
- class="float-right gl-mr-3 qa-delete-button"
+ class="float-right qa-delete-button"
@click="deleteIssuable"
>
{{ deleteIssuableButtonText }}
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
index 2f2c4c6e341..2bddbe4faa0 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
+import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
@@ -17,7 +17,6 @@ export default {
GlButton,
GlDropdown,
GlDropdownItem,
- GlIcon,
GlLink,
GlModal,
},
@@ -26,7 +25,6 @@ export default {
},
actionPrimary: {
text: __('Yes, close issue'),
- attributes: [{ variant: 'warning' }],
},
i18n: {
promoteErrorMessage: __(
@@ -88,9 +86,6 @@ export default {
qaSelector() {
return this.isClosed ? 'reopen_issue_button' : 'close_issue_button';
},
- buttonVariant() {
- return this.isClosed ? 'default' : 'warning';
- },
dropdownText() {
return sprintf(__('%{issueType} actions'), {
issueType: capitalizeFirstCharacter(this.issueType),
@@ -192,9 +187,9 @@ export default {
</script>
<template>
- <div class="detail-page-header-actions">
+ <div class="detail-page-header-actions gl-display-flex">
<gl-dropdown
- class="gl-display-block gl-sm-display-none!"
+ class="gl-sm-display-none! w-100"
block
:text="dropdownText"
:loading="isToggleStateButtonLoading"
@@ -224,26 +219,22 @@ export default {
<gl-button
v-if="showToggleIssueStateButton"
class="gl-display-none gl-sm-display-inline-flex!"
- category="secondary"
:data-qa-selector="qaSelector"
:loading="isToggleStateButtonLoading"
- :variant="buttonVariant"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-button>
<gl-dropdown
- class="gl-display-none gl-sm-display-inline-flex!"
- toggle-class="gl-border-0! gl-shadow-none!"
+ class="gl-display-none gl-sm-display-inline-flex! gl-ml-3"
+ icon="ellipsis_v"
+ category="tertiary"
+ :text="dropdownText"
+ :text-sr-only="true"
no-caret
right
>
- <template #button-content>
- <gl-icon name="ellipsis_v" />
- <span class="gl-sr-only">{{ dropdownText }}</span>
- </template>
-
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index 806d95ca748..5e92211685a 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -1,9 +1,13 @@
<script>
import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { __ } from '~/locale';
import eventHub from '../event_hub';
import animateMixin from '../mixins/animate';
export default {
+ i18n: {
+ editTitleAndDescription: __('Edit title and description'),
+ },
components: {
GlButton,
},
@@ -78,7 +82,8 @@ export default {
v-gl-tooltip.bottom
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
- title="Edit title and description"
+ :title="$options.i18n.editTitleAndDescription"
+ :aria-label="$options.i18n.editTitleAndDescription"
@click="edit"
/>
</div>
diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
index 0b413ce0b06..51cad662ebf 100644
--- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -30,6 +30,9 @@ import issueableEventHub from '../eventhub';
import { emptyStateHelper } from '../service_desk_helper';
import Issuable from './issuable.vue';
+/**
+ * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead
+ */
export default {
LOADING_LIST_ITEMS_LENGTH,
directives: {
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 c57fa5a82fa..57c5107fcbb 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -1,19 +1,63 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
+import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
-import { IssuableStatus } from '~/issue_show/constants';
-import { PAGE_SIZE } from '~/issues_list/constants';
+import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
+import {
+ CREATED_DESC,
+ PAGE_SIZE,
+ RELATIVE_POSITION_ASC,
+ sortOptions,
+ sortParams,
+} from '~/issues_list/constants';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
+ CREATED_DESC,
+ IssuableListTabs,
PAGE_SIZE,
+ sortOptions,
+ sortParams,
+ i18n: {
+ calendarLabel: __('Subscribe to calendar'),
+ jiraIntegrationMessage: s__(
+ 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.',
+ ),
+ jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'),
+ jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'),
+ newIssueLabel: __('New issue'),
+ noClosedIssuesTitle: __('There are no closed issues'),
+ noOpenIssuesDescription: __('To keep this project going, create a new issue'),
+ noOpenIssuesTitle: __('There are no open issues'),
+ noIssuesSignedInDescription: __(
+ 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
+ ),
+ noIssuesSignedInTitle: __(
+ 'The Issue Tracker is the place to add things that need to be improved or solved in a project',
+ ),
+ noIssuesSignedOutButtonText: __('Register / Sign In'),
+ noIssuesSignedOutDescription: __(
+ 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
+ ),
+ noIssuesSignedOutTitle: __('There are no issues to show'),
+ noSearchResultsDescription: __('To widen your search, change or remove filters above'),
+ noSearchResultsTitle: __('Sorry, your filter produced no results'),
+ reorderError: __('An error occurred while reordering issues.'),
+ rssLabel: __('Subscribe to RSS feed'),
+ },
components: {
+ CsvImportExportButtons,
+ GlButton,
+ GlEmptyState,
GlIcon,
+ GlLink,
+ GlSprintf,
IssuableList,
IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
@@ -22,49 +66,147 @@ export default {
GlTooltip: GlTooltipDirective,
},
inject: {
+ calendarPath: {
+ default: '',
+ },
+ canBulkUpdate: {
+ default: false,
+ },
+ emptyStateSvgPath: {
+ default: '',
+ },
endpoint: {
default: '',
},
+ exportCsvPath: {
+ default: '',
+ },
fullPath: {
default: '',
},
+ hasIssues: {
+ default: false,
+ },
+ isSignedIn: {
+ default: false,
+ },
+ issuesPath: {
+ default: '',
+ },
+ jiraIntegrationPath: {
+ default: '',
+ },
+ newIssuePath: {
+ default: '',
+ },
+ rssPath: {
+ default: '',
+ },
+ showNewIssueLink: {
+ default: false,
+ },
+ signInPath: {
+ default: '',
+ },
},
data() {
+ const orderBy = getParameterByName('order_by');
+ const sort = getParameterByName('sort');
+ const sortKey = Object.keys(sortParams).find(
+ (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort,
+ );
+
+ const search = getParameterByName('search') || '';
+ const tokens = search.split(' ').map((searchWord) => ({
+ type: 'filtered-search-term',
+ value: {
+ data: searchWord,
+ },
+ }));
+
return {
- currentPage: toNumber(getParameterByName('page')) || 1,
+ exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
+ filters: sortParams[sortKey] || {},
+ filterTokens: tokens,
isLoading: false,
issues: [],
+ page: toNumber(getParameterByName('page')) || 1,
+ showBulkEditSidebar: false,
+ sortKey: sortKey || CREATED_DESC,
+ state: getParameterByName('state') || IssuableStates.Opened,
totalIssues: 0,
};
},
computed: {
+ isManualOrdering() {
+ return this.sortKey === RELATIVE_POSITION_ASC;
+ },
+ isOpenTab() {
+ return this.state === IssuableStates.Opened;
+ },
+ searchQuery() {
+ return (
+ this.filterTokens
+ .map((searchTerm) => searchTerm.value.data)
+ .filter((searchWord) => Boolean(searchWord))
+ .join(' ') || undefined
+ );
+ },
+ showPaginationControls() {
+ return this.issues.length > 0;
+ },
+ tabCounts() {
+ return Object.values(IssuableStates).reduce(
+ (acc, state) => ({
+ ...acc,
+ [state]: this.state === state ? this.totalIssues : undefined,
+ }),
+ {},
+ );
+ },
urlParams() {
return {
- page: this.currentPage,
- state: IssuableStatus.Open,
+ page: this.page,
+ search: this.searchQuery,
+ state: this.state,
+ ...this.filters,
};
},
},
mounted() {
+ eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => {
+ this.showBulkEditSidebar = showBulkEditSidebar;
+ });
this.fetchIssues();
},
+ beforeDestroy() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
+ eventHub.$off('issuables:toggleBulkEdit');
+ },
methods: {
- fetchIssues(pageToFetch) {
+ fetchIssues() {
+ if (!this.hasIssues) {
+ return undefined;
+ }
+
this.isLoading = true;
return axios
.get(this.endpoint, {
params: {
- page: pageToFetch || this.currentPage,
+ page: this.page,
per_page: this.$options.PAGE_SIZE,
- state: IssuableStatus.Open,
+ search: this.searchQuery,
+ state: this.state,
with_labels_details: true,
+ ...this.filters,
},
})
.then(({ data, headers }) => {
- this.currentPage = Number(headers['x-page']);
+ this.page = Number(headers['x-page']);
this.totalIssues = Number(headers['x-total']);
this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true }));
+ this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
})
.catch(() => {
createFlash({ message: __('An error occurred while loading issues') });
@@ -73,8 +215,71 @@ export default {
this.isLoading = false;
});
},
+ getExportCsvPathWithQuery() {
+ return `${this.exportCsvPath}${window.location.search}`;
+ },
+ handleUpdateLegacyBulkEdit() {
+ // If "select all" checkbox was checked, wait for all checkboxes
+ // to be checked before updating IssuableBulkUpdateSidebar class
+ this.$nextTick(() => {
+ eventHub.$emit('issuables:updateBulkEdit');
+ });
+ },
+ handleBulkUpdateClick() {
+ eventHub.$emit('issuables:enableBulkEdit');
+ },
+ handleClickTab(state) {
+ if (this.state !== state) {
+ this.page = 1;
+ }
+ this.state = state;
+ this.fetchIssues();
+ },
+ handleFilter(filter) {
+ this.filterTokens = filter;
+ this.fetchIssues();
+ },
handlePageChange(page) {
- this.fetchIssues(page);
+ this.page = page;
+ this.fetchIssues();
+ },
+ handleReorder({ newIndex, oldIndex }) {
+ const issueToMove = this.issues[oldIndex];
+ const isDragDropDownwards = newIndex > oldIndex;
+ const isMovingToBeginning = newIndex === 0;
+ const isMovingToEnd = newIndex === this.issues.length - 1;
+
+ let moveBeforeId;
+ let moveAfterId;
+
+ if (isDragDropDownwards) {
+ const afterIndex = isMovingToEnd ? newIndex : newIndex + 1;
+ moveBeforeId = this.issues[newIndex].id;
+ moveAfterId = this.issues[afterIndex].id;
+ } else {
+ const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1;
+ moveBeforeId = this.issues[beforeIndex].id;
+ moveAfterId = this.issues[newIndex].id;
+ }
+
+ return axios
+ .put(`${this.issuesPath}/${issueToMove.iid}/reorder`, {
+ move_before_id: isMovingToBeginning ? null : moveBeforeId,
+ move_after_id: isMovingToEnd ? null : moveAfterId,
+ })
+ .then(() => {
+ // Move issue to new position in list
+ this.issues.splice(oldIndex, 1);
+ this.issues.splice(newIndex, 0, issueToMove);
+ })
+ .catch(() => {
+ createFlash({ message: this.$options.i18n.reorderError });
+ });
+ },
+ handleSort(value) {
+ this.sortKey = value;
+ this.filters = sortParams[value];
+ this.fetchIssues();
},
},
};
@@ -82,26 +287,70 @@ export default {
<template>
<issuable-list
+ v-if="hasIssues"
:namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="[]"
- :sort-options="[]"
+ :initial-filter-value="filterTokens"
+ :sort-options="$options.sortOptions"
+ :initial-sort-by="sortKey"
:issuables="issues"
- :tabs="[]"
- current-tab=""
+ :tabs="$options.IssuableListTabs"
+ :current-tab="state"
+ :tab-counts="tabCounts"
:issuables-loading="isLoading"
- :show-pagination-controls="true"
+ :is-manual-ordering="isManualOrdering"
+ :show-bulk-edit-sidebar="showBulkEditSidebar"
+ :show-pagination-controls="showPaginationControls"
:total-items="totalIssues"
- :current-page="currentPage"
- :previous-page="currentPage - 1"
- :next-page="currentPage + 1"
+ :current-page="page"
+ :previous-page="page - 1"
+ :next-page="page + 1"
:url-params="urlParams"
+ @click-tab="handleClickTab"
+ @filter="handleFilter"
@page-change="handlePageChange"
+ @reorder="handleReorder"
+ @sort="handleSort"
+ @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
>
+ <template #nav-actions>
+ <gl-button
+ v-gl-tooltip
+ :href="rssPath"
+ icon="rss"
+ :title="$options.i18n.rssLabel"
+ :aria-label="$options.i18n.rssLabel"
+ />
+ <gl-button
+ v-gl-tooltip
+ :href="calendarPath"
+ icon="calendar"
+ :title="$options.i18n.calendarLabel"
+ :aria-label="$options.i18n.calendarLabel"
+ />
+ <csv-import-export-buttons
+ class="gl-mr-3"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="totalIssues"
+ />
+ <gl-button
+ v-if="canBulkUpdate"
+ :disabled="showBulkEditSidebar"
+ @click="handleBulkUpdateClick"
+ >
+ {{ __('Edit issues') }}
+ </gl-button>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </template>
+
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
+
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
@@ -139,5 +388,81 @@ export default {
:is-list-item="true"
/>
</template>
+
+ <template #empty-state>
+ <gl-empty-state
+ v-if="searchQuery"
+ :description="$options.i18n.noSearchResultsDescription"
+ :title="$options.i18n.noSearchResultsTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+
+ <gl-empty-state
+ v-else-if="isOpenTab"
+ :description="$options.i18n.noOpenIssuesDescription"
+ :title="$options.i18n.noOpenIssuesTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+
+ <gl-empty-state
+ v-else
+ :title="$options.i18n.noClosedIssuesTitle"
+ :svg-path="emptyStateSvgPath"
+ />
+ </template>
</issuable-list>
+
+ <div v-else-if="isSignedIn">
+ <gl-empty-state
+ :description="$options.i18n.noIssuesSignedInDescription"
+ :title="$options.i18n.noIssuesSignedInTitle"
+ :svg-path="emptyStateSvgPath"
+ >
+ <template #actions>
+ <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
+ {{ $options.i18n.newIssueLabel }}
+ </gl-button>
+ <csv-import-export-buttons
+ class="gl-mr-3"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="totalIssues"
+ />
+ </template>
+ </gl-empty-state>
+ <hr />
+ <p class="gl-text-center gl-font-weight-bold gl-mb-0">
+ {{ $options.i18n.jiraIntegrationTitle }}
+ </p>
+ <p class="gl-text-center gl-mb-0">
+ <gl-sprintf :message="$options.i18n.jiraIntegrationMessage">
+ <template #jiraDocsLink="{ content }">
+ <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-text-center gl-text-gray-500">
+ {{ $options.i18n.jiraIntegrationSecondaryMessage }}
+ </p>
+ </div>
+
+ <gl-empty-state
+ v-else
+ :description="$options.i18n.noIssuesSignedOutDescription"
+ :title="$options.i18n.noIssuesSignedOutTitle"
+ :svg-path="emptyStateSvgPath"
+ :primary-button-text="$options.i18n.noIssuesSignedOutButtonText"
+ :primary-button-link="signInPath"
+ />
</template>
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index f008ba1bf4a..f6f23af80ba 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -54,3 +54,191 @@ export const availableSortOptionsJira = [
];
export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map';
+
+export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
+export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
+export const CREATED_ASC = 'CREATED_ASC';
+export const CREATED_DESC = 'CREATED_DESC';
+export const DUE_DATE_ASC = 'DUE_DATE_ASC';
+export const DUE_DATE_DESC = 'DUE_DATE_DESC';
+export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC';
+export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC';
+export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC';
+export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC';
+export const POPULARITY_ASC = 'POPULARITY_ASC';
+export const POPULARITY_DESC = 'POPULARITY_DESC';
+export const PRIORITY_ASC = 'PRIORITY_ASC';
+export const PRIORITY_DESC = 'PRIORITY_DESC';
+export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
+export const UPDATED_ASC = 'UPDATED_ASC';
+export const UPDATED_DESC = 'UPDATED_DESC';
+export const WEIGHT_ASC = 'WEIGHT_ASC';
+export const WEIGHT_DESC = 'WEIGHT_DESC';
+
+const SORT_ASC = 'asc';
+const SORT_DESC = 'desc';
+
+const BLOCKING_ISSUES = 'blocking_issues';
+
+export const sortParams = {
+ [PRIORITY_ASC]: {
+ order_by: PRIORITY,
+ sort: SORT_ASC,
+ },
+ [PRIORITY_DESC]: {
+ order_by: PRIORITY,
+ sort: SORT_DESC,
+ },
+ [CREATED_ASC]: {
+ order_by: CREATED_AT,
+ sort: SORT_ASC,
+ },
+ [CREATED_DESC]: {
+ order_by: CREATED_AT,
+ sort: SORT_DESC,
+ },
+ [UPDATED_ASC]: {
+ order_by: UPDATED_AT,
+ sort: SORT_ASC,
+ },
+ [UPDATED_DESC]: {
+ order_by: UPDATED_AT,
+ sort: SORT_DESC,
+ },
+ [MILESTONE_DUE_ASC]: {
+ order_by: MILESTONE_DUE,
+ sort: SORT_ASC,
+ },
+ [MILESTONE_DUE_DESC]: {
+ order_by: MILESTONE_DUE,
+ sort: SORT_DESC,
+ },
+ [DUE_DATE_ASC]: {
+ order_by: DUE_DATE,
+ sort: SORT_ASC,
+ },
+ [DUE_DATE_DESC]: {
+ order_by: DUE_DATE,
+ sort: SORT_DESC,
+ },
+ [POPULARITY_ASC]: {
+ order_by: POPULARITY,
+ sort: SORT_ASC,
+ },
+ [POPULARITY_DESC]: {
+ order_by: POPULARITY,
+ sort: SORT_DESC,
+ },
+ [LABEL_PRIORITY_ASC]: {
+ order_by: LABEL_PRIORITY,
+ sort: SORT_ASC,
+ },
+ [LABEL_PRIORITY_DESC]: {
+ order_by: LABEL_PRIORITY,
+ sort: SORT_DESC,
+ },
+ [RELATIVE_POSITION_ASC]: {
+ order_by: RELATIVE_POSITION,
+ per_page: 100,
+ sort: SORT_ASC,
+ },
+ [WEIGHT_ASC]: {
+ order_by: WEIGHT,
+ sort: SORT_ASC,
+ },
+ [WEIGHT_DESC]: {
+ order_by: WEIGHT,
+ sort: SORT_DESC,
+ },
+ [BLOCKING_ISSUES_ASC]: {
+ order_by: BLOCKING_ISSUES,
+ sort: SORT_ASC,
+ },
+ [BLOCKING_ISSUES_DESC]: {
+ order_by: BLOCKING_ISSUES,
+ sort: SORT_DESC,
+ },
+};
+
+export const sortOptions = [
+ {
+ id: 1,
+ title: __('Priority'),
+ sortDirection: {
+ ascending: PRIORITY_ASC,
+ descending: PRIORITY_DESC,
+ },
+ },
+ {
+ id: 2,
+ title: __('Created date'),
+ sortDirection: {
+ ascending: CREATED_ASC,
+ descending: CREATED_DESC,
+ },
+ },
+ {
+ id: 3,
+ title: __('Last updated'),
+ sortDirection: {
+ ascending: UPDATED_ASC,
+ descending: UPDATED_DESC,
+ },
+ },
+ {
+ id: 4,
+ title: __('Milestone due date'),
+ sortDirection: {
+ ascending: MILESTONE_DUE_ASC,
+ descending: MILESTONE_DUE_DESC,
+ },
+ },
+ {
+ id: 5,
+ title: __('Due date'),
+ sortDirection: {
+ ascending: DUE_DATE_ASC,
+ descending: DUE_DATE_DESC,
+ },
+ },
+ {
+ id: 6,
+ title: __('Popularity'),
+ sortDirection: {
+ ascending: POPULARITY_ASC,
+ descending: POPULARITY_DESC,
+ },
+ },
+ {
+ id: 7,
+ title: __('Label priority'),
+ sortDirection: {
+ ascending: LABEL_PRIORITY_ASC,
+ descending: LABEL_PRIORITY_DESC,
+ },
+ },
+ {
+ id: 8,
+ title: __('Manual'),
+ sortDirection: {
+ ascending: RELATIVE_POSITION_ASC,
+ descending: RELATIVE_POSITION_ASC,
+ },
+ },
+ {
+ id: 9,
+ title: __('Weight'),
+ sortDirection: {
+ ascending: WEIGHT_ASC,
+ descending: WEIGHT_DESC,
+ },
+ },
+ {
+ id: 10,
+ title: __('Blocking'),
+ sortDirection: {
+ ascending: BLOCKING_ISSUES_ASC,
+ descending: BLOCKING_ISSUES_DESC,
+ },
+ },
+];
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index a283cbdc86b..0b64df50691 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -73,11 +73,29 @@ export function initIssuesListApp() {
}
const {
+ calendarPath,
+ canBulkUpdate,
+ canEdit,
+ canImportIssues,
+ email,
+ emptyStateSvgPath,
endpoint,
+ exportCsvPath,
fullPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
+ hasIssues,
hasIssueWeightsFeature,
+ importCsvIssuesPath,
+ isSignedIn,
+ issuesPath,
+ jiraIntegrationPath,
+ maxAttachmentSize,
+ newIssuePath,
+ projectImportJiraPath,
+ rssPath,
+ showNewIssueLink,
+ signInPath,
} = el.dataset;
return new Vue({
@@ -86,11 +104,32 @@ export function initIssuesListApp() {
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
+ calendarPath,
+ canBulkUpdate: parseBoolean(canBulkUpdate),
+ emptyStateSvgPath,
endpoint,
fullPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
+ hasIssues: parseBoolean(hasIssues),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+ isSignedIn: parseBoolean(isSignedIn),
+ issuesPath,
+ jiraIntegrationPath,
+ newIssuePath,
+ rssPath,
+ showNewIssueLink: parseBoolean(showNewIssueLink),
+ signInPath,
+ // For CsvImportExportButtons component
+ canEdit: parseBoolean(canEdit),
+ email,
+ exportCsvPath,
+ importCsvIssuesPath,
+ maxAttachmentSize,
+ projectImportJiraPath,
+ showExportButton: parseBoolean(hasIssues),
+ showImportButton: parseBoolean(canImportIssues),
+ showLabel: !parseBoolean(hasIssues),
},
render: (createComponent) => createComponent(IssuesListApp),
});
diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/api.js
index d78aba0a3f7..abf2c070e68 100644
--- a/app/assets/javascripts/jira_connect/api.js
+++ b/app/assets/javascripts/jira_connect/api.js
@@ -1,24 +1,5 @@
import axios from 'axios';
-
-export const getJwt = () => {
- return new Promise((resolve) => {
- AP.context.getToken((token) => {
- resolve(token);
- });
- });
-};
-
-export const getLocation = () => {
- return new Promise((resolve) => {
- if (typeof AP.getLocation !== 'function') {
- resolve();
- }
-
- AP.getLocation((location) => {
- resolve(location);
- });
- });
-};
+import { getJwt } from '~/jira_connect/utils';
export const addSubscription = async (addPath, namespace) => {
const jwt = await getJwt();
@@ -39,11 +20,12 @@ export const removeSubscription = async (removePath) => {
});
};
-export const fetchGroups = async (groupsPath, { page, perPage }) => {
+export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
return axios.get(groupsPath, {
params: {
page,
per_page: perPage,
+ search,
},
});
};
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue
index fe5ad8b67d7..ff4dfb23687 100644
--- a/app/assets/javascripts/jira_connect/components/app.vue
+++ b/app/assets/javascripts/jira_connect/components/app.vue
@@ -1,27 +1,26 @@
<script>
-import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapMutations } from 'vuex';
-import { getLocation } from '~/jira_connect/api';
+import { retrieveAlert, getLocation } from '~/jira_connect/utils';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SET_ALERT } from '../store/mutation_types';
-import { retrieveAlert } from '../utils';
import GroupsList from './groups_list.vue';
+import SubscriptionsList from './subscriptions_list.vue';
export default {
name: 'JiraConnectApp',
components: {
GlAlert,
GlButton,
- GlModal,
- GroupsList,
GlLink,
+ GlModal,
GlSprintf,
+ GroupsList,
+ SubscriptionsList,
},
directives: {
GlModalDirective,
},
- mixins: [glFeatureFlagsMixin()],
inject: {
usersPath: {
default: '',
@@ -91,37 +90,36 @@ export default {
<h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
- <div
- class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
- >
- <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading">
- {{ s__('Integrations|Linked namespaces') }}
- </h5>
- <gl-button
- v-if="usersPath"
- category="primary"
- variant="info"
- class="gl-align-self-center"
- :href="usersPathWithReturnTo"
- target="_blank"
- >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button
- >
- <template v-else>
+ <div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4">
+ <div class="gl-display-flex gl-justify-content-end">
<gl-button
- v-gl-modal-directive="'add-namespace-modal'"
+ v-if="usersPath"
category="primary"
variant="info"
class="gl-align-self-center"
- >{{ s__('Integrations|Add namespace') }}</gl-button
- >
- <gl-modal
- modal-id="add-namespace-modal"
- :title="s__('Integrations|Link namespaces')"
- :action-cancel="$options.modal.cancelProps"
+ :href="usersPathWithReturnTo"
+ target="_blank"
+ >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button
>
- <groups-list />
- </gl-modal>
- </template>
+ <template v-else>
+ <gl-button
+ v-gl-modal-directive="'add-namespace-modal'"
+ category="primary"
+ variant="info"
+ class="gl-align-self-center"
+ >{{ s__('Integrations|Add namespace') }}</gl-button
+ >
+ <gl-modal
+ modal-id="add-namespace-modal"
+ :title="s__('Integrations|Link namespaces')"
+ :action-cancel="$options.modal.cancelProps"
+ >
+ <groups-list />
+ </gl-modal>
+ </template>
+ </div>
+
+ <subscriptions-list />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/components/group_item_name.vue b/app/assets/javascripts/jira_connect/components/group_item_name.vue
new file mode 100644
index 00000000000..e6c172dae9e
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/components/group_item_name.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlAvatar, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAvatar,
+ GlIcon,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon name="folder-o" class="gl-mr-3" />
+ <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3">
+ <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" />
+ </div>
+
+ <div>
+ <span class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold">
+ {{ group.full_name }}
+ </span>
+ <div v-if="group.description">
+ <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue
index 69f2903388c..275ff820419 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui';
+import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui';
import { fetchGroups } from '~/jira_connect/api';
import { defaultPerPage } from '~/jira_connect/constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
@@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue';
export default {
components: {
- GlTabs,
- GlTab,
GlLoadingIcon,
GlPagination,
GlAlert,
+ GlSearchBoxByType,
GroupsListItem,
},
inject: {
@@ -23,7 +22,8 @@ export default {
data() {
return {
groups: [],
- isLoading: false,
+ isLoadingInitial: true,
+ isLoadingMore: false,
page: 1,
perPage: defaultPerPage,
totalItems: 0,
@@ -31,15 +31,18 @@ export default {
};
},
mounted() {
- this.loadGroups();
+ return this.loadGroups().finally(() => {
+ this.isLoadingInitial = false;
+ });
},
methods: {
- loadGroups() {
- this.isLoading = true;
+ loadGroups({ searchTerm } = {}) {
+ this.isLoadingMore = true;
- fetchGroups(this.groupsPath, {
+ return fetchGroups(this.groupsPath, {
page: this.page,
perPage: this.perPage,
+ search: searchTerm,
})
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
@@ -51,50 +54,61 @@ export default {
this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.');
})
.finally(() => {
- this.isLoading = false;
+ this.isLoadingMore = false;
});
},
+ onGroupSearch(searchTerm) {
+ return this.loadGroups({ searchTerm });
+ },
},
};
</script>
<template>
<div>
- <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null">
+ <gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = null">
{{ errorMessage }}
</gl-alert>
- <gl-tabs>
- <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3">
- <gl-loading-icon v-if="isLoading" size="md" />
- <div v-else-if="groups.length === 0" class="gl-text-center">
- <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
- <p class="gl-mt-5">
- {{
- s__('Integrations|You must have owner or maintainer permissions to link namespaces.')
- }}
- </p>
- </div>
- <ul v-else class="gl-list-style-none gl-pl-0">
- <groups-list-item
- v-for="group in groups"
- :key="group.id"
- :group="group"
- @error="errorMessage = $event"
- />
- </ul>
+ <gl-search-box-by-type
+ class="gl-mb-5"
+ debounce="500"
+ :placeholder="__('Search by name')"
+ :is-loading="isLoadingMore"
+ @input="onGroupSearch"
+ />
+
+ <gl-loading-icon v-if="isLoadingInitial" size="md" />
+ <div v-else-if="groups.length === 0" class="gl-text-center">
+ <h5>{{ s__('Integrations|No available namespaces.') }}</h5>
+ <p class="gl-mt-5">
+ {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }}
+ </p>
+ </div>
+ <ul
+ v-else
+ class="gl-list-style-none gl-pl-0 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
+ :class="{ 'gl-opacity-5': isLoadingMore }"
+ data-testid="groups-list"
+ >
+ <groups-list-item
+ v-for="group in groups"
+ :key="group.id"
+ :group="group"
+ :disabled="isLoadingMore"
+ @error="errorMessage = $event"
+ />
+ </ul>
- <div class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-pagination
- v-if="totalItems > perPage && groups.length > 0"
- v-model="page"
- class="gl-mb-0"
- :per-page="perPage"
- :total-items="totalItems"
- @input="loadGroups"
- />
- </div>
- </gl-tab>
- </gl-tabs>
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-pagination
+ v-if="totalItems > perPage && groups.length > 0"
+ v-model="page"
+ class="gl-mb-0"
+ :per-page="perPage"
+ :total-items="totalItems"
+ @input="loadGroups"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
index b8959a2a505..ad046920dd1 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue
@@ -1,15 +1,15 @@
<script>
-import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { addSubscription } from '~/jira_connect/api';
+import { persistAlert, reloadPage } from '~/jira_connect/utils';
import { s__ } from '~/locale';
-import { persistAlert } from '../utils';
+import GroupItemName from './group_item_name.vue';
export default {
components: {
- GlAvatar,
GlButton,
- GlIcon,
+ GroupItemName,
},
inject: {
subscriptionsPath: {
@@ -21,6 +21,11 @@ export default {
type: Object,
required: true,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -42,7 +47,7 @@ export default {
variant: 'success',
});
- AP.navigator.reload();
+ reloadPage();
})
.catch((error) => {
this.$emit(
@@ -50,8 +55,6 @@ export default {
error?.response?.data?.error ||
s__('Integrations|Failed to link namespace. Please try again.'),
);
- })
- .finally(() => {
this.isLoading = false;
});
},
@@ -60,34 +63,22 @@ export default {
</script>
<template>
- <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200">
+ <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
<div class="gl-display-flex gl-align-items-center gl-py-3">
- <gl-icon name="folder-o" class="gl-mr-3" />
- <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3">
- <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" />
- </div>
<div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center">
<div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1">
- <div class="gl-display-flex gl-align-items-center gl-flex-wrap">
- <span
- class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold"
- data-testid="group-list-item-name"
- >
- {{ group.full_name }}
- </span>
- </div>
- <div v-if="group.description" data-testid="group-list-item-description">
- <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p>
- </div>
+ <group-item-name :group="group" />
</div>
<gl-button
category="secondary"
- variant="success"
+ variant="confirm"
:loading="isLoading"
+ :disabled="disabled"
@click.prevent="onClick"
- >{{ __('Link') }}</gl-button
>
+ {{ __('Link') }}
+ </gl-button>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/jira_connect/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue
new file mode 100644
index 00000000000..a606e2edbbb
--- /dev/null
+++ b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { mapMutations } from 'vuex';
+import { removeSubscription } from '~/jira_connect/api';
+import { reloadPage } from '~/jira_connect/utils';
+import { __, s__ } from '~/locale';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { SET_ALERT } from '../store/mutation_types';
+import GroupItemName from './group_item_name.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlEmptyState,
+ GlTable,
+ GroupItemName,
+ TimeagoTooltip,
+ },
+ inject: {
+ subscriptions: {
+ default: [],
+ },
+ },
+ data() {
+ return {
+ loadingItem: null,
+ };
+ },
+ fields: [
+ {
+ key: 'name',
+ label: s__('Integrations|Linked namespaces'),
+ },
+ {
+ key: 'created_at',
+ label: __('Added'),
+ tdClass: 'gl-vertical-align-middle! gl-w-20p',
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'gl-text-right gl-vertical-align-middle! gl-pl-0!',
+ },
+ ],
+ i18n: {
+ emptyTitle: s__('Integrations|No linked namespaces'),
+ emptyDescription: s__(
+ 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.',
+ ),
+ unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'),
+ },
+ methods: {
+ ...mapMutations({
+ setAlert: SET_ALERT,
+ }),
+ isEmpty,
+ isLoadingItem(item) {
+ return this.loadingItem === item;
+ },
+ unlinkBtnClass(item) {
+ return this.isLoadingItem(item) ? '' : 'gl-ml-6';
+ },
+ onClick(item) {
+ this.loadingItem = item;
+
+ removeSubscription(item.unlink_path)
+ .then(() => {
+ reloadPage();
+ })
+ .catch((error) => {
+ this.setAlert({
+ message: error?.response?.data?.error || this.$options.i18n.unlinkError,
+ variant: 'danger',
+ });
+ this.loadingItem = null;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-empty-state
+ v-if="isEmpty(subscriptions)"
+ :title="$options.i18n.emptyTitle"
+ :description="$options.i18n.emptyDescription"
+ />
+ <gl-table v-else :items="subscriptions" :fields="$options.fields">
+ <template #cell(name)="{ item }">
+ <group-item-name :group="item.group" />
+ </template>
+ <template #cell(created_at)="{ item }">
+ <timeago-tooltip :time="item.created_at" />
+ </template>
+ <template #cell(actions)="{ item }">
+ <gl-button
+ :class="unlinkBtnClass(item)"
+ category="secondary"
+ :loading="isLoadingItem(item)"
+ :disabled="!isEmpty(loadingItem)"
+ @click.prevent="onClick(item)"
+ >{{ __('Unlink') }}</gl-button
+ >
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
index ecdb41607a4..dc8bb3b0c77 100644
--- a/app/assets/javascripts/jira_connect/index.js
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -1,25 +1,14 @@
import setConfigs from '@gitlab/ui/dist/config';
import Vue from 'vue';
-import { addSubscription, removeSubscription, getLocation } from '~/jira_connect/api';
+import { getLocation, sizeToParent } from '~/jira_connect/utils';
import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin';
import Translate from '~/vue_shared/translate';
import JiraConnectApp from './components/app.vue';
import createStore from './store';
-import { SET_ALERT } from './store/mutation_types';
const store = createStore();
-const reqComplete = () => {
- AP.navigator.reload();
-};
-
-const reqFailed = (res, fallbackErrorMessage) => {
- const { error = fallbackErrorMessage } = res || {};
-
- store.commit(SET_ALERT, { message: error, variant: 'danger' });
-};
-
const updateSignInLinks = async () => {
const location = await getLocation();
Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => {
@@ -28,43 +17,7 @@ const updateSignInLinks = async () => {
});
};
-const initRemoveSubscriptionButtonHandlers = () => {
- Array.from(document.querySelectorAll('.js-jira-connect-remove-subscription')).forEach((el) => {
- el.addEventListener('click', function onRemoveSubscriptionClick(e) {
- e.preventDefault();
-
- const removePath = e.target.getAttribute('href');
- removeSubscription(removePath)
- .then(reqComplete)
- .catch((err) =>
- reqFailed(err.response.data, 'Failed to remove namespace. Please try again.'),
- );
- });
- });
-};
-
-const initAddSubscriptionFormHandler = () => {
- const formEl = document.querySelector('#add-subscription-form');
- if (!formEl) {
- return;
- }
-
- formEl.addEventListener('submit', function onAddSubscriptionForm(e) {
- e.preventDefault();
-
- const addPath = e.target.getAttribute('action');
- const namespace = (e.target.querySelector('#namespace-input') || {}).value;
-
- addSubscription(addPath, namespace)
- .then(reqComplete)
- .catch((err) => reqFailed(err.response.data, 'Failed to add namespace. Please try again.'));
- });
-};
-
export async function initJiraConnect() {
- initAddSubscriptionFormHandler();
- initRemoveSubscriptionButtonHandlers();
-
await updateSignInLinks();
const el = document.querySelector('.js-jira-connect-app');
@@ -76,14 +29,15 @@ export async function initJiraConnect() {
Vue.use(Translate);
Vue.use(GlFeatureFlagsPlugin);
- const { groupsPath, subscriptionsPath, usersPath } = el.dataset;
- AP.sizeToParent();
+ const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset;
+ sizeToParent();
return new Vue({
el,
store,
provide: {
groupsPath,
+ subscriptions: JSON.parse(subscriptions),
subscriptionsPath,
usersPath,
},
diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js
index 2a6c53ba42c..ecd1a31339a 100644
--- a/app/assets/javascripts/jira_connect/utils.js
+++ b/app/assets/javascripts/jira_connect/utils.js
@@ -1,6 +1,8 @@
import AccessorUtilities from '~/lib/utils/accessor';
import { ALERT_LOCALSTORAGE_KEY } from './constants';
+const isFunction = (fn) => typeof fn === 'function';
+
/**
* Persist alert data to localStorage.
*/
@@ -31,3 +33,41 @@ export const retrieveAlert = () => {
return JSON.parse(initialAlertJSON);
};
+
+export const getJwt = () => {
+ return new Promise((resolve) => {
+ if (isFunction(AP?.context?.getToken)) {
+ AP.context.getToken((token) => {
+ resolve(token);
+ });
+ } else {
+ resolve();
+ }
+ });
+};
+
+export const getLocation = () => {
+ return new Promise((resolve) => {
+ if (isFunction(AP?.getLocation)) {
+ AP.getLocation((location) => {
+ resolve(location);
+ });
+ } else {
+ resolve();
+ }
+ });
+};
+
+export const reloadPage = () => {
+ if (isFunction(AP?.navigator?.reload)) {
+ AP.navigator.reload();
+ } else {
+ window.location.reload();
+ }
+};
+
+export const sizeToParent = () => {
+ if (isFunction(AP?.sizeToParent)) {
+ AP.sizeToParent();
+ }
+};
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index eae6b5d5419..7f25ca8a94d 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlLink } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -23,9 +22,9 @@ export default {
</script>
<template>
<div>
- <span class="font-weight-bold">{{ __('Commit') }}</span>
+ <span class="gl-font-weight-bold">{{ __('Commit') }}</span>
- <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ <gl-link :href="commit.commit_path" class="gl-text-blue-600!" data-testid="commit-sha">
{{ commit.short_id }}
</gl-link>
@@ -37,8 +36,8 @@ export default {
/>
<span v-if="mergeRequest">
- in
- <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
+ {{ __('in') }}
+ <gl-link :href="mergeRequest.path" class="gl-text-blue-600!" data-testid="link-commit"
>!{{ mergeRequest.iid }}</gl-link
>
</span>
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index 488d838db52..00a570fe2f8 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -48,7 +48,7 @@ export default {
}"
>
<gl-link
- v-gl-tooltip
+ v-gl-tooltip:tooltip-container.left
:href="job.status.details_path"
:title="tooltipText"
class="js-job-link gl-display-flex gl-align-items-center"
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index ce4a85b35b7..ea50a11bed6 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,9 +1,15 @@
<script>
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { __, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
export default {
+ i18n: {
+ eraseLogButtonLabel: s__('Job|Erase job log'),
+ scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
+ scrollToTopButtonLabel: s__('Job|Scroll to top'),
+ showRawButtonLabel: s__('Job|Show complete raw'),
+ },
components: {
GlLink,
GlButton,
@@ -82,7 +88,8 @@ export default {
<gl-button
v-if="rawPath"
v-gl-tooltip.body
- :title="s__('Job|Show complete raw')"
+ :title="$options.i18n.showRawButtonLabel"
+ :aria-label="$options.i18n.showRawButtonLabel"
:href="rawPath"
data-testid="job-raw-link-controller"
icon="doc-text"
@@ -91,7 +98,8 @@ export default {
<gl-button
v-if="erasePath"
v-gl-tooltip.body
- :title="s__('Job|Erase job log')"
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
:href="erasePath"
:data-confirm="__('Are you sure you want to erase this build?')"
class="gl-ml-3"
@@ -102,23 +110,25 @@ export default {
<!-- eo links -->
<!-- scroll buttons -->
- <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="gl-ml-3">
+ <div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3">
<gl-button
:disabled="isScrollTopDisabled"
class="btn-scroll"
data-testid="job-controller-scroll-top"
icon="scroll_up"
+ :aria-label="$options.i18n.scrollToTopButtonLabel"
@click="handleScrollToTop"
/>
</div>
- <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="gl-ml-3">
+ <div v-gl-tooltip :title="$options.i18n.scrollToBottomButtonLabel" class="gl-ml-3">
<gl-button
:disabled="isScrollBottomDisabled"
class="js-scroll-bottom btn-scroll"
data-testid="job-controller-scroll-bottom"
icon="scroll_down"
:class="{ animate: isScrollingDown }"
+ :aria-label="$options.i18n.scrollToBottomButtonLabel"
@click="handleScrollToBottom"
/>
</div>
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index a1f4f7abb77..d45012d2023 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -43,6 +43,7 @@ export default {
variables: [],
key: '',
secretValue: '',
+ triggerBtnDisabled: false,
};
},
computed: {
@@ -98,6 +99,11 @@ export default {
1,
);
},
+ trigger() {
+ this.triggerBtnDisabled = true;
+
+ this.triggerManualJob(this.variables);
+ },
},
};
</script>
@@ -111,7 +117,12 @@ export default {
<div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div>
</div>
- <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row">
+ <div
+ v-for="variable in variables"
+ :key="variable.id"
+ class="gl-responsive-table-row"
+ data-testid="ci-variable-row"
+ >
<div class="table-section section-50">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
<div class="table-mobile-content gl-mr-3">
@@ -120,6 +131,7 @@ export default {
v-model="variable.key"
:placeholder="$options.i18n.keyPlaceholder"
class="ci-variable-body-item form-control"
+ data-testid="ci-variable-key"
/>
</div>
</div>
@@ -132,6 +144,7 @@ export default {
v-model="variable.secret_value"
:placeholder="$options.i18n.valuePlaceholder"
class="ci-variable-body-item form-control"
+ data-testid="ci-variable-value"
/>
</div>
</div>
@@ -143,6 +156,7 @@ export default {
category="tertiary"
icon="clear"
:aria-label="__('Delete variable')"
+ data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
</div>
@@ -175,14 +189,16 @@ export default {
</div>
</div>
<div class="d-flex gl-mt-3 justify-content-center">
- <p class="text-muted" v-html="helpText"></p>
+ <p class="text-muted" data-testid="form-help-text" v-html="helpText"></p>
</div>
<div class="d-flex justify-content-center">
<gl-button
variant="info"
category="primary"
:aria-label="__('Trigger manual job')"
- @click="triggerManualJob(variables)"
+ :disabled="triggerBtnDisabled"
+ data-testid="trigger-manual-job-btn"
+ @click="trigger"
>
{{ action.button_title }}
</gl-button>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index fcf03dff34e..1b50006239c 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -49,7 +49,8 @@ export default {
return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
},
hasArtifact() {
- return !isEmpty(this.job.artifact);
+ // the artifact object will always have a locked property
+ return Object.keys(this.job.artifact).length > 1;
},
hasTriggers() {
return !isEmpty(this.job.trigger);
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index b20d58b6ffe..98badb96ed7 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -51,7 +51,9 @@ export default {
});
},
runnerId() {
- return `${this.job.runner.description} (#${this.job.runner.id})`;
+ const { id, short_sha: token, description } = this.job?.runner;
+
+ return `#${id} (${token}) ${description}`;
},
shouldRenderBlock() {
return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags);
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 18de849af88..36b0ad43b14 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -44,13 +44,14 @@ export default {
</script>
<template>
<div class="dropdown">
- <div class="js-pipeline-info">
+ <div class="js-pipeline-info" data-testid="pipeline-info">
<ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
<span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span>
<gl-link
:href="pipeline.path"
class="js-pipeline-path link-commit"
+ data-testid="pipeline-path"
data-qa-selector="pipeline_path"
>#{{ pipeline.id }}</gl-link
>
@@ -58,13 +59,17 @@ export default {
{{ s__('Job|for') }}
<template v-if="isTriggeredByMergeRequest">
- <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name js-mr-link"
+ <gl-link
+ :href="pipeline.merge_request.path"
+ class="link-commit ref-name"
+ data-testid="mr-link"
>!{{ pipeline.merge_request.iid }}</gl-link
>
{{ s__('Job|with') }}
<gl-link
:href="pipeline.merge_request.source_branch_path"
- class="link-commit ref-name js-source-branch-link"
+ class="link-commit ref-name"
+ data-testid="source-branch-link"
>{{ pipeline.merge_request.source_branch }}</gl-link
>
@@ -72,7 +77,8 @@ export default {
{{ s__('Job|into') }}
<gl-link
:href="pipeline.merge_request.target_branch_path"
- class="link-commit ref-name js-target-branch-link"
+ class="link-commit ref-name"
+ data-testid="target-branch-link"
>{{ pipeline.merge_request.target_branch }}</gl-link
>
</template>
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
new file mode 100644
index 00000000000..d9e51b0345a
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -0,0 +1,52 @@
+query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
+ project(fullPath: $fullPath) {
+ jobs(first: 20, statuses: $statuses) {
+ pageInfo {
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ }
+ nodes {
+ detailedStatus {
+ icon
+ label
+ text
+ tooltip
+ action {
+ buttonTitle
+ icon
+ method
+ path
+ title
+ }
+ }
+ id
+ refName
+ refPath
+ tags
+ shortSha
+ commitPath
+ pipeline {
+ id
+ path
+ user {
+ webPath
+ avatarUrl
+ }
+ }
+ stage {
+ name
+ }
+ name
+ duration
+ finishedAt
+ coverage
+ retryable
+ playable
+ cancelable
+ active
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
new file mode 100644
index 00000000000..b6b3bb6d379
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default (containerId = 'js-jobs-table') => {
+ const containerEl = document.getElementById(containerId);
+
+ if (!containerEl) {
+ return false;
+ }
+
+ const { fullPath, jobCounts, jobStatuses } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ apolloProvider,
+ provide: {
+ fullPath,
+ jobStatuses: JSON.parse(jobStatuses),
+ jobCounts: JSON.parse(jobCounts),
+ },
+ render(createElement) {
+ return createElement(JobsTableApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
new file mode 100644
index 00000000000..32b26d45dfe
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const defaultTableClasses = {
+ tdClass: 'gl-p-5!',
+ thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!',
+};
+
+export default {
+ fields: [
+ {
+ key: 'status',
+ label: __('Status'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'job',
+ label: __('Job'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'pipeline',
+ label: __('Pipeline'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'stage',
+ label: __('Stage'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'name',
+ label: __('Name'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'duration',
+ label: __('Duration'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'coverage',
+ label: __('Coverage'),
+ ...defaultTableClasses,
+ },
+ {
+ key: 'actions',
+ label: '',
+ ...defaultTableClasses,
+ },
+ ],
+ components: {
+ GlTable,
+ },
+ props: {
+ jobs: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-table :items="jobs" :fields="$options.fields" />
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
new file mode 100644
index 00000000000..55954e31654
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { __ } from '~/locale';
+import GetJobs from './graphql/queries/get_jobs.query.graphql';
+import JobsTable from './jobs_table.vue';
+import JobsTableTabs from './jobs_table_tabs.vue';
+
+export default {
+ i18n: {
+ errorMsg: __('There was an error fetching the jobs for your project.'),
+ },
+ components: {
+ GlAlert,
+ GlSkeletonLoader,
+ JobsTable,
+ JobsTableTabs,
+ },
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ },
+ apollo: {
+ jobs: {
+ query: GetJobs,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ };
+ },
+ update({ project }) {
+ return project?.jobs;
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ data() {
+ return {
+ jobs: null,
+ hasError: false,
+ isAlertDismissed: false,
+ };
+ },
+ computed: {
+ shouldShowAlert() {
+ return this.hasError && !this.isAlertDismissed;
+ },
+ },
+ methods: {
+ fetchJobsByStatus(scope) {
+ this.$apollo.queries.jobs.refetch({ statuses: scope });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert
+ v-if="shouldShowAlert"
+ class="gl-mt-2"
+ variant="danger"
+ dismissible
+ @dismiss="isAlertDismissed = true"
+ >
+ {{ $options.i18n.errorMsg }}
+ </gl-alert>
+
+ <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" />
+
+ <div v-if="$apollo.loading" class="gl-mt-5">
+ <gl-skeleton-loader
+ preserve-aspect-ratio="none"
+ equal-width-lines
+ :lines="5"
+ :width="600"
+ :height="66"
+ />
+ </div>
+
+ <jobs-table v-else :jobs="jobs.nodes" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
new file mode 100644
index 00000000000..95d265fce60
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlBadge, GlTab, GlTabs } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlBadge,
+ GlTab,
+ GlTabs,
+ },
+ inject: {
+ jobCounts: {
+ default: {},
+ },
+ jobStatuses: {
+ default: {},
+ },
+ },
+ computed: {
+ tabs() {
+ return [
+ {
+ text: __('All'),
+ count: this.jobCounts.all,
+ scope: null,
+ testId: 'jobs-all-tab',
+ },
+ {
+ text: __('Pending'),
+ count: this.jobCounts.pending,
+ scope: this.jobStatuses.pending,
+ testId: 'jobs-pending-tab',
+ },
+ {
+ text: __('Running'),
+ count: this.jobCounts.running,
+ scope: this.jobStatuses.running,
+ testId: 'jobs-running-tab',
+ },
+ {
+ text: __('Finished'),
+ count: this.jobCounts.finished,
+ scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled],
+ testId: 'jobs-finished-tab',
+ },
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tabs>
+ <gl-tab
+ v-for="tab in tabs"
+ :key="tab.text"
+ :title-link-attributes="{ 'data-testid': tab.testId }"
+ @click="$emit('fetchJobsByStatus', tab.scope)"
+ >
+ <template #title>
+ <span>{{ tab.text }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 56e69ab9418..fb88e48c9a6 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */
+/* eslint-disable func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-empty */
/* global Issuable */
/* global ListLabel */
@@ -7,7 +7,6 @@ import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isScopedLabel } from '~/lib/utils/common_utils';
import boardsStore from './boards/stores/boards_store';
-import ModalStore from './boards/stores/modal_store';
import CreateLabelDropdown from './create_label';
import { deprecatedCreateFlash as flash } from './flash';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -313,7 +312,11 @@ export default class LabelsSelect {
return;
}
- if ($('html').hasClass('issue-boards-page')) {
+ if (
+ $('html')
+ .attr('class')
+ .match(/issue-boards-page|epic-boards-page/)
+ ) {
return;
}
if ($dropdown.hasClass('js-multiselect')) {
@@ -357,21 +360,7 @@ export default class LabelsSelect {
return;
}
- let boardsModel;
- if ($dropdown.closest('.add-issues-modal').length) {
- boardsModel = ModalStore.store.filter;
- }
-
- if (boardsModel) {
- if (label.isAny) {
- boardsModel['label_name'] = [];
- } else if ($el.hasClass('is-active')) {
- boardsModel['label_name'].push(label.title);
- }
-
- e.preventDefault();
- return;
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form'));
@@ -522,11 +511,15 @@ export default class LabelsSelect {
}
bindEvents() {
- return $('body').on('change', '.selected-issuable', this.onSelectCheckboxIssue);
+ return $('body').on(
+ 'change',
+ '.issuable-list input[type="checkbox"]',
+ this.onSelectCheckboxIssue,
+ );
}
// eslint-disable-next-line class-methods-use-this
onSelectCheckboxIssue() {
- if ($('.selected-issuable:checked').length) {
+ if ($('.issuable-list input[type="checkbox"]:checked').length) {
return;
}
return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label'));
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index e090f9f6e8c..c720476f3bf 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link';
import { BatchHttpLink } from 'apollo-link-batch-http';
import { createHttpLink } from 'apollo-link-http';
import { createUploadLink } from 'apollo-upload-client';
+import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
@@ -78,6 +79,7 @@ export default (resolvers = {}, config = {}) => {
requestCounterLink,
performanceBarLink,
new StartupJSLink(),
+ apolloCaptchaLink,
uploadsLink,
]),
cache: new InMemoryCache({
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index ff176f11867..da2c10076b1 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -43,3 +43,15 @@ export const validateHexColor = (color = '') => {
return /^#([0-9A-F]{3}){1,2}$/i.test(color);
};
+
+export function darkModeEnabled() {
+ const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
+
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const isWebIde = document.body.dataset.page.startsWith('ide:');
+
+ if (isWebIde) {
+ return ideDarkThemes.includes(window.gon?.user_color_scheme);
+ }
+ return document.body.classList.contains('gl-dark');
+}
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 73eadfe3cbe..fb257228597 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -681,6 +681,19 @@ export const roundOffFloat = (number, precision = 0) => {
};
/**
+ * Method to round values to the nearest half (0.5)
+ *
+ * Eg; roundToNearestHalf(3.141592) = 3, roundToNearestHalf(3.41592) = 3.5
+ *
+ * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
+ * more supported examples.
+ *
+ * @param {Float} number
+ * @returns {Float|Number}
+ */
+export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2;
+
+/**
* Method to round down values with decimal places
* with provided precision.
*
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 145b419f8f0..a509828815a 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -4,8 +4,6 @@ import { isString, mapValues, isNumber, reduce } from 'lodash';
import * as timeago from 'timeago.js';
import { languageCode, s__, __, n__ } from '../../locale';
-const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
-const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR;
const DAYS_IN_WEEK = 7;
window.timeago = timeago;
@@ -256,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => {
: secondsText;
};
+/**
+ * Similar to `timeIntervalInWords`, but rounds the return value
+ * to 1/10th of the largest time unit. For example:
+ *
+ * 30 => 30 seconds
+ * 90 => 1.5 minutes
+ * 7200 => 2 hours
+ * 86400 => 1 day
+ * ... etc.
+ *
+ * The largest supported unit is "days".
+ *
+ * @param {Number} intervalInSeconds The time interval in seconds
+ * @returns {String} A humanized description of the time interval
+ */
+export const humanizeTimeInterval = (intervalInSeconds) => {
+ if (intervalInSeconds < 60 /* = 1 minute */) {
+ const seconds = Math.round(intervalInSeconds * 10) / 10;
+ return n__('%d second', '%d seconds', seconds);
+ } else if (intervalInSeconds < 3600 /* = 1 hour */) {
+ const minutes = Math.round(intervalInSeconds / 6) / 10;
+ return n__('%d minute', '%d minutes', minutes);
+ } else if (intervalInSeconds < 86400 /* = 1 day */) {
+ const hours = Math.round(intervalInSeconds / 360) / 10;
+ return n__('%d hour', '%d hours', hours);
+ }
+
+ const days = Math.round(intervalInSeconds / 8640) / 10;
+ return n__('%d day', '%d days', days);
+};
+
export const dateInWords = (date, abbreviated = false, hideYear = false) => {
if (!date) return date;
@@ -947,49 +976,6 @@ export const format24HourTimeStringFromInt = (time) => {
};
/**
- * A utility function which checks if two date ranges overlap.
- *
- * @param {Object} givenPeriodLeft - the first period to compare.
- * @param {Object} givenPeriodRight - the second period to compare.
- * @returns {Object} { daysOverlap: number of days the overlap is present, hoursOverlap: number of hours the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format }
- * @throws {Error} Uncaught Error: Invalid period
- *
- * @example
- * getOverlapDateInPeriods(
- * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) },
- * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) }
- * ) => { daysOverlap: 2, hoursOverlap: 48, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 }
- *
- */
-export const getOverlapDateInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => {
- const leftStartTime = new Date(givenPeriodLeft.start).getTime();
- const leftEndTime = new Date(givenPeriodLeft.end).getTime();
- const rightStartTime = new Date(givenPeriodRight.start).getTime();
- const rightEndTime = new Date(givenPeriodRight.end).getTime();
-
- if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) {
- throw new Error(__('Invalid period'));
- }
-
- const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime;
-
- if (!isOverlapping) {
- return { daysOverlap: 0 };
- }
-
- const overlapStartDate = Math.max(leftStartTime, rightStartTime);
- const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime;
- const differenceInMs = overlapEndDate - overlapStartDate;
-
- return {
- hoursOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_HOUR),
- daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY),
- overlapStartDate,
- overlapEndDate,
- };
-};
-
-/**
* A utility function that checks that the date is today
*
* @param {Date} date
diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js
index 52e1323412d..b58aef15dda 100644
--- a/app/assets/javascripts/lib/utils/forms.js
+++ b/app/assets/javascripts/lib/utils/forms.js
@@ -1,3 +1,5 @@
+import { convertToCamelCase } from '~/lib/utils/text_utility';
+
export const serializeFormEntries = (entries) =>
entries.reduce((acc, { name, value }) => Object.assign(acc, { [name]: value }), {});
@@ -51,3 +53,95 @@ export const serializeFormObject = (form) =>
return acc;
}, []),
);
+
+/**
+ * Parse inputs of HTML forms generated by Rails.
+ *
+ * This can be helpful when mounting Vue components within Rails forms.
+ *
+ * If called with an HTML element like:
+ *
+ * ```html
+ * <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail">
+ * <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contactInfoPhone">
+ * <input type="checkbox" name="user[interests][]" id="user_interests_vue" value="Vue" checked data-js-name="interests">
+ * <input type="checkbox" name="user[interests][]" id="user_interests_graphql" value="GraphQL" data-js-name="interests">
+ * ```
+ *
+ * It will return an object like:
+ *
+ * ```javascript
+ * {
+ * contactInfoEmail: {
+ * name: 'user[contact_info][email]',
+ * id: 'user_contact_info_email',
+ * value: 'foo@bar.com',
+ * placeholder: 'Email',
+ * },
+ * contactInfoPhone: {
+ * name: 'user[contact_info][phone]',
+ * id: 'user_contact_info_phone',
+ * value: '(123) 456-7890',
+ * placeholder: 'Phone',
+ * },
+ * interests: [
+ * {
+ * name: 'user[interests][]',
+ * id: 'user_interests_vue',
+ * value: 'Vue',
+ * checked: true,
+ * },
+ * {
+ * name: 'user[interests][]',
+ * id: 'user_interests_graphql',
+ * value: 'GraphQL',
+ * checked: false,
+ * },
+ * ],
+ * }
+ * ```
+ *
+ * @param {HTMLInputElement} mountEl
+ * @returns {Object} object with form fields data.
+ */
+export const parseRailsFormFields = (mountEl) => {
+ if (!mountEl) {
+ throw new TypeError('`mountEl` argument is required');
+ }
+
+ const inputs = mountEl.querySelectorAll('[name]');
+
+ return [...inputs].reduce((accumulator, input) => {
+ const fieldName = input.dataset.jsName;
+
+ if (!fieldName) {
+ return accumulator;
+ }
+
+ const fieldNameCamelCase = convertToCamelCase(fieldName);
+ const { id, placeholder, name, value, type, checked } = input;
+ const attributes = {
+ name,
+ id,
+ value,
+ ...(placeholder && { placeholder }),
+ };
+
+ // Store radio buttons and checkboxes as an array so they can be
+ // looped through and rendered in Vue
+ if (['radio', 'checkbox'].includes(type)) {
+ return {
+ ...accumulator,
+ [fieldNameCamelCase]: [
+ ...(accumulator[fieldNameCamelCase] || []),
+ { ...attributes, checked },
+ ],
+ };
+ }
+
+ return {
+ ...accumulator,
+ [fieldNameCamelCase]: attributes,
+ };
+ }, {});
+};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 345dfaf895b..1593a363dd1 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -232,7 +232,7 @@ export function insertMarkdownText({
.join('\n');
}
} else if (tag.indexOf(textPlaceholder) > -1) {
- textToInsert = tag.replace(textPlaceholder, selected);
+ textToInsert = tag.replace(textPlaceholder, selected.replace(/\\n/g, '\n'));
} else {
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
@@ -322,7 +322,7 @@ export function updateTextForToolbarBtn($toolbarBtn) {
blockTag: $toolbarBtn.data('mdBlock'),
wrap: !$toolbarBtn.data('mdPrepend'),
select: $toolbarBtn.data('mdSelect'),
- tagContent: $toolbarBtn.data('mdTagContent'),
+ tagContent: $toolbarBtn.attr('data-md-tag-content'),
});
}
diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js
index 07a4d2deb0b..a88f1bd82fc 100644
--- a/app/assets/javascripts/lib/utils/webpack.js
+++ b/app/assets/javascripts/lib/utils/webpack.js
@@ -11,10 +11,4 @@ export function resetServiceWorkersPublicPath() {
const relativeRootPath = (gon && gon.relative_url_root) || '';
const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/');
__webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase
-
- // monaco-editor-webpack-plugin currently (incorrectly) references the
- // public path as a property of `window`. Once this is fixed upstream we
- // can remove this line
- // see: https://github.com/Microsoft/monaco-editor-webpack-plugin/pull/63
- window.__webpack_public_path__ = webpackAssetPath; // eslint-disable-line
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 417baddc031..3f22bd36a4a 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -16,21 +16,16 @@ import { initRails } from '~/lib/utils/rails_ujs';
import * as popovers from '~/popovers';
import * as tooltips from '~/tooltips';
import initAlertHandler from './alert_handler';
-import { deprecatedCreateFlash as Flash, removeFlashClickListener } from './flash';
+import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
-import {
- handleLocationHash,
- addSelectOnFocusBehaviour,
- getCspNonceValue,
-} from './lib/utils/common_utils';
+import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else
import initFeatureHighlight from './feature_highlight';
import LazyLoader from './lazy_loader';
-import { __ } from './locale';
import initLogoAnimation from './logo';
import initFrequentItemDropdowns from './frequent_items';
import initBreadcrumbs from './breadcrumb';
@@ -49,29 +44,8 @@ applyGitLabUIConfig();
window.jQuery = jQuery;
window.$ = jQuery;
-// Add nonce to jQuery script handler
-jQuery.ajaxSetup({
- converters: {
- // eslint-disable-next-line @gitlab/require-i18n-strings, func-names
- 'text script': function (text) {
- jQuery.globalEval(text, { nonce: getCspNonceValue() });
- return text;
- },
- },
-});
-
-function disableJQueryAnimations() {
- $.fx.off = true;
-}
-
-// Disable jQuery animations
-if (gon?.disable_animations) {
- disableJQueryAnimations();
-}
-
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
- disableJQueryAnimations();
import(/* webpackMode: "eager" */ './test_utils/');
}
@@ -135,20 +109,6 @@ function deferredInitialisation() {
addSelectOnFocusBehaviour('.js-select-on-focus');
- $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
- tooltips.dispose(this);
-
- $(this).closest('li').addClass('gl-display-none!');
- });
-
- $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
- $(this).hide();
- });
-
- $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
- $(this).closest('tr').addClass('gl-display-none!');
- });
-
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
@@ -239,17 +199,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
- // eslint-disable-next-line no-jquery/no-ajax-events
- $(document).ajaxError((e, xhrObj) => {
- const ref = xhrObj.status;
-
- if (ref === 401) {
- Flash(__('You need to be logged in.'));
- } else if (ref === 404 || ref === 500) {
- Flash(__('Something went wrong on our end.'));
- }
- });
-
$('.navbar-toggler').on('click', () => {
$('.header-content').toggleClass('menu-expanded');
});
diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index 83f266779f2..00973100e15 100644
--- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
@@ -12,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
memberId: {
type: Number,
@@ -19,7 +20,11 @@ export default {
},
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
approvePath() {
return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`);
},
diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index 0bcc85157f1..91062c222f4 100644
--- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
@@ -42,6 +42,7 @@ export default {
:member-id="member.id"
:message="message"
:title="s__('Member|Revoke invite')"
+ is-invite
/>
</div>
</action-button-group>
diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index 3b87c29c1bc..fef7940eaa2 100644
--- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
@@ -12,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
groupLink: {
type: Object,
@@ -19,7 +20,11 @@ export default {
},
},
methods: {
- ...mapActions(['showRemoveGroupLinkModal']),
+ ...mapActions({
+ showRemoveGroupLinkModal(dispatch, payload) {
+ return dispatch(`${this.namespace}/showRemoveGroupLinkModal`, payload);
+ },
+ }),
},
};
</script>
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 cb71be39ebc..a477aedd233 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
@@ -8,11 +8,17 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
memberId: {
type: Number,
required: true,
},
+ memberType: {
+ type: String,
+ required: false,
+ default: null,
+ },
message: {
type: String,
required: true,
@@ -31,12 +37,29 @@ export default {
required: false,
default: false,
},
+ isInvite: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ oncallSchedules: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
computedMemberPath() {
return this.memberPath.replace(':id', this.memberId);
},
+ stringifiedSchedules() {
+ return JSON.stringify(this.oncallSchedules);
+ },
},
};
</script>
@@ -50,8 +73,11 @@ export default {
:aria-label="title"
:icon="icon"
:data-member-path="computedMemberPath"
+ :data-member-type="memberType"
:data-is-access-request="isAccessRequest"
+ :data-is-invite="isInvite"
:data-message="message"
+ :data-oncall-schedules="stringifiedSchedules"
data-qa-selector="delete_member_button"
/>
</template>
diff --git a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
index 261a6279920..2173974c6f4 100644
--- a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
@@ -12,6 +12,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
memberId: {
type: Number,
@@ -19,7 +20,11 @@ export default {
},
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
resendPath() {
return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`);
},
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 f779d1755a5..1e9f79927ea 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
@@ -33,7 +33,7 @@ export default {
if (user) {
return sprintf(
- s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'),
+ s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'),
{
usersName: user.name,
source: source.fullName,
@@ -42,12 +42,16 @@ export default {
}
return sprintf(
- s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'),
+ s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'),
{
source: source.fullName,
},
);
},
+ oncallScheduleUserData() {
+ const { user: { name, oncallSchedules: schedules } = {} } = this.member;
+ return { name, schedules };
+ },
},
};
</script>
@@ -59,6 +63,8 @@ export default {
<remove-member-button
v-else
:member-id="member.id"
+ :member-type="member.type"
+ :oncall-schedules="oncallScheduleUserData"
:message="message"
:title="s__('Member|Remove member')"
/>
diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue
index 27fceb7374e..585fabdf3ff 100644
--- a/app/assets/javascripts/members/components/app.vue
+++ b/app/assets/javascripts/members/components/app.vue
@@ -9,8 +9,16 @@ import MembersTable from './table/members_table.vue';
export default {
name: 'MembersApp',
components: { MembersTable, FilterSortContainer, GlAlert },
+ inject: ['namespace'],
computed: {
- ...mapState(['showError', 'errorMessage']),
+ ...mapState({
+ showError(state) {
+ return state[this.namespace].showError;
+ },
+ errorMessage(state) {
+ return state[this.namespace].errorMessage;
+ },
+ }),
},
watch: {
showError(value) {
@@ -23,7 +31,9 @@ export default {
},
methods: {
...mapMutations({
- hideError: HIDE_ERROR,
+ hideError(commit) {
+ return commit(`${this.namespace}/${HIDE_ERROR}`);
+ },
}),
},
};
diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index 658fb43cecb..9687eacb036 100644
--- a/app/assets/javascripts/members/components/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -5,7 +5,6 @@ import {
GlBadge,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
-import { mapState } from 'vuex';
import { generateBadges } from 'ee_else_ce/members/utils';
import { glEmojiTag } from '~/emoji';
import { __ } from '~/locale';
@@ -24,6 +23,7 @@ export default {
directives: {
SafeHtml,
},
+ inject: ['canManageMembers'],
props: {
member: {
type: Object,
@@ -35,7 +35,6 @@ export default {
},
},
computed: {
- ...mapState(['canManageMembers']),
user() {
return this.member.user;
},
diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
index 812a8626949..419b7b83c0f 100644
--- a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
+++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
@@ -6,8 +6,16 @@ import SortDropdown from './sort_dropdown.vue';
export default {
name: 'FilterSortContainer',
components: { MembersFilteredSearchBar, SortDropdown },
+ inject: ['namespace'],
computed: {
- ...mapState(['filteredSearchBar', 'tableSortableFields']),
+ ...mapState({
+ filteredSearchBar(state) {
+ return state[this.namespace].filteredSearchBar;
+ },
+ tableSortableFields(state) {
+ return state[this.namespace].tableSortableFields;
+ },
+ }),
showContainer() {
return this.filteredSearchBar.show || this.showSortDropdown;
},
diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
index 039ee9a0207..cc97d235a9c 100644
--- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -37,13 +37,18 @@ export default {
],
},
],
+ inject: ['namespace', 'sourceId', 'canManageMembers'],
data() {
return {
initialFilterValue: [],
};
},
computed: {
- ...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']),
+ ...mapState({
+ filteredSearchBar(state) {
+ return state[this.namespace].filteredSearchBar;
+ },
+ }),
tokens() {
return this.$options.availableTokens.filter((token) => {
if (
diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
index 9fa8772faf4..ce28283ccdf 100644
--- a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
+++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
@@ -8,8 +8,16 @@ import { parseSortParam, buildSortHref } from '~/members/utils';
export default {
name: 'SortDropdown',
components: { GlSorting, GlSortingItem },
+ inject: ['namespace'],
computed: {
- ...mapState(['tableSortableFields', 'filteredSearchBar']),
+ ...mapState({
+ tableSortableFields(state) {
+ return state[this.namespace].tableSortableFields;
+ },
+ filteredSearchBar(state) {
+ return state[this.namespace].filteredSearchBar;
+ },
+ }),
sort() {
return parseSortParam(this.tableSortableFields);
},
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index a0f978d85cc..44178981136 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -3,6 +3,7 @@ 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 { LEAVE_MODAL_ID } from '../../constants';
export default {
@@ -19,10 +20,11 @@ export default {
csrf,
modalId: LEAVE_MODAL_ID,
modalContent: s__('Members|Are you sure you want to leave "%{source}"?'),
- components: { GlModal, GlForm, GlSprintf },
+ components: { GlModal, GlForm, GlSprintf, OncallSchedulesList },
directives: {
GlTooltip: GlTooltipDirective,
},
+ inject: ['namespace'],
props: {
member: {
type: Object,
@@ -30,13 +32,23 @@ export default {
},
},
computed: {
- ...mapState(['memberPath']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ }),
leavePath() {
return this.memberPath.replace(/:id$/, 'leave');
},
modalTitle() {
return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
},
+ schedules() {
+ return this.member.user?.oncallSchedules;
+ },
+ isPartOfOnCallSchedules() {
+ return this.schedules?.length;
+ },
},
methods: {
handlePrimary() {
@@ -53,7 +65,6 @@ export default {
:title="modalTitle"
:action-primary="$options.actionPrimary"
:action-cancel="$options.actionCancel"
- size="sm"
@primary="handlePrimary"
>
<gl-form ref="form" :action="leavePath" method="post">
@@ -63,6 +74,12 @@ export default {
</gl-sprintf>
</p>
+ <oncall-schedules-list
+ v-if="isPartOfOnCallSchedules"
+ :schedules="schedules"
+ :is-current-user="true"
+ />
+
<input type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
</gl-form>
diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
index 1ba6bf9aba6..b179ced46e1 100644
--- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
@@ -22,8 +22,19 @@ export default {
},
modalId: REMOVE_GROUP_LINK_MODAL_ID,
components: { GlModal, GlSprintf, GlForm },
+ inject: ['namespace'],
computed: {
- ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']),
+ ...mapState({
+ memberPath(state) {
+ return state[this.namespace].memberPath;
+ },
+ groupLinkToRemove(state) {
+ return state[this.namespace].groupLinkToRemove;
+ },
+ removeGroupLinkModalVisible(state) {
+ return state[this.namespace].removeGroupLinkModalVisible;
+ },
+ }),
groupLinkPath() {
return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id);
},
@@ -35,7 +46,11 @@ export default {
},
},
methods: {
- ...mapActions(['hideRemoveGroupLinkModal']),
+ ...mapActions({
+ hideRemoveGroupLinkModal(dispatch) {
+ return dispatch(`${this.namespace}/hideRemoveGroupLinkModal`);
+ },
+ }),
handlePrimary() {
this.$refs.form.$el.submit();
},
diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
index 0a8af81c1d1..9f6e8979102 100644
--- a/app/assets/javascripts/members/components/table/expiration_datepicker.vue
+++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
@@ -7,6 +7,7 @@ import { s__ } from '~/locale';
export default {
name: 'ExpirationDatepicker',
components: { GlDatepicker },
+ inject: ['namespace'],
props: {
member: {
type: Object,
@@ -46,7 +47,11 @@ export default {
}
},
methods: {
- ...mapActions(['updateMemberExpiration']),
+ ...mapActions({
+ updateMemberExpiration(dispatch, payload) {
+ return dispatch(`${this.namespace}/updateMemberExpiration`, payload);
+ },
+ }),
handleInput(date) {
this.busy = true;
this.updateMemberExpiration({
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 9a3edff19ff..236aeaef418 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -31,8 +31,19 @@ export default {
LdapOverrideConfirmationModal: () =>
import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
+ inject: ['namespace', 'currentUserId'],
computed: {
- ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId']),
+ ...mapState({
+ members(state) {
+ return state[this.namespace].members;
+ },
+ tableFields(state) {
+ return state[this.namespace].tableFields;
+ },
+ tableAttrs(state) {
+ return state[this.namespace].tableAttrs;
+ },
+ }),
filteredFields() {
return FIELDS.filter(
(field) => this.tableFields.includes(field.key) && this.showField(field),
diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 1f537740f94..3436bcab2fc 100644
--- a/app/assets/javascripts/members/components/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -1,5 +1,4 @@
<script>
-import { mapState } from 'vuex';
import { MEMBER_TYPES } from '../../constants';
import {
isGroup,
@@ -12,6 +11,7 @@ import {
export default {
name: 'MembersTableCell',
+ inject: ['currentUserId'],
props: {
member: {
type: Object,
@@ -19,7 +19,6 @@ export default {
},
},
computed: {
- ...mapState(['currentUserId']),
isGroup() {
return isGroup(this.member);
},
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 8ad45ab6920..f84ded427cd 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -11,6 +11,7 @@ export default {
GlDropdownItem,
LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
},
+ inject: ['namespace'],
props: {
member: {
type: Object,
@@ -44,7 +45,11 @@ export default {
}
},
methods: {
- ...mapActions(['updateMemberRole']),
+ ...mapActions({
+ updateMemberRole(dispatch, payload) {
+ return dispatch(`${this.namespace}/updateMemberRole`, payload);
+ },
+ }),
handleSelect(value, name) {
if (value === this.member.accessLevel.integerValue) {
return;
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index fe174d9beb6..6376b3fa75a 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -8,6 +8,7 @@ import membersStore from './store';
export const initMembersApp = (
el,
{
+ namespace,
tableFields = [],
tableAttrs = {},
tableSortableFields = [],
@@ -22,22 +23,31 @@ export const initMembersApp = (
Vue.use(Vuex);
Vue.use(GlToast);
- const store = new Vuex.Store(
- membersStore({
- ...parseDataAttributes(el),
- currentUserId: gon.current_user_id || null,
- tableFields,
- tableAttrs,
- tableSortableFields,
- requestFormatter,
- filteredSearchBar,
- }),
- );
+ const { sourceId, canManageMembers, ...vuexStoreAttributes } = parseDataAttributes(el);
+
+ const store = new Vuex.Store({
+ modules: {
+ [namespace]: membersStore({
+ ...vuexStoreAttributes,
+ tableFields,
+ tableAttrs,
+ tableSortableFields,
+ requestFormatter,
+ filteredSearchBar,
+ }),
+ },
+ });
return new Vue({
el,
components: { App },
store,
+ provide: {
+ namespace,
+ currentUserId: gon.current_user_id || null,
+ sourceId,
+ canManageMembers,
+ },
render: (createElement) => createElement('app'),
});
};
diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js
index 45f4eefffc9..6c371887a3f 100644
--- a/app/assets/javascripts/members/store/index.js
+++ b/app/assets/javascripts/members/store/index.js
@@ -3,6 +3,7 @@ import mutations from 'ee_else_ce/members/store/mutations';
import createState from 'ee_else_ce/members/store/state';
export default (initialState) => ({
+ namespaced: true,
state: createState(initialState),
actions,
mutations,
diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js
index 23a7983adcc..4006b4b501d 100644
--- a/app/assets/javascripts/members/store/state.js
+++ b/app/assets/javascripts/members/store/state.js
@@ -1,8 +1,5 @@
export default ({
members,
- sourceId,
- currentUserId,
- canManageMembers,
tableFields,
tableAttrs,
tableSortableFields,
@@ -11,9 +8,6 @@ export default ({
filteredSearchBar,
}) => ({
members,
- sourceId,
- currentUserId,
- canManageMembers,
tableFields,
tableAttrs,
tableSortableFields,
diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
index 2c7c8038af5..7649c363daa 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -1,8 +1,10 @@
<script>
import { debounce } from 'lodash';
+import { mapActions } from 'vuex';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
+import { INTERACTIVE_RESOLVE_MODE } from '../constants';
export default {
props: {
@@ -10,14 +12,6 @@ export default {
type: Object,
required: true,
},
- onCancelDiscardConfirmation: {
- type: Function,
- required: true,
- },
- onAcceptDiscardConfirmation: {
- type: Function,
- required: true,
- },
},
data() {
return {
@@ -50,6 +44,7 @@ export default {
}
},
methods: {
+ ...mapActions(['setFileResolveMode', 'setPromptConfirmationState', 'updateFile']),
loadEditor() {
const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite');
const DataPromise = axios.get(this.file.content_path);
@@ -82,23 +77,24 @@ export default {
saveDiffResolution() {
this.saved = true;
- // This probably be better placed in the data provider
- /* eslint-disable vue/no-mutating-props */
- this.file.content = this.editor.getValue();
- this.file.resolveEditChanged = this.file.content !== this.originalContent;
- this.file.promptDiscardConfirmation = false;
- /* eslint-enable vue/no-mutating-props */
+ this.updateFile({
+ ...this.file,
+ content: this.editor.getValue(),
+ resolveEditChanged: this.file.content !== this.originalContent,
+ promptDiscardConfirmation: false,
+ });
},
resetEditorContent() {
if (this.fileLoaded) {
this.editor.setValue(this.originalContent);
}
},
- cancelDiscardConfirmation(file) {
- this.onCancelDiscardConfirmation(file);
- },
acceptDiscardConfirmation(file) {
- this.onAcceptDiscardConfirmation(file);
+ this.setPromptConfirmationState({ file, promptDiscardConfirmation: false });
+ this.setFileResolveMode({ file, mode: INTERACTIVE_RESOLVE_MODE });
+ },
+ cancelDiscardConfirmation(file) {
+ this.setPromptConfirmationState({ file, promptDiscardConfirmation: false });
},
},
};
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 519fd53af1e..9721481e6be 100644
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -1,34 +1,41 @@
<script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import actionsMixin from '../mixins/line_conflict_actions';
+import { mapActions } from 'vuex';
+import syntaxHighlight from '~/syntax_highlight';
+import { SYNTAX_HIGHLIGHT_CLASS } from '../constants';
import utilsMixin from '../mixins/line_conflict_utils';
export default {
directives: {
SafeHtml,
},
- mixins: [utilsMixin, actionsMixin],
+ mixins: [utilsMixin],
+ SYNTAX_HIGHLIGHT_CLASS,
props: {
file: {
type: Object,
required: true,
},
},
+ mounted() {
+ syntaxHighlight(document.querySelectorAll(`.${SYNTAX_HIGHLIGHT_CLASS}`));
+ },
+ methods: {
+ ...mapActions(['handleSelected']),
+ },
};
</script>
<template>
- <table class="diff-wrap-lines code code-commit js-syntax-highlight">
- <tr
- v-for="line in file.inlineLines"
- :key="(line.isHeader ? line.id : line.new_line) + line.richText"
- class="line_holder diff-inline"
- >
+ <table :class="['diff-wrap-lines code code-commit', $options.SYNTAX_HIGHLIGHT_CLASS]">
+ <!-- Unfortunately there isn't a good key for these sections -->
+ <!-- eslint-disable vue/require-v-for-key -->
+ <tr v-for="line in file.inlineLines" class="line_holder diff-inline">
<template v-if="line.isHeader">
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="line_content header">
<strong>{{ line.richText }}</strong>
- <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ <button class="btn" @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 e66f641f70d..7b1d947ccff 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -1,32 +1,41 @@
<script>
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
-import actionsMixin from '../mixins/line_conflict_actions';
+import { mapActions } from 'vuex';
+import syntaxHighlight from '~/syntax_highlight';
+import { SYNTAX_HIGHLIGHT_CLASS } from '../constants';
import utilsMixin from '../mixins/line_conflict_utils';
export default {
directives: {
SafeHtml,
},
- mixins: [utilsMixin, actionsMixin],
+ mixins: [utilsMixin],
+ SYNTAX_HIGHLIGHT_CLASS,
props: {
file: {
type: Object,
required: true,
},
},
+ mounted() {
+ syntaxHighlight(document.querySelectorAll(`.${SYNTAX_HIGHLIGHT_CLASS}`));
+ },
+ methods: {
+ ...mapActions(['handleSelected']),
+ },
};
</script>
<template>
<!-- Unfortunately there isn't a good key for these sections -->
<!-- eslint-disable vue/require-v-for-key -->
- <table class="diff-wrap-lines code js-syntax-highlight">
+ <table :class="['diff-wrap-lines code', $options.SYNTAX_HIGHLIGHT_CLASS]">
<tr v-for="section in file.parallelLines" class="line_holder parallel">
<template v-for="line in section">
<template v-if="line.isHeader">
<td class="diff-line-num header" :class="lineCssClass(line)"></td>
<td class="line_content header" :class="lineCssClass(line)">
<strong>{{ line.richText }}</strong>
- <button class="btn" @click="handleSelected(file, line.id, line.section)">
+ <button class="btn" @click="handleSelected({ file, line })">
{{ line.buttonTitle }}
</button>
</td>
diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js
index 6f3ee339e36..dddcc891e81 100644
--- a/app/assets/javascripts/merge_conflicts/constants.js
+++ b/app/assets/javascripts/merge_conflicts/constants.js
@@ -13,6 +13,7 @@ export const VIEW_TYPES = {
export const EDIT_RESOLVE_MODE = 'edit';
export const INTERACTIVE_RESOLVE_MODE = 'interactive';
export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
+export const SYNTAX_HIGHLIGHT_CLASS = 'js-syntax-highlight';
export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
index 16a7cfb2ba8..0509cf0afa1 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -1,14 +1,15 @@
<script>
import { GlSprintf } from '@gitlab/ui';
+import { mapGetters, mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import DiffFileEditor from './components/diff_file_editor.vue';
import InlineConflictLines from './components/inline_conflict_lines.vue';
import ParallelConflictLines from './components/parallel_conflict_lines.vue';
+import { INTERACTIVE_RESOLVE_MODE } from './constants';
/**
- * NOTE: Most of this component is directly using $root, rather than props or a better data store.
- * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should
+ * A lot of the classes below should
* be replaced with GitLab UI components.
*
* We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner
@@ -25,60 +26,88 @@ export default {
InlineConflictLines,
ParallelConflictLines,
},
- inject: ['mergeRequestPath', 'sourceBranchPath'],
+ inject: ['mergeRequestPath', 'sourceBranchPath', 'resolveConflictsPath'],
i18n: {
commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'),
resolveInfo: __(
'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
),
},
+ computed: {
+ ...mapGetters([
+ 'getConflictsCountText',
+ 'isReadyToCommit',
+ 'getCommitButtonText',
+ 'fileTextTypePresent',
+ ]),
+ ...mapState(['isLoading', 'hasError', 'isParallel', 'conflictsData']),
+ commitMessage: {
+ get() {
+ return this.conflictsData.commitMessage;
+ },
+ set(value) {
+ this.updateCommitMessage(value);
+ },
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setViewType',
+ 'submitResolvedConflicts',
+ 'setFileResolveMode',
+ 'setPromptConfirmationState',
+ 'updateCommitMessage',
+ ]),
+ onClickResolveModeButton(file, mode) {
+ if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) {
+ this.setPromptConfirmationState({ file, promptDiscardConfirmation: true });
+ } else {
+ this.setFileResolveMode({ file, mode });
+ }
+ },
+ },
};
</script>
<template>
<div id="conflicts">
- <div v-if="$root.isLoading" class="loading">
+ <div v-if="isLoading" class="loading">
<div class="spinner spinner-md"></div>
</div>
- <div v-if="$root.hasError" class="nothing-here-block">
- {{ $root.conflictsData.errorMessage }}
+ <div v-if="hasError" class="nothing-here-block">
+ {{ conflictsData.errorMessage }}
</div>
- <template v-if="!$root.isLoading && !$root.hasError">
+ <template v-if="!isLoading && !hasError">
<div class="content-block oneline-block files-changed">
- <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons">
+ <div v-if="fileTextTypePresent" class="inline-parallel-buttons">
<div class="btn-group">
<button
- :class="{ active: !$root.isParallel }"
+ :class="{ active: !isParallel }"
class="btn gl-button"
- @click="$root.handleViewTypeChange('inline')"
+ @click="setViewType('inline')"
>
{{ __('Inline') }}
</button>
<button
- :class="{ active: $root.isParallel }"
+ :class="{ active: isParallel }"
class="btn gl-button"
- @click="$root.handleViewTypeChange('parallel')"
+ data-testid="side-by-side"
+ @click="setViewType('parallel')"
>
{{ __('Side-by-side') }}
</button>
</div>
</div>
<div class="js-toggle-container">
- <div class="commit-stat-summary">
+ <div class="commit-stat-summary" data-testid="conflicts-count">
<gl-sprintf :message="$options.i18n.commitStatSummary">
<template #conflict>
- <strong class="cred">
- {{ $root.conflictsCountText }}
- </strong>
+ <strong class="cred">{{ getConflictsCountText }}</strong>
</template>
<template #sourceBranch>
- <strong class="ref-name">
- {{ $root.conflictsData.sourceBranch }}
- </strong>
+ <strong class="ref-name">{{ conflictsData.sourceBranch }}</strong>
</template>
<template #targetBranch>
- <strong class="ref-name">
- {{ $root.conflictsData.targetBranch }}
- </strong>
+ <strong class="ref-name">{{ conflictsData.targetBranch }}</strong>
</template>
</gl-sprintf>
</div>
@@ -87,12 +116,13 @@ export default {
<div class="files-wrapper">
<div class="files">
<div
- v-for="file in $root.conflictsData.files"
+ v-for="file in conflictsData.files"
:key="file.blobPath"
class="diff-file file-holder conflict"
+ data-testid="files"
>
<div class="js-file-title file-title file-title-flex-parent cursor-default">
- <div class="file-header-content">
+ <div class="file-header-content" data-testid="file-name">
<file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" />
<strong class="file-title-name">{{ file.filePath }}</strong>
</div>
@@ -102,7 +132,8 @@ export default {
:class="{ active: file.resolveMode === 'interactive' }"
class="btn gl-button"
type="button"
- @click="$root.onClickResolveModeButton(file, 'interactive')"
+ data-testid="interactive-button"
+ @click="onClickResolveModeButton(file, 'interactive')"
>
{{ __('Interactive mode') }}
</button>
@@ -110,7 +141,8 @@ export default {
:class="{ active: file.resolveMode === 'edit' }"
class="btn gl-button"
type="button"
- @click="$root.onClickResolveModeButton(file, 'edit')"
+ data-testid="inline-button"
+ @click="onClickResolveModeButton(file, 'edit')"
>
{{ __('Edit inline') }}
</button>
@@ -118,35 +150,23 @@ export default {
<a :href="file.blobPath" class="btn gl-button view-file">
<gl-sprintf :message="__('View file @ %{commitSha}')">
<template #commitSha>
- {{ $root.conflictsData.shortCommitSha }}
+ {{ conflictsData.shortCommitSha }}
</template>
</gl-sprintf>
</a>
</div>
</div>
<div class="diff-content diff-wrap-lines">
- <div
- v-show="
- !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
- "
- class="file-content"
- >
- <inline-conflict-lines :file="file" />
- </div>
- <div
- v-show="
- $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text'
- "
- class="file-content"
- >
- <parallel-conflict-lines :file="file" />
- </div>
- <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'">
- <diff-file-editor
- :file="file"
- :on-accept-discard-confirmation="$root.acceptDiscardConfirmation"
- :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation"
- />
+ <template v-if="file.resolveMode === 'interactive' && file.type === 'text'">
+ <div v-if="!isParallel" class="file-content">
+ <inline-conflict-lines :file="file" />
+ </div>
+ <div v-if="isParallel" class="file-content">
+ <parallel-conflict-lines :file="file" />
+ </div>
+ </template>
+ <div v-if="file.resolveMode === 'edit' || file.type === 'text-editor'">
+ <diff-file-editor :file="file" />
</div>
</div>
</div>
@@ -169,7 +189,7 @@ export default {
</template>
<template #branch_name>
<a class="ref-name" :href="sourceBranchPath">
- {{ $root.conflictsData.sourceBranch }}
+ {{ conflictsData.sourceBranch }}
</a>
</template>
</gl-sprintf>
@@ -183,7 +203,8 @@ export default {
<div class="max-width-marker"></div>
<textarea
id="commit-message"
- v-model="$root.conflictsData.commitMessage"
+ v-model="commitMessage"
+ data-testid="commit-message"
class="form-control js-commit-message"
rows="5"
></textarea>
@@ -195,12 +216,12 @@ export default {
<div class="row">
<div class="col-6">
<button
- :disabled="!$root.readyToCommit"
+ :disabled="!isReadyToCommit"
class="btn gl-button btn-success js-submit-button"
type="button"
- @click="$root.commit()"
+ @click="submitResolvedConflicts(resolveConflictsPath)"
>
- <span>{{ $root.commitButtonText }}</span>
+ <span>{{ getCommitButtonText }}</span>
</button>
</div>
<div class="col-6 text-right">
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
deleted file mode 100644
index 64d69159222..00000000000
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import axios from '../lib/utils/axios_utils';
-
-export default class MergeConflictsService {
- constructor(options) {
- this.conflictsPath = options.conflictsPath;
- this.resolveConflictsPath = options.resolveConflictsPath;
- }
-
- fetchConflictsData() {
- return axios.get(this.conflictsPath);
- }
-
- submitResolveConflicts(data) {
- return axios.post(this.resolveConflictsPath, data);
- }
-}
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
deleted file mode 100644
index fb3444262ea..00000000000
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ /dev/null
@@ -1,432 +0,0 @@
-/* eslint-disable no-param-reassign, babel/camelcase, no-nested-ternary, no-continue */
-
-import $ from 'jquery';
-import Cookies from 'js-cookie';
-import Vue from 'vue';
-import { s__ } from '~/locale';
-
-((global) => {
- global.mergeConflicts = global.mergeConflicts || {};
-
- const diffViewType = Cookies.get('diff_view');
- const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
- const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
- const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
- const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
- const INTERACTIVE_RESOLVE_MODE = 'interactive';
- const EDIT_RESOLVE_MODE = 'edit';
- const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
- const VIEW_TYPES = {
- INLINE: 'inline',
- PARALLEL: 'parallel',
- };
- const CONFLICT_TYPES = {
- TEXT: 'text',
- TEXT_EDITOR: 'text-editor',
- };
-
- global.mergeConflicts.mergeConflictsStore = {
- state: {
- isLoading: true,
- hasError: false,
- isSubmitting: false,
- isParallel: diffViewType === VIEW_TYPES.PARALLEL,
- diffViewType,
- conflictsData: {},
- },
-
- setConflictsData(data) {
- this.decorateFiles(data.files);
-
- this.state.conflictsData = {
- files: data.files,
- commitMessage: data.commit_message,
- sourceBranch: data.source_branch,
- targetBranch: data.target_branch,
- shortCommitSha: data.commit_sha.slice(0, 7),
- };
- },
-
- decorateFiles(files) {
- files.forEach((file) => {
- file.content = '';
- file.resolutionData = {};
- file.promptDiscardConfirmation = false;
- file.resolveMode = DEFAULT_RESOLVE_MODE;
- file.filePath = this.getFilePath(file);
- file.blobPath = file.blob_path;
-
- if (file.type === CONFLICT_TYPES.TEXT) {
- file.showEditor = false;
- file.loadEditor = false;
-
- this.setInlineLine(file);
- this.setParallelLine(file);
- } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
- file.showEditor = true;
- file.loadEditor = true;
- }
- });
- },
-
- setInlineLine(file) {
- file.inlineLines = [];
-
- file.sections.forEach((section) => {
- let currentLineType = 'new';
- const { conflict, lines, id } = section;
-
- if (conflict) {
- file.inlineLines.push(this.getHeadHeaderLine(id));
- }
-
- lines.forEach((line) => {
- const { type } = line;
-
- if ((type === 'new' || type === 'old') && currentLineType !== type) {
- currentLineType = type;
- file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
- }
-
- this.decorateLineForInlineView(line, id, conflict);
- file.inlineLines.push(line);
- });
-
- if (conflict) {
- file.inlineLines.push(this.getOriginHeaderLine(id));
- }
- });
- },
-
- setParallelLine(file) {
- file.parallelLines = [];
- const linesObj = { left: [], right: [] };
-
- file.sections.forEach((section) => {
- const { conflict, lines, id } = section;
-
- if (conflict) {
- linesObj.left.push(this.getOriginHeaderLine(id));
- linesObj.right.push(this.getHeadHeaderLine(id));
- }
-
- lines.forEach((line) => {
- const { type } = line;
-
- if (conflict) {
- if (type === 'old') {
- linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
- } else if (type === 'new') {
- linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
- }
- } else {
- const lineType = type || 'context';
-
- linesObj.left.push(this.getLineForParallelView(line, id, lineType));
- linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
- }
- });
-
- this.checkLineLengths(linesObj);
- });
-
- for (let i = 0, len = linesObj.left.length; i < len; i += 1) {
- file.parallelLines.push([linesObj.right[i], linesObj.left[i]]);
- }
- },
-
- setLoadingState(state) {
- this.state.isLoading = state;
- },
-
- setErrorState(state) {
- this.state.hasError = state;
- },
-
- setFailedRequest(message) {
- this.state.hasError = true;
- this.state.conflictsData.errorMessage = message;
- },
-
- getConflictsCount() {
- if (!this.state.conflictsData.files.length) {
- return 0;
- }
-
- const { files } = this.state.conflictsData;
- let count = 0;
-
- files.forEach((file) => {
- if (file.type === CONFLICT_TYPES.TEXT) {
- file.sections.forEach((section) => {
- if (section.conflict) {
- count += 1;
- }
- });
- } else {
- count += 1;
- }
- });
-
- return count;
- },
-
- getConflictsCountText() {
- const count = this.getConflictsCount();
- const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
-
- return `${count} ${text}`;
- },
-
- setViewType(viewType) {
- this.state.diffView = viewType;
- this.state.isParallel = viewType === VIEW_TYPES.PARALLEL;
-
- Cookies.set('diff_view', viewType);
- },
-
- getHeadHeaderLine(id) {
- return {
- id,
- richText: HEAD_HEADER_TEXT,
- buttonTitle: HEAD_BUTTON_TITLE,
- type: 'new',
- section: 'head',
- isHeader: true,
- isHead: true,
- isSelected: false,
- isUnselected: false,
- };
- },
-
- decorateLineForInlineView(line, id, conflict) {
- const { type } = line;
- line.id = id;
- line.hasConflict = conflict;
- line.isHead = type === 'new';
- line.isOrigin = type === 'old';
- line.hasMatch = type === 'match';
- line.richText = line.rich_text;
- line.isSelected = false;
- line.isUnselected = false;
- },
-
- getLineForParallelView(line, id, lineType, isHead) {
- const { old_line, new_line, rich_text } = line;
- const hasConflict = lineType === 'conflict';
-
- return {
- id,
- lineType,
- hasConflict,
- isHead: hasConflict && isHead,
- isOrigin: hasConflict && !isHead,
- hasMatch: lineType === 'match',
- lineNumber: isHead ? new_line : old_line,
- section: isHead ? 'head' : 'origin',
- richText: rich_text,
- isSelected: false,
- isUnselected: false,
- };
- },
-
- getOriginHeaderLine(id) {
- return {
- id,
- richText: ORIGIN_HEADER_TEXT,
- buttonTitle: ORIGIN_BUTTON_TITLE,
- type: 'old',
- section: 'origin',
- isHeader: true,
- isOrigin: true,
- isSelected: false,
- isUnselected: false,
- };
- },
-
- getFilePath(file) {
- const { old_path, new_path } = file;
- return old_path === new_path ? new_path : `${old_path} → ${new_path}`;
- },
-
- checkLineLengths(linesObj) {
- const { left, right } = linesObj;
-
- if (left.length !== right.length) {
- if (left.length > right.length) {
- const diff = left.length - right.length;
- for (let i = 0; i < diff; i += 1) {
- right.push({ lineType: 'emptyLine', richText: '' });
- }
- } else {
- const diff = right.length - left.length;
- for (let i = 0; i < diff; i += 1) {
- left.push({ lineType: 'emptyLine', richText: '' });
- }
- }
- }
- },
-
- setPromptConfirmationState(file, state) {
- file.promptDiscardConfirmation = state;
- },
-
- setFileResolveMode(file, mode) {
- if (mode === INTERACTIVE_RESOLVE_MODE) {
- file.showEditor = false;
- } else if (mode === EDIT_RESOLVE_MODE) {
- // Restore Interactive mode when switching to Edit mode
- file.showEditor = true;
- file.loadEditor = true;
- file.resolutionData = {};
-
- this.restoreFileLinesState(file);
- }
-
- file.resolveMode = mode;
- },
-
- restoreFileLinesState(file) {
- file.inlineLines.forEach((line) => {
- if (line.hasConflict || line.isHeader) {
- line.isSelected = false;
- line.isUnselected = false;
- }
- });
-
- file.parallelLines.forEach((lines) => {
- const left = lines[0];
- const right = lines[1];
- const isLeftMatch = left.hasConflict || left.isHeader;
- const isRightMatch = right.hasConflict || right.isHeader;
-
- if (isLeftMatch || isRightMatch) {
- left.isSelected = false;
- left.isUnselected = false;
- right.isSelected = false;
- right.isUnselected = false;
- }
- });
- },
-
- isReadyToCommit() {
- const { files } = this.state.conflictsData;
- const hasCommitMessage = $.trim(this.state.conflictsData.commitMessage).length;
- let unresolved = 0;
-
- for (let i = 0, l = files.length; i < l; i += 1) {
- const file = files[i];
-
- if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
- let numberConflicts = 0;
- const resolvedConflicts = Object.keys(file.resolutionData).length;
-
- // We only check for conflicts type 'text'
- // since conflicts `text_editor` can´t be resolved in interactive mode
- if (file.type === CONFLICT_TYPES.TEXT) {
- for (let j = 0, k = file.sections.length; j < k; j += 1) {
- if (file.sections[j].conflict) {
- numberConflicts += 1;
- }
- }
-
- if (resolvedConflicts !== numberConflicts) {
- unresolved += 1;
- }
- }
- } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
- // Unlikely to happen since switching to Edit mode saves content automatically.
- // Checking anyway in case the save strategy changes in the future
- if (!file.content) {
- unresolved += 1;
- continue;
- }
- }
- }
-
- return !this.state.isSubmitting && hasCommitMessage && !unresolved;
- },
-
- getCommitButtonText() {
- const initial = s__('MergeConflict|Commit to source branch');
- const inProgress = s__('MergeConflict|Committing...');
-
- return this.state ? (this.state.isSubmitting ? inProgress : initial) : initial;
- },
-
- getCommitData() {
- let commitData = {};
-
- commitData = {
- commit_message: this.state.conflictsData.commitMessage,
- files: [],
- };
-
- this.state.conflictsData.files.forEach((file) => {
- const addFile = {
- old_path: file.old_path,
- new_path: file.new_path,
- };
-
- if (file.type === CONFLICT_TYPES.TEXT) {
- // Submit only one data for type of editing
- if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) {
- addFile.sections = file.resolutionData;
- } else if (file.resolveMode === EDIT_RESOLVE_MODE) {
- addFile.content = file.content;
- }
- } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) {
- addFile.content = file.content;
- }
-
- commitData.files.push(addFile);
- });
-
- return commitData;
- },
-
- handleSelected(file, sectionId, selection) {
- Vue.set(file.resolutionData, sectionId, selection);
-
- file.inlineLines.forEach((line) => {
- if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
- this.markLine(line, selection);
- }
- });
-
- file.parallelLines.forEach((lines) => {
- const left = lines[0];
- const right = lines[1];
- const hasSameId = right.id === sectionId || left.id === sectionId;
- const isLeftMatch = left.hasConflict || left.isHeader;
- const isRightMatch = right.hasConflict || right.isHeader;
-
- if (hasSameId && (isLeftMatch || isRightMatch)) {
- this.markLine(left, selection);
- this.markLine(right, selection);
- }
- });
- },
-
- markLine(line, selection) {
- if (selection === 'head' && line.isHead) {
- line.isSelected = true;
- line.isUnselected = false;
- } else if (selection === 'origin' && line.isOrigin) {
- line.isSelected = true;
- line.isUnselected = false;
- } else {
- line.isSelected = false;
- line.isUnselected = true;
- }
- },
-
- setSubmitState(state) {
- this.state.isSubmitting = state;
- },
-
- fileTextTypePresent() {
- return this.state.conflictsData.files.some((f) => f.type === CONFLICT_TYPES.TEXT);
- },
- };
-})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 4b73dd317cd..cf02c6fbd6b 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,100 +1,32 @@
-import $ from 'jquery';
import Vue from 'vue';
-import { __ } from '~/locale';
-import { deprecatedCreateFlash as createFlash } from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
-import './merge_conflict_store';
-import syntaxHighlight from '../syntax_highlight';
import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue';
-import MergeConflictsService from './merge_conflict_service';
+import { createStore } from './store';
export default function initMergeConflicts() {
- const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts');
- const { mergeConflictsStore } = gl.mergeConflicts;
- const mergeConflictsService = new MergeConflictsService({
- conflictsPath: conflictsEl.dataset.conflictsPath,
- resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath,
- });
- const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset;
+ const {
+ sourceBranchPath,
+ mergeRequestPath,
+ conflictsPath,
+ resolveConflictsPath,
+ } = conflictsEl.dataset;
initIssuableSidebar();
+ const store = createStore();
+
return new Vue({
el: conflictsEl,
+ store,
provide: {
sourceBranchPath,
mergeRequestPath,
- },
- data: mergeConflictsStore.state,
- computed: {
- conflictsCountText() {
- return mergeConflictsStore.getConflictsCountText();
- },
- readyToCommit() {
- return mergeConflictsStore.isReadyToCommit();
- },
- commitButtonText() {
- return mergeConflictsStore.getCommitButtonText();
- },
- showDiffViewTypeSwitcher() {
- return mergeConflictsStore.fileTextTypePresent();
- },
+ resolveConflictsPath,
},
created() {
- mergeConflictsService
- .fetchConflictsData()
- .then(({ data }) => {
- if (data.type === 'error') {
- mergeConflictsStore.setFailedRequest(data.message);
- } else {
- mergeConflictsStore.setConflictsData(data);
- }
-
- mergeConflictsStore.setLoadingState(false);
-
- this.$nextTick(() => {
- syntaxHighlight($('.js-syntax-highlight'));
- });
- })
- .catch(() => {
- mergeConflictsStore.setLoadingState(false);
- mergeConflictsStore.setFailedRequest();
- });
- },
- methods: {
- handleViewTypeChange(viewType) {
- mergeConflictsStore.setViewType(viewType);
- },
- onClickResolveModeButton(file, mode) {
- if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) {
- mergeConflictsStore.setPromptConfirmationState(file, true);
- return;
- }
-
- mergeConflictsStore.setFileResolveMode(file, mode);
- },
- acceptDiscardConfirmation(file) {
- mergeConflictsStore.setPromptConfirmationState(file, false);
- mergeConflictsStore.setFileResolveMode(file, INTERACTIVE_RESOLVE_MODE);
- },
- cancelDiscardConfirmation(file) {
- mergeConflictsStore.setPromptConfirmationState(file, false);
- },
- commit() {
- mergeConflictsStore.setSubmitState(true);
-
- mergeConflictsService
- .submitResolveConflicts(mergeConflictsStore.getCommitData())
- .then(({ data }) => {
- window.location.href = data.redirect_to;
- })
- .catch(() => {
- mergeConflictsStore.setSubmitState(false);
- createFlash(__('Failed to save merge conflicts resolutions. Please try again!'));
- });
- },
+ store.dispatch('fetchConflictsData', conflictsPath);
},
render(createElement) {
return createElement(MergeConflictsResolverApp);
diff --git a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js
deleted file mode 100644
index 364ae2b2688..00000000000
--- a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export default {
- methods: {
- handleSelected(file, sectionId, selection) {
- gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection);
- },
- },
-};
diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js
index 8036e90c58c..df515c4ac1a 100644
--- a/app/assets/javascripts/merge_conflicts/store/actions.js
+++ b/app/assets/javascripts/merge_conflicts/store/actions.js
@@ -118,3 +118,8 @@ export const handleSelected = ({ commit, state, getters }, { file, line: { id, s
commit(types.UPDATE_FILE, { file: updated, index });
};
+
+export const updateFile = ({ commit, getters }, file) => {
+ const index = getters.getFileIndex(file);
+ commit(types.UPDATE_FILE, { file, index });
+};
diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js
index 03e425fb478..54f3d6ec4bc 100644
--- a/app/assets/javascripts/merge_conflicts/store/getters.js
+++ b/app/assets/javascripts/merge_conflicts/store/getters.js
@@ -67,7 +67,7 @@ export const isReadyToCommit = (state) => {
}
}
- return !state.isSubmitting && hasCommitMessage && !unresolved;
+ return Boolean(!state.isSubmitting && hasCommitMessage && !unresolved);
};
export const getCommitButtonText = (state) => {
diff --git a/app/assets/javascripts/merge_request/components/status_box.vue b/app/assets/javascripts/merge_request/components/status_box.vue
index 5d2660d65e6..526aafc1def 100644
--- a/app/assets/javascripts/merge_request/components/status_box.vue
+++ b/app/assets/javascripts/merge_request/components/status_box.vue
@@ -13,7 +13,7 @@ const CLASSES = {
const STATUS = {
opened: [__('Open'), 'issue-open-m'],
locked: [__('Open'), 'issue-open-m'],
- closed: [__('Closed'), 'close'],
+ closed: [__('Closed'), 'issue-close'],
merged: [__('Merged'), 'git-merge'],
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 81b9db6b4d5..67b24793a65 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -264,7 +264,7 @@ export default class MergeRequestTabs {
}
}
- // Replaces the current Merge Request-specific action in the URL with a new one
+ // Replaces the current merge request-specific action in the URL with a new one
//
// If the action is "notes", the URL is reset to the standard
// `MergeRequests#show` route.
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index f4b60fc0961..b992eaff779 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -11,7 +11,6 @@ import boardsStore, {
boardStoreIssueSet,
boardStoreIssueDelete,
} from './boards/stores/boards_store';
-import ModalStore from './boards/stores/modal_store';
import axios from './lib/utils/axios_utils';
import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility';
@@ -211,7 +210,7 @@ export default class MilestoneSelect {
const { e } = clickEvent;
let selected = clickEvent.selectedObj;
- let data, modalStoreFilter;
+ let data;
if (!selected) return;
if (options.handleClick) {
@@ -234,14 +233,7 @@ export default class MilestoneSelect {
return;
}
- if ($dropdown.closest('.add-issues-modal').length) {
- modalStoreFilter = ModalStore.store.filter;
- }
-
- if (modalStoreFilter) {
- modalStoreFilter[$dropdown.data('fieldName')] = selected.name;
- e.preventDefault();
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
deleted file mode 100644
index 05f2f15fa9a..00000000000
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import $ from 'jquery';
-import { deprecatedCreateFlash as flash } from './flash';
-import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
-
-/**
- * In each pipelines table we have a mini pipeline graph for each pipeline.
- *
- * When we click in a pipeline stage, we need to make an API call to get the
- * builds list to render in a dropdown.
- *
- * The container should be the table element.
- *
- * The stage icon clicked needs to have the following HTML structure:
- * <div class="dropdown">
- * <button class="dropdown js-builds-dropdown-button" data-toggle="dropdown"></button>
- * <div class="js-builds-dropdown-container dropdown-menu"></div>
- * </div>
- */
-
-export default class MiniPipelineGraph {
- constructor(opts = {}) {
- this.container = opts.container || '';
- this.dropdownListSelector = '.js-builds-dropdown-container';
- this.getBuildsList = this.getBuildsList.bind(this);
- }
-
- /**
- * Adds the event listener when the dropdown is opened.
- * All dropdown events are fired at the .dropdown-menu's parent element.
- */
- bindEvents() {
- $(document)
- .off('shown.bs.dropdown', this.container)
- .on('shown.bs.dropdown', this.container, this.getBuildsList);
- }
-
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(document).on(
- 'click',
- `${this.container} .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item`,
- (e) => {
- e.stopPropagation();
- },
- );
- }
-
- /**
- * For the clicked stage, renders the given data in the dropdown list.
- *
- * @param {HTMLElement} stageContainer
- * @param {Object} data
- */
- renderBuildsList(stageContainer, data) {
- const dropdownContainer = stageContainer.parentElement.querySelector(
- `${this.dropdownListSelector} .js-builds-dropdown-list ul`,
- );
-
- dropdownContainer.innerHTML = data;
- }
-
- /**
- * For the clicked stage, gets the list of builds.
- *
- * All dropdown events have a relatedTarget property,
- * whose value is the toggling anchor element.
- *
- * @param {Object} e bootstrap dropdown event
- * @return {Promise}
- */
- getBuildsList(e) {
- const button = e.relatedTarget;
- const endpoint = button.dataset.stageEndpoint;
-
- this.renderBuildsList(button, '');
- this.toggleLoading(button);
-
- axios
- .get(endpoint)
- .then(({ data }) => {
- this.toggleLoading(button);
- this.renderBuildsList(button, data.html);
- this.stopDropdownClickPropagation();
- })
- .catch(() => {
- this.toggleLoading(button);
- if ($(button).parent().hasClass('open')) {
- $(button).dropdown('toggle');
- }
- flash(__('An error occurred while fetching the builds.'), 'alert');
- });
- }
-
- /**
- * Toggles the visibility of the loading icon.
- *
- * @param {HTMLElement} stageContainer
- * @return {type}
- */
- toggleLoading(stageContainer) {
- stageContainer.parentElement
- .querySelector(`${this.dropdownListSelector} .js-builds-dropdown-loading`)
- .classList.toggle('hidden');
- }
-}
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 3c423bea368..05b5b760f0a 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -14,6 +14,7 @@ import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import invalidUrl from '~/lib/utils/invalid_url';
import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
@@ -24,6 +25,9 @@ import DashboardsDropdown from './dashboards_dropdown.vue';
import RefreshButton from './refresh_button.vue';
export default {
+ i18n: {
+ metricsSettings: s__('Metrics|Metrics Settings'),
+ },
components: {
GlIcon,
GlButton,
@@ -282,7 +286,8 @@ export default {
data-testid="metrics-settings-button"
icon="settings"
:href="operationsSettingsPath"
- :title="s__('Metrics|Metrics Settings')"
+ :title="$options.i18n.metricsSettings"
+ :aria-label="$options.i18n.metricsSettings"
/>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
index 847339e814a..e5f0206bb8b 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
@@ -10,6 +10,7 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
+import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
import DashboardPanel from './dashboard_panel.vue';
@@ -24,6 +25,9 @@ metrics:
`;
export default {
+ i18n: {
+ refreshButtonLabel: s__('Metrics|Refresh Prometheus data'),
+ },
components: {
GlCard,
GlForm,
@@ -191,7 +195,8 @@ export default {
v-gl-tooltip
data-testid="previewRefreshButton"
icon="retry"
- :title="s__('Metrics|Refresh Prometheus data')"
+ :title="$options.i18n.refreshButtonLabel"
+ :aria-label="$options.i18n.refreshButtonLabel"
@click="onRefresh"
/>
<dashboard-panel :graph-data="panelPreviewGraphData" />
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
index 627af202028..1765a2f3d5d 100644
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
@@ -23,7 +23,7 @@ export default {
},
},
radioVals: {
- /* Use the default branch (e.g. master) */
+ /* Use the default branch (e.g. main) */
DEFAULT: 'DEFAULT',
/* Create a new branch */
NEW: 'NEW',
diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue
index 3f9f57d4ac1..fb5ab12916e 100644
--- a/app/assets/javascripts/monitoring/components/links_section.vue
+++ b/app/assets/javascripts/monitoring/components/links_section.vue
@@ -15,7 +15,7 @@ export default {
<template>
<div
ref="linksSection"
- class="gl-sm-display-flex gl-flex-sm-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section"
+ class="gl-sm-display-flex gl-sm-flex-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section"
>
<div
v-for="(link, key) in links"
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
index 3daf5b38933..0b80043a92c 100644
--- a/app/assets/javascripts/monitoring/components/refresh_button.vue
+++ b/app/assets/javascripts/monitoring/components/refresh_button.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import Visibility from 'visibilityjs';
import { mapActions } from 'vuex';
-import { n__, __ } from '~/locale';
+import { n__, __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -45,6 +45,9 @@ const makeInterval = (length = 0, unit = 's') => {
};
export default {
+ i18n: {
+ refreshDashboard: s__('Metrics|Refresh dashboard'),
+ },
components: {
GlButtonGroup,
GlButton,
@@ -148,7 +151,8 @@ export default {
v-gl-tooltip
class="gl-flex-grow-1"
variant="default"
- :title="s__('Metrics|Refresh dashboard')"
+ :title="$options.i18n.refreshDashboard"
+ :aria-label="$options.i18n.refreshDashboard"
icon="retry"
@click="refresh"
/>
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 9a93e90c2bb..d85fd10be45 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -25,6 +25,9 @@ export default () => {
return {
noteableData,
+ endpoints: {
+ metadata: notesDataset.endpointMetadata,
+ },
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: JSON.parse(notesDataset.notesData),
helpPagePath: notesDataset.helpPagePath,
@@ -54,6 +57,9 @@ export default () => {
},
created() {
this.setActiveTab(window.mrTabs.getCurrentAction());
+ this.setEndpoints(this.endpoints);
+
+ this.fetchMrMetadata();
},
mounted() {
this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
@@ -65,7 +71,7 @@ export default () => {
window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab);
},
methods: {
- ...mapActions(['setActiveTab']),
+ ...mapActions(['setActiveTab', 'setEndpoints', 'fetchMrMetadata']),
updateDiscussionTabCounter() {
this.notesCountBadge.text(this.discussionTabCounter);
},
diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js
index 426c6a00d5e..bc66d1dd68f 100644
--- a/app/assets/javascripts/mr_notes/stores/actions.js
+++ b/app/assets/javascripts/mr_notes/stores/actions.js
@@ -1,7 +1,32 @@
+import axios from '~/lib/utils/axios_utils';
+
import types from './mutation_types';
-export default {
- setActiveTab({ commit }, tab) {
- commit(types.SET_ACTIVE_TAB, tab);
- },
-};
+export function setActiveTab({ commit }, tab) {
+ commit(types.SET_ACTIVE_TAB, tab);
+}
+
+export function setEndpoints({ commit }, endpoints) {
+ commit(types.SET_ENDPOINTS, endpoints);
+}
+
+export function setMrMetadata({ commit }, metadata) {
+ commit(types.SET_MR_METADATA, metadata);
+}
+
+export function fetchMrMetadata({ dispatch, state }) {
+ if (state.endpoints?.metadata) {
+ axios
+ .get(state.endpoints.metadata)
+ .then((response) => {
+ dispatch('setMrMetadata', response.data);
+ })
+ .catch(() => {
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/324740
+ // We can't even do a simple console warning here because
+ // the pipeline will fail. However, the issue above will
+ // eventually handle errors appropriately.
+ // console.warn('Failed to load MR Metadata for the Overview tab.');
+ });
+ }
+}
diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js
index c28e666943b..52e12ba664c 100644
--- a/app/assets/javascripts/mr_notes/stores/modules/index.js
+++ b/app/assets/javascripts/mr_notes/stores/modules/index.js
@@ -1,10 +1,12 @@
-import actions from '../actions';
+import * as actions from '../actions';
import getters from '../getters';
import mutations from '../mutations';
export default () => ({
state: {
+ endpoints: {},
activeTab: null,
+ mrMetadata: {},
},
actions,
getters,
diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js
index 105104361cf..88cf6e48988 100644
--- a/app/assets/javascripts/mr_notes/stores/mutation_types.js
+++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js
@@ -1,3 +1,5 @@
export default {
SET_ACTIVE_TAB: 'SET_ACTIVE_TAB',
+ SET_ENDPOINTS: 'SET_ENDPOINTS',
+ SET_MR_METADATA: 'SET_MR_METADATA',
};
diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js
index 8175aa9488f..6af6adb4e18 100644
--- a/app/assets/javascripts/mr_notes/stores/mutations.js
+++ b/app/assets/javascripts/mr_notes/stores/mutations.js
@@ -4,4 +4,10 @@ export default {
[types.SET_ACTIVE_TAB](state, tab) {
Object.assign(state, { activeTab: tab });
},
+ [types.SET_ENDPOINTS](state, endpoints) {
+ Object.assign(state, { endpoints });
+ },
+ [types.SET_MR_METADATA](state, metadata) {
+ Object.assign(state, { mrMetadata: metadata });
+ },
};
diff --git a/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue b/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue
new file mode 100644
index 00000000000..8de6e910bb6
--- /dev/null
+++ b/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export default {
+ name: 'LockPopovers',
+ components: {
+ GlPopover,
+ GlSprintf,
+ GlLink,
+ },
+ data() {
+ return {
+ targets: [],
+ };
+ },
+ mounted() {
+ this.targets = [...document.querySelectorAll('.js-cascading-settings-lock-popover-target')].map(
+ (el) => {
+ const {
+ dataset: { popoverData },
+ } = el;
+
+ const {
+ lockedByAncestor,
+ lockedByApplicationSetting,
+ ancestorNamespace,
+ } = convertObjectPropsToCamelCase(JSON.parse(popoverData || '{}'), { deep: true });
+
+ return {
+ el,
+ lockedByAncestor,
+ lockedByApplicationSetting,
+ ancestorNamespace,
+ };
+ },
+ );
+ },
+};
+</script>
+
+<template>
+ <div>
+ <template
+ v-for="(
+ { el, lockedByApplicationSetting, lockedByAncestor, ancestorNamespace }, index
+ ) in targets"
+ >
+ <gl-popover
+ v-if="lockedByApplicationSetting || lockedByAncestor"
+ :key="index"
+ :target="el"
+ placement="top"
+ >
+ <template #title>{{ s__('CascadingSettings|Setting enforced') }}</template>
+ <p data-testid="cascading-settings-lock-popover">
+ <template v-if="lockedByApplicationSetting">{{
+ s__('CascadingSettings|This setting has been enforced by an instance admin.')
+ }}</template>
+
+ <gl-sprintf
+ v-else-if="lockedByAncestor && ancestorNamespace"
+ :message="
+ s__('CascadingSettings|This setting has been enforced by an owner of %{link}.')
+ "
+ >
+ <template #link>
+ <gl-link :href="ancestorNamespace.path" class="gl-font-sm">{{
+ ancestorNamespace.fullName
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-popover>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/namespaces/cascading_settings/index.js b/app/assets/javascripts/namespaces/cascading_settings/index.js
new file mode 100644
index 00000000000..3e44d1e9e2d
--- /dev/null
+++ b/app/assets/javascripts/namespaces/cascading_settings/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import LockPopovers from './components/lock_popovers.vue';
+
+export const initCascadingSettingsLockPopovers = () => {
+ const el = document.querySelector('.js-cascading-settings-lock-popovers');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(LockPopovers);
+ },
+ });
+};
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 8ed40f36103..b5c59f34e87 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -22,7 +22,7 @@ import syntaxHighlight from '~/syntax_highlight';
import Autosave from './autosave';
import loadAwardsHandler from './awards_handler';
import CommentTypeToggle from './comment_type_toggle';
-import { deprecatedCreateFlash as Flash } from './flash';
+import createFlash from './flash';
import { defaultAutocompleteConfig } from './gfm_auto_complete';
import GLForm from './gl_form';
import axios from './lib/utils/axios_utils';
@@ -106,7 +106,7 @@ export default class Notes {
this.collapseLongCommitList();
this.setViewType(view);
- // We are in the Merge Requests page so we need another edit form for Changes tab
+ // We are in the merge requests page so we need another edit form for Changes tab
if (getPagePath(1) === 'merge_requests') {
$('.note-edit-form').clone().addClass('mr-note-edit-form').insertAfter('.note-edit-form');
}
@@ -399,7 +399,11 @@ export default class Notes {
if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) {
$notesList.find('.system-note.being-posted').remove();
}
- this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0));
+ this.addFlash({
+ message: noteEntity.errors.commands_only,
+ type: 'notice',
+ parent: this.parentTimeline.get(0),
+ });
this.refresh();
}
return;
@@ -620,20 +624,21 @@ export default class Notes {
} else if ($form.hasClass('js-discussion-note-form')) {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
- return this.addFlash(
- __(
+ return this.addFlash({
+ message: __(
'Your comment could not be submitted! Please check your network connection and try again.',
),
- 'alert',
- formParentTimeline.get(0),
- );
+ type: 'alert',
+ parent: formParentTimeline.get(0),
+ });
}
updateNoteError() {
- // eslint-disable-next-line no-new
- new Flash(
- __('Your comment could not be updated! Please check your network connection and try again.'),
- );
+ createFlash({
+ message: __(
+ 'Your comment could not be updated! Please check your network connection and try again.',
+ ),
+ });
}
/**
@@ -1289,7 +1294,7 @@ export default class Notes {
}
addFlash(...flashParams) {
- this.flashContainer = new Flash(...flashParams);
+ this.flashContainer = createFlash(...flashParams);
}
clearFlash() {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 08d7c745791..79d8ce78329 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -84,6 +84,7 @@ export default {
'getNoteableDataByProp',
'getNotesData',
'openState',
+ 'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
isNoteTypeComment() {
@@ -171,6 +172,9 @@ export default {
endpoint() {
return this.getNoteableData.create_note_path;
},
+ draftEndpoint() {
+ return this.getNotesData.draftsPath;
+ },
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
? this.$options.i18n.mergeRequest
@@ -214,12 +218,15 @@ export default {
this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK];
}
},
- handleSave(withIssueAction) {
+ handleSaveDraft() {
+ this.handleSave({ isDraft: true });
+ },
+ handleSave({ withIssueAction = false, isDraft = false } = {}) {
this.errors = [];
if (this.note.length) {
const noteData = {
- endpoint: this.endpoint,
+ endpoint: isDraft ? this.draftEndpoint : this.endpoint,
data: {
note: {
noteable_type: this.noteableType,
@@ -229,6 +236,7 @@ export default {
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
},
+ isDraft,
};
if (this.noteType === constants.DISCUSSION) {
@@ -392,62 +400,82 @@ export default {
</markdown-field>
</comment-field-layout>
<div class="note-form-actions">
- <gl-form-checkbox
- v-if="confidentialNotesEnabled && canSetConfidential"
- v-model="noteIsConfidential"
- class="gl-mb-6"
- data-testid="confidential-note-checkbox"
- >
- {{ $options.i18n.confidential }}
- <gl-icon
- v-gl-tooltip:tooltipcontainer.bottom
- name="question"
- :size="16"
- :title="$options.i18n.confidentialVisibility"
- class="gl-text-gray-500"
- />
- </gl-form-checkbox>
- <gl-dropdown
- split
- :text="commentButtonTitle"
- class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
- category="primary"
- variant="success"
- :disabled="disableSubmitButton"
- data-testid="comment-button"
- data-qa-selector="comment_button"
- :data-track-label="trackingLabel"
- data-track-event="click_button"
- @click="handleSave()"
- >
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeComment"
- :selected="isNoteTypeComment"
- @click="setNoteTypeToComment"
+ <template v-if="hasDrafts">
+ <gl-button
+ :disabled="disableSubmitButton"
+ data-testid="add-to-review-button"
+ type="submit"
+ category="primary"
+ variant="success"
+ @click.prevent="handleSaveDraft()"
+ >{{ __('Add to review') }}</gl-button
+ >
+ <gl-button
+ :disabled="disableSubmitButton"
+ data-testid="add-comment-now-button"
+ category="secondary"
+ @click.prevent="handleSave()"
+ >{{ __('Add comment now') }}</gl-button
+ >
+ </template>
+ <template v-else>
+ <gl-form-checkbox
+ v-if="confidentialNotesEnabled && canSetConfidential"
+ v-model="noteIsConfidential"
+ class="gl-mb-6"
+ data-testid="confidential-note-checkbox"
>
- <strong>{{ $options.i18n.submitButton.comment }}</strong>
- <p class="gl-m-0">{{ commentDescription }}</p>
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeDiscussion"
- :selected="isNoteTypeDiscussion"
- data-qa-selector="discussion_menu_item"
- @click="setNoteTypeToDiscussion"
+ {{ $options.i18n.confidential }}
+ <gl-icon
+ v-gl-tooltip:tooltipcontainer.bottom
+ name="question"
+ :size="16"
+ :title="$options.i18n.confidentialVisibility"
+ class="gl-text-gray-500"
+ />
+ </gl-form-checkbox>
+ <gl-dropdown
+ split
+ :text="commentButtonTitle"
+ class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
+ category="primary"
+ variant="confirm"
+ :disabled="disableSubmitButton"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
+ @click="handleSave()"
>
- <strong>{{ $options.i18n.submitButton.startThread }}</strong>
- <p class="gl-m-0">{{ startDiscussionDescription }}</p>
- </gl-dropdown-item>
- </gl-dropdown>
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeComment"
+ :selected="isNoteTypeComment"
+ @click="setNoteTypeToComment"
+ >
+ <strong>{{ $options.i18n.submitButton.comment }}</strong>
+ <p class="gl-m-0">{{ commentDescription }}</p>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeDiscussion"
+ :selected="isNoteTypeDiscussion"
+ data-qa-selector="discussion_menu_item"
+ @click="setNoteTypeToDiscussion"
+ >
+ <strong>{{ $options.i18n.submitButton.startThread }}</strong>
+ <p class="gl-m-0">{{ startDiscussionDescription }}</p>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
<gl-button
v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
:class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
:disabled="isSubmitting"
data-testid="close-reopen-button"
- @click="handleSave(true)"
+ @click="handleSave({ withIssueAction: true })"
>{{ issueActionButtonTitle }}</gl-button
>
</div>
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index fa3c900c337..7e8bb75902b 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -1,6 +1,11 @@
<script>
/* global Mousetrap */
import 'mousetrap';
+import {
+ keysFor,
+ MR_NEXT_UNRESOLVED_DISCUSSION,
+ MR_PREVIOUS_UNRESOLVED_DISCUSSION,
+} from '~/behaviors/shortcuts/keybindings';
import eventHub from '~/notes/event_hub';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
@@ -10,12 +15,12 @@ export default {
eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
mounted() {
- Mousetrap.bind('n', this.jumpToNextDiscussion);
- Mousetrap.bind('p', this.jumpToPreviousDiscussion);
+ Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion);
+ Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion);
},
beforeDestroy() {
- Mousetrap.unbind('n');
- Mousetrap.unbind('p');
+ Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION));
+ Mousetrap.unbind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION));
eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 0f74d78c8e0..dfe2763d8bd 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -121,6 +121,7 @@ export default {
:is="componentName(firstNote)"
:note="componentData(firstNote)"
:line="line || diffLine"
+ :discussion-file="discussion.diff_file"
:commit="commit"
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
@@ -167,6 +168,7 @@ export default {
v-for="(note, index) in discussion.notes"
:key="note.id"
:note="componentData(note)"
+ :discussion-file="discussion.diff_file"
:help-page-path="helpPagePath"
:line="diffLine"
:discussion-root="index === 0"
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index cace382ccd6..5f429cbf462 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -1,7 +1,11 @@
<script>
import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
export default {
+ i18n: {
+ buttonLabel: s__('MergeRequests|Resolve this thread in a new issue'),
+ },
name: 'ResolveWithIssueButton',
components: {
GlButton,
@@ -23,7 +27,8 @@ export default {
<gl-button
v-gl-tooltip
:href="url"
- :title="s__('MergeRequests|Resolve this thread in a new issue')"
+ :title="$options.i18n.buttonLabel"
+ :aria-label="$options.i18n.buttonLabel"
class="new-issue-for-discussion discussion-create-issue-btn"
icon="issue-new"
/>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index ed6701b34e8..24399e669a6 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -13,6 +13,12 @@ import { splitCamelCase } from '../../lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
export default {
+ i18n: {
+ addReactionLabel: __('Add reaction'),
+ editCommentLabel: __('Edit comment'),
+ deleteCommentLabel: __('Delete comment'),
+ moreActionsLabel: __('More actions'),
+ },
name: 'NoteActions',
components: {
GlIcon,
@@ -119,9 +125,11 @@ export default {
type: Boolean,
required: true,
},
+ // This can be undefined when `canAwardEmoji` is false
awardPath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
computed: {
@@ -301,9 +309,9 @@ export default {
category="tertiary"
variant="default"
size="small"
- title="Add reaction"
+ :title="$options.i18n.addReactionLabel"
+ :aria-label="$options.i18n.addReactionLabel"
data-position="right"
- :aria-label="__('Add reaction')"
>
<span class="reaction-control-icon reaction-control-icon-neutral">
<gl-icon name="slight-smile" />
@@ -325,32 +333,35 @@ export default {
<gl-button
v-if="canEdit"
v-gl-tooltip
- title="Edit comment"
+ :title="$options.i18n.editCommentLabel"
+ :aria-label="$options.i18n.editCommentLabel"
icon="pencil"
size="small"
category="tertiary"
- class="note-action-button js-note-edit btn btn-transparent"
+ class="note-action-button js-note-edit"
data-qa-selector="note_edit_button"
@click="onEdit"
/>
<gl-button
v-if="showDeleteAction"
v-gl-tooltip
- title="Delete comment"
+ :title="$options.i18n.deleteCommentLabel"
+ :aria-label="$options.i18n.deleteCommentLabel"
size="small"
icon="remove"
category="tertiary"
- class="note-action-button js-note-delete btn btn-transparent"
+ class="note-action-button js-note-delete"
@click="onDelete"
/>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions">
<gl-button
v-gl-tooltip
- title="More actions"
+ :title="$options.i18n.moreActionsLabel"
+ :aria-label="$options.i18n.moreActionsLabel"
icon="ellipsis_v"
size="small"
category="tertiary"
- class="note-action-button more-actions-toggle btn btn-transparent"
+ class="note-action-button more-actions-toggle"
data-toggle="dropdown"
@click="closeTooltip"
/>
diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue
index b20facc4032..c49f3e2de99 100644
--- a/app/assets/javascripts/notes/components/note_attachment.vue
+++ b/app/assets/javascripts/notes/components/note_attachment.vue
@@ -24,7 +24,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
- <img :src="attachment.url" class="note-image-attach" />
+ <img :src="attachment.url" class="note-image-attach col-lg-4" />
</a>
<div class="attachment">
<a
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index d74ade15de1..a70bac94b71 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -60,6 +60,11 @@ export default {
required: false,
default: null,
},
+ lines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
note: {
type: Object,
required: false,
@@ -333,6 +338,7 @@ export default {
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
+ :lines="lines"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
@@ -384,7 +390,7 @@ export default {
<gl-button
:disabled="isDisabled"
category="primary"
- variant="success"
+ variant="confirm"
class="gl-mr-3"
data-qa-selector="start_review_button"
@click="handleAddToReview"
@@ -418,7 +424,7 @@ export default {
<gl-button
:disabled="isDisabled"
category="primary"
- variant="success"
+ variant="confirm"
data-qa-selector="reply_comment_button"
class="gl-mr-3 js-vue-issue-save js-comment-button"
@click="handleUpdate()"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 185f4a70367..0feb77be653 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -48,6 +48,11 @@ export default {
required: false,
default: null,
},
+ discussionFile: {
+ type: Object,
+ required: false,
+ default: null,
+ },
helpPagePath: {
type: String,
required: false,
@@ -86,7 +91,7 @@ export default {
isRequesting: false,
isResolving: false,
commentLineStart: {},
- resolveAsThread: this.glFeatures.removeResolveNote,
+ resolveAsThread: true,
};
},
computed: {
@@ -139,14 +144,9 @@ export default {
return this.note.isDraft;
},
canResolve() {
- if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false;
+ if (!this.discussionRoot) return false;
- if (this.glFeatures.removeResolveNote) return this.note.current_user.can_resolve_discussion;
-
- return (
- this.note.current_user.can_resolve ||
- (this.note.isDraft && this.note.discussion_id !== null)
- );
+ return this.note.current_user.can_resolve_discussion;
},
lineRange() {
return this.note.position?.line_range;
@@ -172,12 +172,18 @@ export default {
return commentLineOptions(lines, this.commentLineStart, this.line.line_code);
},
diffFile() {
+ let fileResolvedFromAvailableSource;
+
if (this.commentLineStart.line_code) {
const lineCode = this.commentLineStart.line_code.split('_')[0];
- return this.getDiffFileByHash(lineCode);
+ fileResolvedFromAvailableSource = this.getDiffFileByHash(lineCode);
+ }
+
+ if (!fileResolvedFromAvailableSource && this.discussionFile) {
+ fileResolvedFromAvailableSource = this.discussionFile;
}
- return null;
+ return fileResolvedFromAvailableSource || null;
},
},
created() {
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 58cfd150659..433f75a752d 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -3,8 +3,10 @@ import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import draftNote from '../../batch_comments/components/draft_note.vue';
import { deprecatedCreateFlash as Flash } from '../../flash';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
@@ -32,6 +34,8 @@ export default {
discussionFilterNote,
OrderedLayout,
SidebarSubscription,
+ draftNote,
+ TimelineEntryItem,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -276,6 +280,9 @@ export default {
<ul id="notes-list" class="notes main-notes-list timeline">
<template v-for="discussion in allDiscussions">
<skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" />
+ <timeline-entry-item v-else-if="discussion.isDraft" :key="discussion.id">
+ <draft-note :draft="discussion" />
+ </timeline-entry-item>
<template v-else-if="discussion.isPlaceholderNote">
<placeholder-system-note
v-if="discussion.placeholderType === $options.systemNote"
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
index ed1f456c174..92c39fbb9f0 100644
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ b/app/assets/javascripts/notes/components/sort_discussion.vue
@@ -49,18 +49,17 @@ export default {
</script>
<template>
- <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile">
+ <div
+ data-testid="sort-discussion-filter"
+ class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
+ >
<local-storage-sync
:value="sortDirection"
:storage-key="storageKey"
:persist="persistSortOrder"
@input="setDiscussionSortDirection({ direction: $event })"
/>
- <gl-dropdown
- :text="dropdownText"
- data-testid="sort-discussion-filter"
- class="js-dropdown-text full-width-mobile"
- >
+ <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
<gl-dropdown-item
v-for="{ text, key, cls } in $options.SORT_OPTIONS"
:key="key"
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index baada4c5ce8..27ed8e203b0 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -1,24 +1,11 @@
import { deprecatedCreateFlash as Flash } from '~/flash';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
- mixins: [glFeatureFlagsMixin()],
computed: {
discussionResolved() {
if (this.discussion) {
- const { notes, resolved } = this.discussion;
-
- if (this.glFeatures.removeResolveNote) {
- return Boolean(resolved);
- }
-
- if (notes) {
- // Decide resolved state using store. Only valid for discussions.
- return notes.filter((note) => !note.system).every((note) => note.resolved);
- }
-
- return resolved;
+ return Boolean(this.discussion.resolved);
}
return this.note.resolved;
@@ -47,7 +34,7 @@ export default {
let endpoint =
discussion && this.discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`;
- if (this.glFeatures.removeResolveNote && this.discussionResolvePath) {
+ if (this.discussionResolvePath) {
endpoint = this.discussionResolvePath;
}
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 43d99937b8d..39f66063cfb 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -2,7 +2,23 @@ import { flattenDeep, clone } from 'lodash';
import * as constants from '../constants';
import { collapseSystemNotes } from './collapse_utils';
-export const discussions = (state) => {
+const getDraftComments = (state) => {
+ if (!state.batchComments) {
+ return [];
+ }
+
+ return state.batchComments.drafts
+ .filter((draft) => !draft.file_path && !draft.discussion_id)
+ .map((x) => ({
+ ...x,
+ // Treat a top-level draft note as individual_note so it's not included in
+ // expand/collapse threads
+ individual_note: true,
+ }))
+ .sort((a, b) => a.id - b.id);
+};
+
+export const discussions = (state, getters, rootState) => {
let discussionsInState = clone(state.discussions);
// NOTE: not testing bc will be removed when backend is finished.
@@ -22,11 +38,15 @@ export const discussions = (state) => {
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
}
+ discussionsInState = collapseSystemNotes(discussionsInState);
+
+ discussionsInState = discussionsInState.concat(getDraftComments(rootState));
+
if (state.discussionSortOrder === constants.DESC) {
discussionsInState = discussionsInState.reverse();
}
- return collapseSystemNotes(discussionsInState);
+ return discussionsInState;
};
export const convertedDisscussionIds = (state) => state.convertedDisscussionIds;
@@ -257,3 +277,6 @@ export const commentsDisabled = (state) => state.commentsDisabled;
export const suggestionsCount = (state, getters) =>
Object.values(getters.notesById).filter((n) => n.suggestions.length).length;
+
+export const hasDrafts = (state, getters, rootState, rootGetters) =>
+ Boolean(rootGetters['batchComments/hasDrafts']);
diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
index 0c0bbb744b3..a24612e4680 100644
--- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue
+++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue
@@ -31,7 +31,9 @@ export default {
<template>
<section class="settings no-animate">
<div class="settings-header">
- <h4 class="js-section-header">
+ <h4
+ class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only"
+ >
{{ s__('MetricsSettings|Metrics dashboard') }}
</h4>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue
index cd61d323d83..2e183b1b978 100644
--- a/app/assets/javascripts/packages/list/components/package_search.vue
+++ b/app/assets/javascripts/packages/list/components/package_search.vue
@@ -2,7 +2,8 @@
import { mapState, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
-import getTableHeaders from '../utils';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+import { sortableFields } from '../utils';
import PackageTypeToken from './tokens/package_type_token.vue';
export default {
@@ -16,7 +17,7 @@ export default {
operators: [{ value: '=', description: __('is'), default: 'true' }],
},
],
- components: { RegistrySearch },
+ components: { RegistrySearch, UrlSync },
computed: {
...mapState({
isGroupPage: (state) => state.config.isGroupPage,
@@ -24,7 +25,7 @@ export default {
filter: (state) => state.filter,
}),
sortableFields() {
- return getTableHeaders(this.isGroupPage);
+ return sortableFields(this.isGroupPage);
},
},
methods: {
@@ -38,13 +39,18 @@ export default {
</script>
<template>
- <registry-search
- :filter="filter"
- :sorting="sorting"
- :tokens="$options.tokens"
- :sortable-fields="sortableFields"
- @sorting:changed="updateSorting"
- @filter:changed="setFilter"
- @filter:submit="$emit('update')"
- />
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ :filter="filter"
+ :sorting="sorting"
+ :tokens="$options.tokens"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSorting"
+ @filter:changed="setFilter"
+ @filter:submit="$emit('update')"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
</template>
diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue
index 6176e15ffd4..426ad150ea9 100644
--- a/app/assets/javascripts/packages/list/components/package_title.vue
+++ b/app/assets/javascripts/packages/list/components/package_title.vue
@@ -11,25 +11,25 @@ export default {
MetadataItem,
},
props: {
- packagesCount: {
+ count: {
type: Number,
required: false,
default: null,
},
- packageHelpUrl: {
+ helpUrl: {
type: String,
required: true,
},
},
computed: {
showPackageCount() {
- return Number.isInteger(this.packagesCount);
+ return Number.isInteger(this.count);
},
packageAmountText() {
- return n__(`%d Package`, `%d Packages`, this.packagesCount);
+ return n__(`%d Package`, `%d Packages`, this.count);
},
infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }];
+ return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
},
},
i18n: {
diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue
index a609dfebedf..4c5fb0ee7c9 100644
--- a/app/assets/javascripts/packages/list/components/packages_list_app.vue
+++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue
@@ -5,9 +5,9 @@ import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants';
-import PackageSearch from './package_search.vue';
-import PackageTitle from './package_title.vue';
import PackageList from './packages_list.vue';
export default {
@@ -16,8 +16,38 @@ export default {
GlLink,
GlSprintf,
PackageList,
- PackageTitle,
- PackageSearch,
+ 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({
@@ -30,22 +60,32 @@ export default {
}),
emptySearch() {
return (
- this.filter.filter((f) => f.type !== 'filtered-search-term' || f.value?.data).length === 0
+ this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
);
},
emptyStateTitle() {
return this.emptySearch
- ? s__('PackageRegistry|There are no packages yet')
+ ? 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']),
+ ...mapActions([
+ 'requestPackagesList',
+ 'requestDeletePackage',
+ 'setSelectedType',
+ 'setSorting',
+ 'setFilter',
+ ]),
onPageChanged(page) {
return this.requestPackagesList({ page });
},
@@ -65,24 +105,21 @@ export default {
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
- noResults: s__(
- 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
- ),
},
};
</script>
<template>
<div>
- <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" />
- <package-search @update="requestPackagesList" />
+ <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="$options.i18n.noResults">
+ <gl-sprintf v-else :message="noResultsText">
<template #noPackagesLink="{ content }">
<gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index 25a55200df2..b4fe3c70dea 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -82,6 +82,10 @@ export const PACKAGE_TYPES = [
title: s__('PackageRegistry|PyPI'),
type: PackageType.PYPI,
},
+ {
+ title: s__('PackageRegistry|RubyGems'),
+ type: PackageType.RUBYGEMS,
+ },
];
export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
index 58b09c1ebd1..2911cf70a33 100644
--- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js
+++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js
@@ -1,11 +1,8 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import Translate from '~/vue_shared/translate';
import PackagesListApp from './components/packages_list_app.vue';
import { createStore } from './stores';
-Vue.use(VueApollo);
Vue.use(Translate);
export default () => {
@@ -13,14 +10,9 @@ export default () => {
const store = createStore();
store.dispatch('setInitialState', el.dataset);
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
return new Vue({
el,
store,
- apolloProvider,
components: {
PackagesListApp,
},
diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js
index ee89d3cdefe..537b30d2ca4 100644
--- a/app/assets/javascripts/packages/list/utils.js
+++ b/app/assets/javascripts/packages/list/utils.js
@@ -1,7 +1,7 @@
import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants';
-export default (isGroupPage) =>
- SORT_FIELDS.filter((f) => f.key !== LIST_KEY_PROJECT || isGroupPage);
+export const sortableFields = (isGroupPage) =>
+ SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage);
/**
* A small util function that works out if the delete action has deleted the
diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
new file mode 100644
index 00000000000..105f7bbe132
--- /dev/null
+++ b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ name: 'PackageIconAndName',
+ components: {
+ GlIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
+ <span><slot></slot></span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue
index 172b356227a..4de4c191e51 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
+import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { getPackageTypeLabel } from '../utils';
@@ -11,7 +11,6 @@ export default {
name: 'PackageListRow',
components: {
GlButton,
- GlIcon,
GlLink,
GlSprintf,
GlTruncate,
@@ -19,11 +18,23 @@ export default {
PackagePath,
PublishMethod,
ListItem,
+ PackageIconAndName: () =>
+ import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'),
+ InfrastructureIconAndName: () =>
+ import(
+ /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue'
+ ),
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
+ inject: {
+ iconComponent: {
+ from: 'iconComponent',
+ default: 'PackageIconAndName',
+ },
+ },
props: {
packageEntity: {
type: Object,
@@ -94,10 +105,9 @@ export default {
</gl-sprintf>
</div>
- <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type">
- <gl-icon name="package" class="gl-ml-3 gl-mr-2" />
- <span>{{ packageType }}</span>
- </div>
+ <component :is="iconComponent" v-if="showPackageType">
+ {{ packageType }}
+ </component>
<package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
</div>
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index c0f7f150337..f7de31c2c86 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -7,6 +7,7 @@ export const PackageType = {
NUGET: 'nuget',
PYPI: 'pypi',
COMPOSER: 'composer',
+ RUBYGEMS: 'rubygems',
GENERIC: 'generic',
};
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index d34372e89b6..bd35a47ca4d 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -10,19 +10,21 @@ export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : '');
export const getPackageTypeLabel = (packageType) => {
switch (packageType) {
case PackageType.CONAN:
- return s__('PackageType|Conan');
+ return s__('PackageRegistry|Conan');
case PackageType.MAVEN:
- return s__('PackageType|Maven');
+ return s__('PackageRegistry|Maven');
case PackageType.NPM:
- return s__('PackageType|npm');
+ return s__('PackageRegistry|npm');
case PackageType.NUGET:
- return s__('PackageType|NuGet');
+ return s__('PackageRegistry|NuGet');
case PackageType.PYPI:
- return s__('PackageType|PyPI');
+ return s__('PackageRegistry|PyPI');
+ case PackageType.RUBYGEMS:
+ return s__('PackageRegistry|RubyGems');
case PackageType.COMPOSER:
- return s__('PackageType|Composer');
+ return s__('PackageRegistry|Composer');
case PackageType.GENERIC:
- return s__('PackageType|Generic');
+ return s__('PackageRegistry|Generic');
default:
return null;
}
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
new file mode 100644
index 00000000000..3100a1a7296
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue
@@ -0,0 +1,17 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ name: 'InfrastructureIconAndName',
+ components: {
+ GlIcon,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon name="infrastructure-registry" class="gl-ml-3 gl-mr-2" />
+ <span>{{ s__('InfrastructureRegistry|Terraform') }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
new file mode 100644
index 00000000000..4928da862ea
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue
@@ -0,0 +1,45 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants';
+import { sortableFields } from '~/packages/list/utils';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+
+export default {
+ components: { RegistrySearch, UrlSync },
+ computed: {
+ ...mapState({
+ isGroupPage: (state) => state.config.isGroupPage,
+ sorting: (state) => state.sorting,
+ filter: (state) => state.filter,
+ }),
+ sortableFields() {
+ return sortableFields(this.isGroupPage).filter((h) => h.orderBy !== LIST_KEY_PACKAGE_TYPE);
+ },
+ },
+ methods: {
+ ...mapActions(['setSorting', 'setFilter']),
+ updateSorting(newValue) {
+ this.setSorting(newValue);
+ this.$emit('update');
+ },
+ },
+};
+</script>
+
+<template>
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ :filter="filter"
+ :sorting="sorting"
+ :tokens="[]"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSorting"
+ @filter:changed="setFilter"
+ @filter:submit="$emit('update')"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
new file mode 100644
index 00000000000..2a479c65d0c
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue
@@ -0,0 +1,53 @@
+<script>
+import { s__, n__ } from '~/locale';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+export default {
+ name: 'InfrastructureTitle',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ helpUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ showModuleCount() {
+ return Number.isInteger(this.count);
+ },
+ moduleAmountText() {
+ return n__(`%d Module`, `%d Modules`, this.count);
+ },
+ infoMessages() {
+ return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
+ },
+ },
+ i18n: {
+ LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'),
+ LIST_INTRO_TEXT: s__(
+ 'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}',
+ ),
+ },
+};
+</script>
+
+<template>
+ <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
+ <template #metadata-amount>
+ <metadata-item
+ v-if="showModuleCount"
+ icon="infrastructure-registry"
+ :text="moduleAmountText"
+ />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
new file mode 100644
index 00000000000..88ee8a4200e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js
@@ -0,0 +1,33 @@
+import Vue from 'vue';
+import { s__ } from '~/locale';
+import PackagesListApp from '~/packages/list/components/packages_list_app.vue';
+import { createStore } from '~/packages/list/stores';
+import Translate from '~/vue_shared/translate';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-vue-packages-list');
+ const store = createStore();
+ store.dispatch('setInitialState', el.dataset);
+
+ return new Vue({
+ el,
+ store,
+ components: {
+ PackagesListApp,
+ },
+ provide: {
+ titleComponent: 'InfrastructureTitle',
+ searchComponent: 'InfrastructureSearch',
+ iconComponent: 'InfrastructureIconAndName',
+ emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'),
+ noResultsText: s__(
+ 'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.',
+ ),
+ },
+ render(createElement) {
+ return createElement('packages-list-app');
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
index d4f51b83e1e..faacabb44ce 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
@@ -2,6 +2,7 @@
import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
import {
+ MAVEN_TOGGLE_LABEL,
MAVEN_TITLE,
MAVEN_SETTINGS_SUBTITLE,
MAVEN_DUPLICATES_ALLOWED_DISABLED,
@@ -15,6 +16,7 @@ import {
export default {
name: 'MavenSettings',
i18n: {
+ MAVEN_TOGGLE_LABEL,
MAVEN_TITLE,
MAVEN_SETTINGS_SUBTITLE,
MAVEN_SETTING_EXCEPTION_TITLE,
@@ -80,6 +82,8 @@ export default {
<div class="gl-display-flex">
<gl-toggle
data-qa-selector="allow_duplicates_toggle"
+ :label="$options.i18n.MAVEN_TOGGLE_LABEL"
+ label-position="hidden"
:value="mavenDuplicatesAllowed"
@change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)"
/>
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 72bec74060c..d52a6a626f9 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -8,6 +8,7 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__(
export const MAVEN_TITLE = s__('PackageRegistry|Maven');
export const MAVEN_SETTINGS_SUBTITLE = s__('PackageRegistry|Settings for Maven packages');
+export const MAVEN_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
export const MAVEN_DUPLICATES_ALLOWED_DISABLED = s__(
'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.',
);
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js
new file mode 100644
index 00000000000..55b5816cc5a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/constants.js
@@ -0,0 +1 @@
+export const FILTERED_SEARCH_TERM = 'filtered-search-term';
diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js
new file mode 100644
index 00000000000..cc5c7ce82bf
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/shared/utils.js
@@ -0,0 +1,29 @@
+import { queryToObject } from '~/lib/utils/url_utility';
+import { FILTERED_SEARCH_TERM } from './constants';
+
+export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true });
+
+export const keyValueToFilterToken = (type, data) => ({ type, value: { data } });
+
+export const searchArrayToFilterTokens = (search) =>
+ search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s));
+
+export const extractFilterAndSorting = (queryObject) => {
+ const { type, search, sort, orderBy } = queryObject;
+ const filters = [];
+ const sorting = {};
+
+ if (type) {
+ filters.push(keyValueToFilterToken('type', type));
+ }
+ if (search) {
+ filters.push(...searchArrayToFilterTokens(search));
+ }
+ if (sort) {
+ sorting.sort = sort;
+ }
+ if (orderBy) {
+ sorting.orderBy = orderBy;
+ }
+ return { filters, sorting };
+};
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index f78d2b0dbd3..3ad9d80b4f2 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -8,20 +8,21 @@ const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
export default {
- init(
+ init({
limit = 0,
preload = false,
disable = false,
prepareData = $.noop,
- callback = $.noop,
+ successCallback = $.noop,
+ errorCallback = $.noop,
container = '',
- ) {
- this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
+ } = {}) {
this.limit = limit;
this.offset = parseInt(getParameterByName('offset'), 10) || this.limit;
this.disable = disable;
this.prepareData = prepareData;
- this.callback = callback;
+ this.successCallback = successCallback;
+ this.errorCallback = errorCallback;
this.loading = $(`${container} .loading`).first();
if (preload) {
this.offset = 0;
@@ -32,8 +33,10 @@ export default {
getOld() {
this.loading.show();
+ const url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
+
axios
- .get(this.url, {
+ .get(url, {
params: {
limit: this.limit,
offset: this.offset,
@@ -41,7 +44,7 @@ export default {
})
.then(({ data }) => {
this.append(data.count, this.prepareData(data.html));
- this.callback();
+ this.successCallback();
// keep loading until we've filled the viewport height
if (!this.disable && !this.isScrollable()) {
@@ -50,7 +53,8 @@ export default {
this.loading.hide();
}
})
- .catch(() => this.loading.hide());
+ .catch((err) => this.errorCallback(err))
+ .finally(() => this.loading.hide());
},
append(count, html) {
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index 0a4311ec73a..a88d35796f7 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,5 +1,8 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import UsersSelect from '~/users_select';
import AbuseReports from './abuse_reports';
new AbuseReports(); /* eslint-disable-line no-new */
new UsersSelect(); /* eslint-disable-line no-new */
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index 2732fc191be..6b7bfbf217d 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -1,16 +1,6 @@
import $ from 'jquery';
import { refreshCurrentPage } from '../../lib/utils/url_utility';
-function showDenylistType() {
- if ($('input[name="denylist_type"]:checked').val() === 'file') {
- $('.js-denylist-file').show();
- $('.js-denylist-raw').hide();
- } else {
- $('.js-denylist-file').hide();
- $('.js-denylist-raw').show();
- }
-}
-
export default function adminInit() {
$('input#user_force_random_password').on('change', function randomPasswordClick() {
const $elems = $('#user_password, #user_password_confirmation');
@@ -27,7 +17,4 @@ export default function adminInit() {
});
$('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage);
-
- $("input[name='denylist_type']").on('click', showDenylistType);
- showDenylistType();
}
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue
new file mode 100644
index 00000000000..2217792d7f3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ helpText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: Boolean,
+ required: true,
+ },
+ dataQaSelector: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input :name="name" type="hidden" :value="value ? '1' : '0'" data-testid="input" />
+
+ <gl-form-checkbox
+ :checked="value"
+ :data-qa-selector="dataQaSelector"
+ @input="$emit('input', $event)"
+ >
+ <span data-testid="label">{{ label }}</span>
+ <template v-if="helpText" #help>
+ <span data-testid="helpText">{{ helpText }}</span>
+ </template>
+ </gl-form-checkbox>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
new file mode 100644
index 00000000000..9850113d4be
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue
@@ -0,0 +1,417 @@
+<script>
+import {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlSprintf,
+ GlLink,
+ GlModal,
+} from '@gitlab/ui';
+import { toSafeInteger } from 'lodash';
+import csrf from '~/lib/utils/csrf';
+import { __, s__, sprintf } from '~/locale';
+import SignupCheckbox from './signup_checkbox.vue';
+
+const DENYLIST_TYPE_RAW = 'raw';
+const DENYLIST_TYPE_FILE = 'file';
+
+export default {
+ csrf,
+ DENYLIST_TYPE_RAW,
+ DENYLIST_TYPE_FILE,
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlSprintf,
+ GlLink,
+ SignupCheckbox,
+ GlModal,
+ },
+ inject: [
+ 'host',
+ 'settingsPath',
+ 'signupEnabled',
+ 'requireAdminApprovalAfterUserSignup',
+ 'sendUserConfirmationEmail',
+ 'minimumPasswordLength',
+ 'minimumPasswordLengthMin',
+ 'minimumPasswordLengthMax',
+ 'minimumPasswordLengthHelpLink',
+ 'domainAllowlistRaw',
+ 'newUserSignupsCap',
+ 'domainDenylistEnabled',
+ 'denylistTypeRawSelected',
+ 'domainDenylistRaw',
+ 'emailRestrictionsEnabled',
+ 'supportedSyntaxLinkUrl',
+ 'emailRestrictions',
+ 'afterSignUpText',
+ ],
+ data() {
+ return {
+ showModal: false,
+ form: {
+ signupEnabled: this.signupEnabled,
+ requireAdminApproval: this.requireAdminApprovalAfterUserSignup,
+ sendConfirmationEmail: this.sendUserConfirmationEmail,
+ minimumPasswordLength: this.minimumPasswordLength,
+ minimumPasswordLengthMin: this.minimumPasswordLengthMin,
+ minimumPasswordLengthMax: this.minimumPasswordLengthMax,
+ minimumPasswordLengthHelpLink: this.minimumPasswordLengthHelpLink,
+ domainAllowlistRaw: this.domainAllowlistRaw,
+ userCap: this.newUserSignupsCap,
+ domainDenylistEnabled: this.domainDenylistEnabled,
+ denylistType: this.denylistTypeRawSelected
+ ? this.$options.DENYLIST_TYPE_RAW
+ : this.$options.DENYLIST_TYPE_FILE,
+ domainDenylistRaw: this.domainDenylistRaw,
+ emailRestrictionsEnabled: this.emailRestrictionsEnabled,
+ supportedSyntaxLinkUrl: this.supportedSyntaxLinkUrl,
+ emailRestrictions: this.emailRestrictions,
+ afterSignUpText: this.afterSignUpText,
+ },
+ };
+ },
+ computed: {
+ isOldUserCapUnlimited() {
+ // User cap is set to unlimited if no value is provided in the field
+ return this.newUserSignupsCap === '';
+ },
+ isNewUserCapUnlimited() {
+ // User cap is set to unlimited if no value is provided in the field
+ return this.form.userCap === '';
+ },
+ hasUserCapChangedFromUnlimitedToLimited() {
+ return this.isOldUserCapUnlimited && !this.isNewUserCapUnlimited;
+ },
+ hasUserCapChangedFromLimitedToUnlimited() {
+ return !this.isOldUserCapUnlimited && this.isNewUserCapUnlimited;
+ },
+ hasUserCapBeenIncreased() {
+ if (this.hasUserCapChangedFromUnlimitedToLimited) {
+ return false;
+ }
+
+ const oldValueAsInteger = toSafeInteger(this.newUserSignupsCap);
+ const newValueAsInteger = toSafeInteger(this.form.userCap);
+
+ return this.hasUserCapChangedFromLimitedToUnlimited || newValueAsInteger > oldValueAsInteger;
+ },
+ canUsersBeAccidentallyApproved() {
+ const hasUserCapBeenToggledOff =
+ this.requireAdminApprovalAfterUserSignup && !this.form.requireAdminApproval;
+
+ return this.hasUserCapBeenIncreased || hasUserCapBeenToggledOff;
+ },
+ signupEnabledHelpText() {
+ const text = sprintf(
+ s__(
+ 'ApplicationSettings|When enabled, any user visiting %{host} will be able to create an account.',
+ ),
+ {
+ host: this.host,
+ },
+ );
+
+ return text;
+ },
+ requireAdminApprovalHelpText() {
+ const text = sprintf(
+ s__(
+ 'ApplicationSettings|When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by an admin before they can sign in. This setting is effective only if sign-ups are enabled.',
+ ),
+ {
+ host: this.host,
+ },
+ );
+
+ return text;
+ },
+ },
+ watch: {
+ showModal(value) {
+ if (value === true) {
+ this.$refs[this.$options.modal.id].show();
+ } else {
+ this.$refs[this.$options.modal.id].hide();
+ }
+ },
+ },
+ methods: {
+ submitButtonHandler() {
+ if (this.canUsersBeAccidentallyApproved) {
+ this.showModal = true;
+
+ return;
+ }
+
+ this.submitForm();
+ },
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ modalHideHandler() {
+ this.showModal = false;
+ },
+ },
+ i18n: {
+ buttonText: s__('ApplicationSettings|Save changes'),
+ signupEnabledLabel: s__('ApplicationSettings|Sign-up enabled'),
+ requireAdminApprovalLabel: s__('ApplicationSettings|Require admin approval for new sign-ups'),
+ sendConfirmationEmailLabel: s__('ApplicationSettings|Send confirmation email on sign-up'),
+ minimumPasswordLengthLabel: s__(
+ 'ApplicationSettings|Minimum password length (number of characters)',
+ ),
+ domainAllowListLabel: s__('ApplicationSettings|Allowed domains for sign-ups'),
+ domainAllowListDescription: s__(
+ 'ApplicationSettings|ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com',
+ ),
+ userCapLabel: s__('ApplicationSettings|User cap'),
+ userCapDescription: s__(
+ 'ApplicationSettings|Once the instance reaches the user cap, any user who is added or requests access will have to be approved by an admin. Leave the field empty for unlimited.',
+ ),
+ domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'),
+ domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign ups'),
+ domainDenyListTypeFileLabel: s__('ApplicationSettings|Upload denylist file'),
+ domainDenyListTypeRawLabel: s__('ApplicationSettings|Enter denylist manually'),
+ domainDenyListFileLabel: s__('ApplicationSettings|Denylist file'),
+ domainDenyListFileDescription: s__(
+ 'ApplicationSettings|Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries.',
+ ),
+ domainDenyListListLabel: s__('ApplicationSettings|Denied domains for sign-ups'),
+ domainDenyListListDescription: s__(
+ 'ApplicationSettings|Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com',
+ ),
+ domainPlaceholder: s__('ApplicationSettings|domain.com'),
+ emailRestrictionsEnabledGroupLabel: s__('ApplicationSettings|Email restrictions'),
+ emailRestrictionsEnabledLabel: s__(
+ 'ApplicationSettings|Enable email restrictions for sign ups',
+ ),
+ emailRestrictionsGroupLabel: s__('ApplicationSettings|Email restrictions for sign-ups'),
+ afterSignUpTextGroupLabel: s__('ApplicationSettings|After sign up text'),
+ afterSignUpTextGroupDescription: s__('ApplicationSettings|Markdown enabled'),
+ },
+ modal: {
+ id: 'signup-settings-modal',
+ actionPrimary: {
+ text: s__('ApplicationSettings|Approve users'),
+ attributes: {
+ variant: 'confirm',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ title: s__('ApplicationSettings|Approve all users in the pending approval status?'),
+ text: s__(
+ 'ApplicationSettings|By making this change, you will automatically approve all users in pending approval status.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <form
+ ref="form"
+ accept-charset="UTF-8"
+ data-testid="form"
+ method="post"
+ :action="settingsPath"
+ enctype="multipart/form-data"
+ >
+ <input type="hidden" name="utf8" value="✓" />
+ <input type="hidden" name="_method" value="patch" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+
+ <section class="gl-mb-8">
+ <signup-checkbox
+ v-model="form.signupEnabled"
+ class="gl-mb-5"
+ name="application_setting[signup_enabled]"
+ :help-text="signupEnabledHelpText"
+ :label="$options.i18n.signupEnabledLabel"
+ data-qa-selector="signup_enabled_checkbox"
+ />
+
+ <signup-checkbox
+ v-model="form.requireAdminApproval"
+ class="gl-mb-5"
+ name="application_setting[require_admin_approval_after_user_signup]"
+ :help-text="requireAdminApprovalHelpText"
+ :label="$options.i18n.requireAdminApprovalLabel"
+ data-qa-selector="require_admin_approval_after_user_signup_checkbox"
+ data-testid="require-admin-approval-checkbox"
+ />
+
+ <signup-checkbox
+ v-model="form.sendConfirmationEmail"
+ class="gl-mb-5"
+ name="application_setting[send_user_confirmation_email]"
+ :label="$options.i18n.sendConfirmationEmailLabel"
+ />
+
+ <gl-form-group
+ :label="$options.i18n.userCapLabel"
+ :description="$options.i18n.userCapDescription"
+ >
+ <gl-form-input
+ v-model="form.userCap"
+ type="text"
+ name="application_setting[new_user_signups_cap]"
+ data-testid="user-cap-input"
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.minimumPasswordLengthLabel">
+ <gl-form-input
+ v-model="form.minimumPasswordLength"
+ :min="form.minimumPasswordLengthMin"
+ :max="form.minimumPasswordLengthMax"
+ type="number"
+ name="application_setting[minimum_password_length]"
+ />
+
+ <gl-sprintf
+ :message="
+ s__(
+ 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="form.minimumPasswordLengthHelpLink" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+
+ <gl-form-group
+ :description="$options.i18n.domainAllowListDescription"
+ :label="$options.i18n.domainAllowListLabel"
+ >
+ <textarea
+ v-model="form.domainAllowlistRaw"
+ :placeholder="$options.i18n.domainPlaceholder"
+ rows="8"
+ class="form-control gl-form-input"
+ name="application_setting[domain_allowlist_raw]"
+ ></textarea>
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.domainDenyListGroupLabel">
+ <signup-checkbox
+ v-model="form.domainDenylistEnabled"
+ name="application_setting[domain_denylist_enabled]"
+ :label="$options.i18n.domainDenyListLabel"
+ />
+ </gl-form-group>
+
+ <gl-form-radio-group v-model="form.denylistType" name="denylist_type" class="gl-mb-5">
+ <gl-form-radio :value="$options.DENYLIST_TYPE_FILE">{{
+ $options.i18n.domainDenyListTypeFileLabel
+ }}</gl-form-radio>
+ <gl-form-radio :value="$options.DENYLIST_TYPE_RAW">{{
+ $options.i18n.domainDenyListTypeRawLabel
+ }}</gl-form-radio>
+ </gl-form-radio-group>
+
+ <gl-form-group
+ v-if="form.denylistType === $options.DENYLIST_TYPE_FILE"
+ :description="$options.i18n.domainDenyListFileDescription"
+ :label="$options.i18n.domainDenyListFileLabel"
+ label-for="domain-denylist-file-input"
+ data-testid="domain-denylist-file-input-group"
+ >
+ <input
+ id="domain-denylist-file-input"
+ class="form-control gl-form-input"
+ type="file"
+ accept=".txt,.conf"
+ name="application_setting[domain_denylist_file]"
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="form.denylistType !== $options.DENYLIST_TYPE_FILE"
+ :description="$options.i18n.domainDenyListListDescription"
+ :label="$options.i18n.domainDenyListListLabel"
+ data-testid="domain-denylist-raw-input-group"
+ >
+ <textarea
+ v-model="form.domainDenylistRaw"
+ :placeholder="$options.i18n.domainPlaceholder"
+ rows="8"
+ class="form-control gl-form-input"
+ name="application_setting[domain_denylist_raw]"
+ ></textarea>
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.emailRestrictionsEnabledGroupLabel">
+ <signup-checkbox
+ v-model="form.emailRestrictionsEnabled"
+ name="application_setting[email_restrictions_enabled]"
+ :label="$options.i18n.emailRestrictionsEnabledLabel"
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="$options.i18n.emailRestrictionsGroupLabel">
+ <textarea
+ v-model="form.emailRestrictions"
+ rows="4"
+ class="form-control gl-form-input"
+ name="application_setting[email_restrictions]"
+ ></textarea>
+
+ <gl-sprintf
+ :message="
+ s__(
+ 'ApplicationSettings|Restricts sign-ups for email addresses that match the given regex. See the %{linkStart}supported syntax%{linkEnd} for more information.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="form.supportedSyntaxLinkUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.i18n.afterSignUpTextGroupLabel"
+ :description="$options.i18n.afterSignUpTextGroupDescription"
+ >
+ <textarea
+ v-model="form.afterSignUpText"
+ rows="4"
+ class="form-control gl-form-input"
+ name="application_setting[after_sign_up_text]"
+ ></textarea>
+ </gl-form-group>
+ </section>
+
+ <gl-button
+ data-qa-selector="save_changes_button"
+ variant="confirm"
+ @click.prevent="submitButtonHandler"
+ >
+ {{ $options.i18n.buttonText }}
+ </gl-button>
+
+ <gl-modal
+ :ref="$options.modal.id"
+ :modal-id="$options.modal.id"
+ :action-cancel="$options.modal.actionCancel"
+ :action-primary="$options.modal.actionPrimary"
+ :title="$options.modal.title"
+ @primary="submitForm"
+ @hide="modalHideHandler"
+ >
+ {{ $options.modal.text }}
+ </gl-modal>
+ </form>
+</template>
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index eda1a9d3599..c48d99da990 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,27 +1,9 @@
-import Vue from 'vue';
-import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
import initUserInternalRegexPlaceholder from '../account_and_limits';
+import initGitpod from '../gitpod';
+import initSignupRestrictions from '../signup_restrictions';
(() => {
initUserInternalRegexPlaceholder();
-
- const el = document.querySelector('#js-gitpod-settings-help-text');
- if (!el) {
- return;
- }
-
- const { message, messageUrl } = el.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- render(createElement) {
- return createElement(IntegrationHelpText, {
- props: {
- message,
- messageUrl,
- },
- });
- },
- });
+ initGitpod();
+ initSignupRestrictions();
})();
diff --git a/app/assets/javascripts/pages/admin/application_settings/gitpod.js b/app/assets/javascripts/pages/admin/application_settings/gitpod.js
new file mode 100644
index 00000000000..74e46617d52
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/gitpod.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
+
+export default function initGitpod() {
+ const el = document.querySelector('#js-gitpod-settings-help-text');
+
+ if (!el) {
+ return false;
+ }
+
+ const { message, messageUrl } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(IntegrationHelpText, {
+ props: {
+ message,
+ messageUrl,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index e3c6b0f6f5b..a6e3a7dc08a 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -4,9 +4,7 @@ import initSearchSettings from '~/search_settings';
import selfMonitor from '~/self_monitor';
import initSettingsPanels from '~/settings_panels';
-if (gon.features?.ciInstanceVariablesUi) {
- initVariableList('js-instance-variables');
-}
+initVariableList('js-instance-variables');
selfMonitor();
// Initialize expandable settings panels
initSettingsPanels();
diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
new file mode 100644
index 00000000000..70b896f6372
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import SignupForm from './general/components/signup_form.vue';
+import { getParsedDataset } from './utils';
+
+export default function initSignupRestrictions(elementSelector = '#js-signup-form') {
+ const el = document.querySelector(elementSelector);
+
+ if (!el) {
+ return false;
+ }
+
+ const parsedDataset = getParsedDataset({
+ dataset: el.dataset,
+ booleanAttributes: [
+ 'signupEnabled',
+ 'requireAdminApprovalAfterUserSignup',
+ 'sendUserConfirmationEmail',
+ 'domainDenylistEnabled',
+ 'denylistTypeRawSelected',
+ 'emailRestrictionsEnabled',
+ ],
+ });
+
+ return new Vue({
+ el,
+ provide: {
+ ...parsedDataset,
+ },
+ render: (createElement) => createElement(SignupForm),
+ });
+}
diff --git a/app/assets/javascripts/pages/admin/application_settings/utils.js b/app/assets/javascripts/pages/admin/application_settings/utils.js
new file mode 100644
index 00000000000..5462a13d523
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/utils.js
@@ -0,0 +1,21 @@
+import { includes } from 'lodash';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+/**
+ * Returns a new dataset that has all the values of keys indicated in
+ * booleanAttributes transformed by the parseBoolean() helper function
+ *
+ * @param {Object}
+ * @returns {Object}
+ */
+export const getParsedDataset = ({ dataset = {}, booleanAttributes = [] } = {}) => {
+ const parsedDataset = {};
+
+ Object.keys(dataset).forEach((key) => {
+ parsedDataset[key] = includes(booleanAttributes, key)
+ ? parseBoolean(dataset[key])
+ : dataset[key];
+ });
+
+ return parsedDataset;
+};
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
index d6cc6a850eb..b7db6443658 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js
@@ -1,3 +1,7 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import initBroadcastMessagesForm from './broadcast_message';
-document.addEventListener('DOMContentLoaded', initBroadcastMessagesForm);
+document.addEventListener('DOMContentLoaded', () => {
+ initBroadcastMessagesForm();
+ initDeprecatedRemoveRowBehavior();
+});
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 94f7cfd55be..1630cfb8253 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -2,9 +2,9 @@ import initFilePickers from '~/file_pickers';
import BindInOut from '../../../../behaviors/bind_in_out';
import Group from '../../../../group';
-document.addEventListener('DOMContentLoaded', () => {
+(() => {
BindInOut.initAll();
initFilePickers();
return new Group();
-});
+})();
diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js
index 5de1d4d6344..f7c25347e75 100644
--- a/app/assets/javascripts/pages/admin/labels/edit/index.js
+++ b/app/assets/javascripts/pages/admin/labels/edit/index.js
@@ -1,3 +1,3 @@
import Labels from '../../../../labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js
new file mode 100644
index 00000000000..e5ab5d43bbf
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/labels/index/index.js
@@ -0,0 +1,3 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js
index 5de1d4d6344..f7c25347e75 100644
--- a/app/assets/javascripts/pages/admin/labels/new/index.js
+++ b/app/assets/javascripts/pages/admin/labels/new/index.js
@@ -1,3 +1,3 @@
import Labels from '../../../../labels';
-document.addEventListener('DOMContentLoaded', () => new Labels());
+new Labels(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index/index.js
index 45ed3ac6bd8..45ed3ac6bd8 100644
--- a/app/assets/javascripts/pages/admin/runners/index.js
+++ b/app/assets/javascripts/pages/admin/runners/index/index.js
diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js
new file mode 100644
index 00000000000..d1853772fda
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/runners/show/index.js
@@ -0,0 +1,3 @@
+import { initRunnerDetail } from '~/runner/runner_details';
+
+initRunnerDetail();
diff --git a/app/assets/javascripts/pages/admin/services/edit/index.js b/app/assets/javascripts/pages/admin/services/edit/index.js
index 3d692ef4dcc..b8080ddff77 100644
--- a/app/assets/javascripts/pages/admin/services/edit/index.js
+++ b/app/assets/javascripts/pages/admin/services/edit/index.js
@@ -1,6 +1,4 @@
import IntegrationSettingsForm from '~/integrations/integration_settings_form';
-document.addEventListener('DOMContentLoaded', () => {
- const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
- integrationSettingsForm.init();
-});
+const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form');
+integrationSettingsForm.init();
diff --git a/app/assets/javascripts/pages/admin/spam_logs/index.js b/app/assets/javascripts/pages/admin/spam_logs/index.js
new file mode 100644
index 00000000000..e5ab5d43bbf
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/spam_logs/index.js
@@ -0,0 +1,3 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index d2b83f980d7..20407334b3f 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -119,8 +119,8 @@ export default {
<gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button>
<gl-button
:disabled="!canSubmit"
- category="primary"
- variant="warning"
+ category="secondary"
+ variant="danger"
@click="onSecondaryAction"
>
{{ secondaryAction }}
diff --git a/app/assets/javascripts/pages/admin/users/new/index.js b/app/assets/javascripts/pages/admin/users/new/index.js
index 7b7d4c169ef..34c10e44f4c 100644
--- a/app/assets/javascripts/pages/admin/users/new/index.js
+++ b/app/assets/javascripts/pages/admin/users/new/index.js
@@ -1,51 +1,3 @@
-import $ from 'jquery';
+import { setupInternalUserRegexHandler } from '~/admin/users/new';
-export default class UserInternalRegexHandler {
- constructor() {
- this.regexPattern = $('[data-user-internal-regex-pattern]').data('user-internal-regex-pattern');
- if (this.regexPattern && this.regexPattern !== '') {
- this.regexOptions = $('[data-user-internal-regex-options]').data(
- 'user-internal-regex-options',
- );
- this.external = $('#user_external');
- this.warningMessage = $('#warning_external_automatically_set');
- this.addListenerToEmailField();
- this.addListenerToUserExternalCheckbox();
- }
- }
-
- addListenerToEmailField() {
- $('#user_email').on('input', (event) => {
- this.setExternalCheckbox(event.currentTarget.value);
- });
- }
-
- addListenerToUserExternalCheckbox() {
- this.external.on('click', () => {
- this.warningMessage.addClass('hidden');
- });
- }
-
- isEmailInternal(email) {
- const regex = new RegExp(this.regexPattern, this.regexOptions);
- return regex.test(email);
- }
-
- setExternalCheckbox(email) {
- const isChecked = this.external.prop('checked');
- if (this.isEmailInternal(email)) {
- if (isChecked) {
- this.external.prop('checked', false);
- this.warningMessage.removeClass('hidden');
- }
- } else if (!isChecked) {
- this.external.prop('checked', true);
- this.warningMessage.addClass('hidden');
- }
- }
-}
-
-document.addEventListener('DOMContentLoaded', () => {
- // eslint-disable-next-line
- new UserInternalRegexHandler();
-});
+setupInternalUserRegexHandler();
diff --git a/app/assets/javascripts/pages/dashboard/activity/index.js b/app/assets/javascripts/pages/dashboard/activity/index.js
index 1b887cad496..8b7c36a0976 100644
--- a/app/assets/javascripts/pages/dashboard/activity/index.js
+++ b/app/assets/javascripts/pages/dashboard/activity/index.js
@@ -1,3 +1,4 @@
import Activities from '~/activities';
-document.addEventListener('DOMContentLoaded', () => new Activities());
+// eslint-disable-next-line no-new
+new Activities();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index d53cd405504..42341436b55 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -1,6 +1,8 @@
/* eslint-disable class-methods-use-this, no-unneeded-ternary */
import $ from 'jquery';
+import { getGroups } from '~/api/groups_api';
+import { getProjects } from '~/api/projects_api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { deprecatedCreateFlash as flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -41,14 +43,37 @@ export default class Todos {
}
initFilters() {
- this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']);
- this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']);
+ this.initAjaxFilterDropdown(getGroups, $('.js-group-search'), 'group_id');
+ this.initAjaxFilterDropdown(getProjects, $('.js-project-search'), 'project_id');
this.initFilterDropdown($('.js-type-search'), 'type');
this.initFilterDropdown($('.js-action-search'), 'action_id');
return new UsersSelect();
}
+ initAjaxFilterDropdown(apiMethod, $dropdown, fieldName) {
+ initDeprecatedJQueryDropdown($dropdown, {
+ fieldName,
+ selectable: true,
+ filterable: true,
+ filterRemote: true,
+ data(search, callback) {
+ return apiMethod(search, {}, (data) => {
+ callback(
+ data.map((d) => ({
+ id: d.id,
+ text: d.full_name || d.name_with_namespace,
+ })),
+ );
+ });
+ },
+ clicked: () => {
+ const $formEl = $dropdown.closest('form.filter-form');
+ $formEl.submit();
+ },
+ });
+ }
+
initFilterDropdown($dropdown, fieldName, searchFields) {
initDeprecatedJQueryDropdown($dropdown, {
fieldName,
@@ -58,12 +83,6 @@ export default class Todos {
data: $dropdown.data('data'),
clicked: () => {
const $formEl = $dropdown.closest('form.filter-form');
- const mutexDropdowns = {
- group_id: 'project_id',
- project_id: 'group_id',
- };
-
- $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove();
$formEl.submit();
},
});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 176d2406751..49b9822795c 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -4,6 +4,7 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers';
import TransferDropdown from '~/groups/transfer_dropdown';
import groupsSelect from '~/groups_select';
+import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
@@ -26,6 +27,7 @@ document.addEventListener('DOMContentLoaded', () => {
projectSelect();
initSearchSettings();
+ initCascadingSettingsLockPopovers();
return new TransferDropdown();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index ab70fa572ba..b0a70055835 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -8,6 +8,7 @@ import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigg
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
+import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import UsersSelect from '~/users_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
@@ -29,6 +30,7 @@ function mountRemoveMemberModal() {
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-group-members-list'), {
+ namespace: MEMBER_TYPES.user,
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
@@ -43,6 +45,7 @@ initMembersApp(document.querySelector('.js-group-members-list'), {
});
initMembersApp(document.querySelector('.js-group-group-links-list'), {
+ namespace: MEMBER_TYPES.group,
tableFields: SHARED_FIELDS.concat('granted'),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
@@ -51,6 +54,7 @@ initMembersApp(document.querySelector('.js-group-group-links-list'), {
requestFormatter: groupLinkRequestFormatter,
});
initMembersApp(document.querySelector('.js-group-invited-members-list'), {
+ namespace: MEMBER_TYPES.invite,
tableFields: SHARED_FIELDS.concat('invited'),
requestFormatter: groupMemberRequestFormatter,
filteredSearchBar: {
@@ -62,6 +66,7 @@ initMembersApp(document.querySelector('.js-group-invited-members-list'), {
},
});
initMembersApp(document.querySelector('.js-group-access-requests-list'), {
+ namespace: MEMBER_TYPES.accessRequest,
tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: groupMemberRequestFormatter,
});
diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js
index 87d522d7654..95c2c7cd7d0 100644
--- a/app/assets/javascripts/pages/groups/labels/index/index.js
+++ b/app/assets/javascripts/pages/groups/labels/index/index.js
@@ -1,3 +1,5 @@
+import initDeleteLabelModal from '~/delete_label_modal';
import initLabels from '~/init_labels';
initLabels();
+initDeleteLabelModal();
diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
index 1d68ccd724d..301e0b4f7a2 100644
--- a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
+++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
@@ -1,7 +1,12 @@
+import { buildApiUrl } from '~/api/api_utils';
import axios from '~/lib/utils/axios_utils';
-const rootUrl = gon.relative_url_root;
+const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists';
-export default function fetchGroupPathAvailability(groupPath) {
- return axios.get(`${rootUrl}/users/${groupPath}/suggests`);
+export default function fetchGroupPathAvailability(groupPath, parentId) {
+ const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath));
+
+ return axios.get(url, {
+ params: { parent_id: parentId },
+ });
}
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
index 89dccea2812..a0ff98645fb 100644
--- a/app/assets/javascripts/pages/groups/new/group_path_validator.js
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -8,6 +8,7 @@ import fetchGroupPathAvailability from './fetch_group_path_availability';
const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline';
const successInputClass = 'gl-field-success-outline';
+const parentIdSelector = 'group_parent_id';
const successMessageSelector = '.validation-success';
const pendingMessageSelector = '.validation-pending';
const unavailableMessageSelector = '.validation-error';
@@ -20,9 +21,10 @@ export default class GroupPathValidator extends InputValidator {
const container = opts.container || '';
const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`);
+ const parentIdElement = document.getElementById(parentIdSelector);
this.debounceValidateInput = debounce((inputDomElement) => {
- GroupPathValidator.validateGroupPathInput(inputDomElement);
+ GroupPathValidator.validateGroupPathInput(inputDomElement, parentIdElement);
}, debounceTimeoutDuration);
validateElements.forEach((element) =>
@@ -37,13 +39,14 @@ export default class GroupPathValidator extends InputValidator {
this.debounceValidateInput(inputDomElement);
}
- static validateGroupPathInput(inputDomElement) {
+ static validateGroupPathInput(inputDomElement, parentIdElement) {
const groupPath = inputDomElement.value;
+ const parentId = parentIdElement.value;
if (inputDomElement.checkValidity() && groupPath.length > 1) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
- fetchGroupPathAvailability(groupPath)
+ fetchGroupPathAvailability(groupPath, parentId)
.then(({ data }) => data)
.then((data) => {
GroupPathValidator.setInputState(inputDomElement, !data.exists);
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 322ad2c79e7..569b5afd676 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -5,10 +5,8 @@ import Group from '~/group';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
import GroupPathValidator from './group_path_validator';
-const parentId = $('#group_parent_id');
-if (!parentId.val()) {
- new GroupPathValidator(); // eslint-disable-line no-new
-}
+new GroupPathValidator(); // eslint-disable-line no-new
+
BindInOut.initAll();
initFilePickers();
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index 0c3fdcf3e75..636eea5d7ac 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -4,7 +4,6 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
// Initialize expandable settings panels
@@ -21,5 +20,3 @@ initSharedRunnersForm();
initVariableList();
initInstallRunner();
-
-initSearchSettings();
diff --git a/app/assets/javascripts/pages/groups/settings/index.js b/app/assets/javascripts/pages/groups/settings/index.js
new file mode 100644
index 00000000000..cb787c60002
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/index.js
@@ -0,0 +1,5 @@
+import initRevokeButton from '~/deploy_tokens/init_revoke_button';
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
+initRevokeButton();
diff --git a/app/assets/javascripts/pages/groups/settings/integrations/index.js b/app/assets/javascripts/pages/groups/settings/integrations/index.js
new file mode 100644
index 00000000000..53068f72d3f
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/integrations/index.js
@@ -0,0 +1,3 @@
+import initIntegrationsList from '~/integrations/index';
+
+initIntegrationsList();
diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
index d13bf026777..3b922622d2c 100644
--- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
+++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
@@ -1,6 +1,3 @@
import bundle from '~/packages_and_registries/settings/group/bundle';
-import initSearchSettings from '~/search_settings';
bundle();
-
-document.addEventListener('DOMContentLoaded', initSearchSettings);
diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
index 2c9867653de..92405f205cb 100644
--- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
@@ -1,10 +1,7 @@
import DueDateSelectors from '~/due_date_select';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
// Initialize expandable settings panels
initSettingsPanels();
new DueDateSelectors(); // eslint-disable-line no-new
-
-initSearchSettings();
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index a24c6ca7754..b5441127797 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -7,81 +7,78 @@ import { __ } from '~/locale';
import EmojiMenu from './emoji_menu';
const defaultStatusEmoji = 'speech_balloon';
+const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
+const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
+const statusEmojiField = document.getElementById('js-status-emoji-field');
+const statusMessageField = document.getElementById('js-status-message-field');
-document.addEventListener('DOMContentLoaded', () => {
- const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
- const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
- const statusEmojiField = document.getElementById('js-status-emoji-field');
- const statusMessageField = document.getElementById('js-status-message-field');
+const toggleNoEmojiPlaceholder = (isVisible) => {
+ const placeholderElement = document.getElementById('js-no-emoji-placeholder');
+ placeholderElement.classList.toggle('hidden', !isVisible);
+};
- const toggleNoEmojiPlaceholder = (isVisible) => {
- const placeholderElement = document.getElementById('js-no-emoji-placeholder');
- placeholderElement.classList.toggle('hidden', !isVisible);
- };
+const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji');
+const removeStatusEmoji = () => {
+ const statusEmoji = findStatusEmoji();
+ if (statusEmoji) {
+ statusEmoji.remove();
+ }
+};
- const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji');
- const removeStatusEmoji = () => {
- const statusEmoji = findStatusEmoji();
- if (statusEmoji) {
- statusEmoji.remove();
- }
- };
+const selectEmojiCallback = (emoji, emojiTag) => {
+ statusEmojiField.value = emoji;
+ toggleNoEmojiPlaceholder(false);
+ removeStatusEmoji();
+ toggleEmojiMenuButton.innerHTML += emojiTag;
+};
- const selectEmojiCallback = (emoji, emojiTag) => {
- statusEmojiField.value = emoji;
- toggleNoEmojiPlaceholder(false);
- removeStatusEmoji();
- toggleEmojiMenuButton.innerHTML += emojiTag;
- };
-
- const clearEmojiButton = document.getElementById('js-clear-user-status-button');
- clearEmojiButton.addEventListener('click', () => {
- statusEmojiField.value = '';
- statusMessageField.value = '';
- removeStatusEmoji();
- toggleNoEmojiPlaceholder(true);
- });
+const clearEmojiButton = document.getElementById('js-clear-user-status-button');
+clearEmojiButton.addEventListener('click', () => {
+ statusEmojiField.value = '';
+ statusMessageField.value = '';
+ removeStatusEmoji();
+ toggleNoEmojiPlaceholder(true);
+});
- const emojiAutocomplete = new GfmAutoComplete();
- emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+const emojiAutocomplete = new GfmAutoComplete();
+emojiAutocomplete.setup($(statusMessageField), { emojis: true });
- const userNameInput = document.getElementById('user_name');
- userNameInput.addEventListener('input', () => {
- const EMOJI_REGEX = emojiRegex();
- if (EMOJI_REGEX.test(userNameInput.value)) {
- // set field to invalid so it gets detected by GlFieldErrors
- userNameInput.setCustomValidity(__('Invalid field'));
- } else {
- userNameInput.setCustomValidity('');
- }
- });
+const userNameInput = document.getElementById('user_name');
+userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity(__('Invalid field'));
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+});
- Emoji.initEmojiMap()
- .then(() => {
- const emojiMenu = new EmojiMenu(
- Emoji,
- toggleEmojiMenuButtonSelector,
- 'js-status-emoji-menu',
- selectEmojiCallback,
- );
- emojiMenu.bindEvents();
+Emoji.initEmojiMap()
+ .then(() => {
+ const emojiMenu = new EmojiMenu(
+ Emoji,
+ toggleEmojiMenuButtonSelector,
+ 'js-status-emoji-menu',
+ selectEmojiCallback,
+ );
+ emojiMenu.bindEvents();
- const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
- statusMessageField.addEventListener('input', () => {
- const hasStatusMessage = statusMessageField.value.trim() !== '';
- const statusEmoji = findStatusEmoji();
- if (hasStatusMessage && statusEmoji) {
- return;
- }
+ const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
+ statusMessageField.addEventListener('input', () => {
+ const hasStatusMessage = statusMessageField.value.trim() !== '';
+ const statusEmoji = findStatusEmoji();
+ if (hasStatusMessage && statusEmoji) {
+ return;
+ }
- if (hasStatusMessage) {
- toggleNoEmojiPlaceholder(false);
- toggleEmojiMenuButton.innerHTML += defaultEmojiTag;
- } else if (statusEmoji.dataset.name === defaultStatusEmoji) {
- toggleNoEmojiPlaceholder(true);
- removeStatusEmoji();
- }
- });
- })
- .catch(() => createFlash(__('Failed to load emoji list.')));
-});
+ if (hasStatusMessage) {
+ toggleNoEmojiPlaceholder(false);
+ toggleEmojiMenuButton.innerHTML += defaultEmojiTag;
+ } else if (statusEmoji.dataset.name === defaultStatusEmoji) {
+ toggleNoEmojiPlaceholder(true);
+ removeStatusEmoji();
+ }
+ });
+ })
+ .catch(() => createFlash(__('Failed to load emoji list.')));
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 10bac6d60c2..fc2702b8c37 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -5,10 +5,29 @@ import GpgBadges from '~/gpg_badges';
import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
-new BlobViewer(); // eslint-disable-line no-new
-initBlob();
+const viewBlobEl = document.querySelector('#js-view-blob-app');
+
+if (viewBlobEl) {
+ const { blobPath } = viewBlobEl.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: viewBlobEl,
+ render(createElement) {
+ return createElement(BlobContentViewer, {
+ props: {
+ path: blobPath,
+ },
+ });
+ },
+ });
+} else {
+ new BlobViewer(); // eslint-disable-line no-new
+ initBlob();
+}
const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
const statusLink = document.querySelector('.commit-actions .ci-status-link');
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index 72861855c5a..27ec746ad02 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -1,7 +1,16 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
+import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import DeleteModal from '~/branches/branches_delete_modal';
import initDiverganceGraph from '~/branches/divergence_graph';
AjaxLoadingSpinner.init();
new DeleteModal(); // eslint-disable-line no-new
-initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
+
+const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
+ '.js-branch-list',
+).dataset;
+
+initDiverganceGraph(divergingCountsEndpoint, defaultBranch);
+BranchSortDropdown();
+initDeprecatedRemoveRowBehavior();
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index 7112b23775d..288d6711682 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -13,6 +13,7 @@ import {
GlFormRadioGroup,
GlFormSelect,
} from '@gitlab/ui';
+import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -145,6 +146,10 @@ export default {
this.fork.visibility = visibility;
}
},
+ // eslint-disable-next-line func-names
+ 'fork.name': function (newVal) {
+ this.fork.slug = kebabCase(newVal);
+ },
},
mounted() {
this.fetchNamespaces();
@@ -213,6 +218,7 @@ export default {
id="fork-url"
v-model="selectedNamespace"
data-testid="fork-url-input"
+ data-qa-selector="fork_namespace_dropdown"
required
>
<template slot="first">
@@ -286,6 +292,7 @@ export default {
category="primary"
variant="confirm"
data-testid="submit-button"
+ data-qa-selector="fork_project_button"
:loading="isSaving"
>
{{ s__('ForkProject|Fork project') }}
diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js
new file mode 100644
index 00000000000..2a120a690ef
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/hooks/index.js
@@ -0,0 +1,3 @@
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 4e35f28ab06..c0da0069a99 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -5,6 +5,7 @@ import IssuableForm from 'ee_else_ce/issuable_form';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
import initSuggestions from '~/issuable_suggestions';
+import initIssuableTypeSelector from '~/issuable_type_selector';
import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
@@ -20,4 +21,5 @@ export default () => {
});
initSuggestions();
+ initIssuableTypeSelector();
};
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 366f8dc61bc..85489ae8687 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -20,7 +20,12 @@ initFilteredSearch({
useDefaultState: true,
});
-new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+if (gon.features?.vueIssuesList) {
+ new IssuableIndex();
+} else {
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+}
+
new ShortcutsNavigation();
new UsersSelect();
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 992bf3c54ff..2b679a83eac 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -3,6 +3,8 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue';
import '~/notes/index';
@@ -34,6 +36,8 @@ export default function initShowIssue() {
initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
+ initInviteMembersModal();
+ initInviteMembersTrigger();
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())
@@ -42,10 +46,18 @@ export default function initShowIssue() {
new ZenMode(); // eslint-disable-line no-new
if (issueType !== IssuableType.TestCase) {
+ const awardEmojiEl = document.getElementById('js-vue-awards-block');
+
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
initIssuableSidebar();
- loadAwardsHandler();
+ if (awardEmojiEl) {
+ import('~/emoji/awards_app')
+ .then((m) => m.default(awardEmojiEl))
+ .catch(() => {});
+ } else {
+ loadAwardsHandler();
+ }
initInviteMemberModal();
initInviteMemberTrigger();
}
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 681d151b77f..75194499a7f 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,17 +1,23 @@
import Vue from 'vue';
+import initJobsTable from '~/jobs/components/table';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
-const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
-remainingTimeElements.forEach(
- (el) =>
- new Vue({
- el,
- render(h) {
- return h(GlCountdown, {
- props: {
- endDateString: el.dateTime,
- },
- });
- },
- }),
-);
+if (gon.features?.jobsTableVue) {
+ initJobsTable();
+} else {
+ const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
+
+ remainingTimeElements.forEach(
+ (el) =>
+ new Vue({
+ el,
+ render(h) {
+ return h(GlCountdown, {
+ props: {
+ endDateString: el.dateTime,
+ },
+ });
+ },
+ }),
+ );
+}
diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js
index d57dbeb1242..6fef057dee0 100644
--- a/app/assets/javascripts/pages/projects/jobs/show/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/show/index.js
@@ -1,3 +1,3 @@
import initJobDetails from '~/jobs';
-document.addEventListener('DOMContentLoaded', initJobDetails);
+initJobDetails();
diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js
index 9f782c07101..94ab0d64de4 100644
--- a/app/assets/javascripts/pages/projects/labels/index/index.js
+++ b/app/assets/javascripts/pages/projects/labels/index/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import initDeleteLabelModal from '~/delete_label_modal';
import initLabels from '~/init_labels';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
@@ -9,6 +10,7 @@ Vue.use(Translate);
const initLabelIndex = () => {
initLabels();
+ initDeleteLabelModal();
const onRequestFinished = ({ labelUrl, successful }) => {
const button = document.querySelector(
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
index 32ca623ca45..ef9e13f7ccf 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
@@ -1,11 +1,17 @@
<script>
-import { GlLink } from '@gitlab/ui';
-import { ACTION_LABELS } from '../constants';
+import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
+import LearnGitlabSectionCard from './learn_gitlab_section_card.vue';
export default {
- components: { GlLink },
+ components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard },
i18n: {
- ACTION_LABELS,
+ title: s__('LearnGitLab|Learn GitLab'),
+ description: s__(
+ 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
+ ),
+ percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
},
props: {
actions: {
@@ -13,15 +19,49 @@ export default {
type: Object,
},
},
+ maxValue: Object.keys(ACTION_LABELS).length,
+ sections: Object.keys(ACTION_SECTIONS),
+ computed: {
+ progressValue() {
+ return Object.values(this.actions).filter((a) => a.completed).length;
+ },
+ progressPercentage() {
+ return Math.round((this.progressValue / this.$options.maxValue) * 100);
+ },
+ },
+ methods: {
+ actionsFor(section) {
+ const actions = Object.fromEntries(
+ Object.entries(this.actions).filter(
+ ([action]) => ACTION_LABELS[action].section === section,
+ ),
+ );
+ return actions;
+ },
+ },
};
</script>
<template>
- <ul>
- <li v-for="(value, action) in actions" :key="action">
- <span v-if="value.completed">{{ $options.i18n.ACTION_LABELS[action].title }}</span>
- <span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
- </span>
- </li>
- </ul>
+ <div>
+ <div class="row">
+ <div class="gl-mb-7 gl-ml-5">
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p>
+ </div>
+ </div>
+ <div class="gl-mb-3">
+ <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage">
+ <gl-sprintf :message="$options.i18n.percentageCompleted">
+ <template #percentage>{{ progressPercentage }}</template>
+ <template #percentSymbol>%</template>
+ </gl-sprintf>
+ </p>
+ <gl-progress-bar :value="progressValue" :max="$options.maxValue" />
+ </div>
+ <div class="row row-cols-1 row-cols-md-3 gl-mt-5">
+ <div v-for="section in $options.sections" :key="section" class="col gl-mb-6">
+ <learn-gitlab-section-card :section="section" :actions="actionsFor(section)" />
+ </div>
+ </div>
+ </div>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
index 230054ff76e..8f92ce95dbf 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
@@ -1,5 +1,6 @@
<script>
import { GlProgressBar, GlSprintf } from '@gitlab/ui';
+import { pick } from 'lodash';
import { s__ } from '~/locale';
import { ACTION_LABELS } from '../constants';
import LearnGitlabInfoCard from './learn_gitlab_info_card.vue';
@@ -42,7 +43,7 @@ export default {
infoProps(action) {
return {
...this.actions[action],
- ...ACTION_LABELS[action],
+ ...pick(ACTION_LABELS[action], ['title', 'actionLabel', 'description', 'trialRequired']),
};
},
progressValue() {
@@ -96,6 +97,9 @@ export default {
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
<div class="col gl-mb-6">
+ <learn-gitlab-info-card v-bind="infoProps('issueCreated')" />
+ </div>
+ <div class="col gl-mb-6">
<learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" />
</div>
</div>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
index 3d2a8eed9d4..6cd3bbc359b 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue
@@ -61,7 +61,7 @@ export default {
<div
class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content"
>
- <img :src="svg" />
+ <img :src="svg" :alt="actionLabel" />
<h6>{{ title }}</h6>
<p class="gl-font-sm gl-text-gray-700">{{ description }}</p>
<gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
new file mode 100644
index 00000000000..db694a66afd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlCard } from '@gitlab/ui';
+import { imagePath } from '~/lib/utils/common_utils';
+import { ACTION_LABELS, ACTION_SECTIONS } from '../constants';
+
+import LearnGitlabSectionLink from './learn_gitlab_section_link.vue';
+
+export default {
+ name: 'LearnGitlabSectionCard',
+ components: { GlCard, LearnGitlabSectionLink },
+ i18n: {
+ ...ACTION_SECTIONS,
+ },
+ props: {
+ section: {
+ required: true,
+ type: String,
+ },
+ actions: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ sortedActions() {
+ return Object.entries(this.actions).sort(
+ (a1, a2) => ACTION_LABELS[a1[0]].position - ACTION_LABELS[a2[0]].position,
+ );
+ },
+ },
+ methods: {
+ svg(section) {
+ return imagePath(`learn_gitlab/section_${section}.svg`);
+ },
+ },
+};
+</script>
+<template>
+ <gl-card class="gl-pt-0 learn-gitlab-section-card">
+ <div class="learn-gitlab-section-card-header">
+ <img :src="svg(section)" />
+ <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2>
+ <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p>
+ </div>
+ <learn-gitlab-section-link
+ v-for="[action, value] in sortedActions"
+ :key="action"
+ :action="action"
+ :value="value"
+ />
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
new file mode 100644
index 00000000000..6f51c7372fd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ACTION_LABELS } from '../constants';
+
+export default {
+ name: 'LearnGitlabSectionLink',
+ components: { GlLink, GlIcon },
+ i18n: {
+ ACTION_LABELS,
+ trialOnly: s__('LearnGitlab|Trial only'),
+ },
+ props: {
+ action: {
+ required: true,
+ type: String,
+ },
+ value: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ trialOnly() {
+ return ACTION_LABELS[this.action].trialRequired;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-mb-4">
+ <span v-if="value.completed" class="gl-text-green-500">
+ <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" />
+ {{ $options.i18n.ACTION_LABELS[action].title }}
+ </span>
+ <span v-else>
+ <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
+ </span>
+ <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only">
+ - {{ $options.i18n.trialOnly }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
index 80f04b0cf44..9e204aa6746 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js
@@ -5,6 +5,8 @@ export const ACTION_LABELS = {
title: s__('LearnGitLab|Create or import a repository'),
actionLabel: s__('LearnGitLab|Create or import a repository'),
description: s__('LearnGitLab|Create or import your first repository into your new project.'),
+ section: 'workspace',
+ position: 1,
},
userAdded: {
title: s__('LearnGitLab|Invite your colleagues'),
@@ -12,16 +14,22 @@ export const ACTION_LABELS = {
description: s__(
'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.',
),
+ section: 'workspace',
+ position: 0,
},
pipelineCreated: {
title: s__('LearnGitLab|Set up CI/CD'),
actionLabel: s__('LearnGitLab|Set-up CI/CD'),
description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'),
+ section: 'workspace',
+ position: 2,
},
trialStarted: {
title: s__('LearnGitLab|Start a free Ultimate trial'),
actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'),
description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'),
+ section: 'workspace',
+ position: 3,
},
codeOwnersEnabled: {
title: s__('LearnGitLab|Add code owners'),
@@ -30,21 +38,59 @@ export const ACTION_LABELS = {
'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.',
),
trialRequired: true,
+ section: 'workspace',
+ position: 4,
},
requiredMrApprovalsEnabled: {
title: s__('LearnGitLab|Add merge request approval'),
actionLabel: s__('LearnGitLab|Enable require merge approvals'),
description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'),
trialRequired: true,
+ section: 'workspace',
+ position: 5,
},
mergeRequestCreated: {
title: s__('LearnGitLab|Submit a merge request'),
actionLabel: s__('LearnGitLab|Submit a merge request (MR)'),
description: s__('LearnGitLab|Review and edit proposed changes to source code.'),
+ section: 'plan',
+ position: 1,
},
securityScanEnabled: {
- title: s__('LearnGitLab|Run a security scan'),
- actionLabel: s__('LearnGitLab|Run a Security scan'),
+ title: s__('LearnGitLab|Run a Security scan using CI/CD'),
+ actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'),
description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'),
+ section: 'deploy',
+ position: 1,
+ },
+ issueCreated: {
+ title: s__('LearnGitLab|Create an issue'),
+ actionLabel: s__('LearnGitLab|Create an issue'),
+ description: s__(
+ 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.',
+ ),
+ section: 'plan',
+ position: 0,
+ },
+};
+
+export const ACTION_SECTIONS = {
+ workspace: {
+ title: s__('LearnGitLab|Set up your workspace'),
+ description: s__(
+ "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:",
+ ),
+ },
+ plan: {
+ title: s__('LearnGitLab|Plan and execute'),
+ description: s__(
+ 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:',
+ ),
+ },
+ deploy: {
+ title: s__('LearnGitLab|Deploy'),
+ description: s__(
+ 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:',
+ ),
},
};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index d4d5e9f2711..a5118e3529a 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -5,21 +5,33 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
+import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
export default function initMergeRequestShow() {
+ const awardEmojiEl = document.getElementById('js-vue-awards-block');
+
new ZenMode(); // eslint-disable-line no-new
initIssuableSidebar();
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
initSourcegraph();
- loadAwardsHandler();
+ if (awardEmojiEl) {
+ import('~/emoji/awards_app')
+ .then((m) => m.default(awardEmojiEl))
+ .catch(() => {});
+ } else {
+ loadAwardsHandler();
+ }
initInviteMemberModal();
initInviteMemberTrigger();
+ initInviteMembersModal();
+ initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
new file mode 100644
index 00000000000..dfb750eca41
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js
@@ -0,0 +1,3 @@
+import initList from '~/packages_and_registries/infrastructure_registry/list_app_bundle';
+
+initList();
diff --git a/app/assets/javascripts/pages/projects/pages/index.js b/app/assets/javascripts/pages/projects/pages/index.js
new file mode 100644
index 00000000000..2a120a690ef
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages/index.js
@@ -0,0 +1,3 @@
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/projects/path_locks/index.js b/app/assets/javascripts/pages/projects/path_locks/index.js
new file mode 100644
index 00000000000..e5ab5d43bbf
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/path_locks/index.js
@@ -0,0 +1,3 @@
+import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+
+document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 3b19231720a..159c619e16c 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -139,7 +139,7 @@ export default {
v-model="cronInterval"
:placeholder="__('Define a custom pattern with cron syntax')"
:name="inputNameAttribute"
- class="form-control inline cron-interval-input"
+ class="form-control inline cron-interval-input gl-form-input"
type="text"
required="true"
@input="onCustomInput"
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index da8dc527d79..91f376060f8 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -123,10 +123,19 @@ export default class Project {
const loc = window.location.href;
if (loc.includes('/-/')) {
- const refs = this.fullData.Branches.concat(this.fullData.Tags);
- const currentRef = refs.find((ref) => loc.indexOf(ref) > -1);
- if (currentRef) {
- const targetPath = loc.split(currentRef)[1].slice(1).split('#')[0];
+ // Since the current ref in renderRow is outdated on page changes
+ // (To be addressed in: https://gitlab.com/gitlab-org/gitlab/-/issues/327085)
+ // We are deciphering the current ref from the dropdown data instead
+ const currentRef = $dropdown.data('ref');
+ // The split and startWith is to ensure an exact word match
+ // and avoid partial match ie. currentRef is "dev" and loc is "development"
+ const splitPathAfterRefPortion = loc.split(currentRef)[1];
+ const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/');
+
+ if (doesPathContainRef) {
+ // We are ignoring the url containing the ref portion
+ // and plucking the thereafter portion to reconstructure the url that is correct
+ const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0];
selectedUrl.searchParams.set('path', targetPath);
selectedUrl.hash = window.location.hash;
}
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 4aea5614bfb..471798d2931 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -7,6 +7,7 @@ import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigg
import { s__ } from '~/locale';
import memberExpirationDate from '~/member_expiration_date';
import { initMembersApp } from '~/members';
+import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
import UsersSelect from '~/users_select';
@@ -42,6 +43,7 @@ new UsersSelect(); // eslint-disable-line no-new
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list'), {
+ namespace: MEMBER_TYPES.user,
tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
@@ -56,6 +58,7 @@ initMembersApp(document.querySelector('.js-project-members-list'), {
});
initMembersApp(document.querySelector('.js-project-group-links-list'), {
+ namespace: MEMBER_TYPES.group,
tableFields: SHARED_FIELDS.concat('granted'),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
@@ -72,11 +75,13 @@ initMembersApp(document.querySelector('.js-project-group-links-list'), {
});
initMembersApp(document.querySelector('.js-project-invited-members-list'), {
+ namespace: MEMBER_TYPES.invite,
tableFields: SHARED_FIELDS.concat('invited'),
requestFormatter: projectMemberRequestFormatter,
});
initMembersApp(document.querySelector('.js-project-access-requests-list'), {
+ namespace: MEMBER_TYPES.accessRequest,
tableFields: SHARED_FIELDS.concat('requested'),
requestFormatter: projectMemberRequestFormatter,
});
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index b7e8d4b03ac..be9259ec3ca 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -6,7 +6,6 @@ import initDeployFreeze from '~/deploy_freeze';
import { initInstallRunner } from '~/pages/shared/mount_runner_instructions';
import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
@@ -43,6 +42,4 @@ document.addEventListener('DOMContentLoaded', () => {
}
initInstallRunner();
-
- initSearchSettings();
});
diff --git a/app/assets/javascripts/pages/projects/settings/index.js b/app/assets/javascripts/pages/projects/settings/index.js
new file mode 100644
index 00000000000..cb787c60002
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/index.js
@@ -0,0 +1,5 @@
+import initRevokeButton from '~/deploy_tokens/init_revoke_button';
+import initSearchSettings from '~/search_settings';
+
+initSearchSettings();
+initRevokeButton();
diff --git a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
index bf9ccdbf9a8..01ad87160c5 100644
--- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
@@ -1,4 +1,7 @@
+import initIntegrationsList from '~/integrations/index';
import PersistentUserCallout from '~/persistent_user_callout';
const callout = document.querySelector('.js-webhooks-moved-alert');
PersistentUserCallout.factory(callout);
+
+initIntegrationsList();
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 4a800ab150d..3a46241e2eb 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -3,7 +3,6 @@ import mountErrorTrackingForm from '~/error_tracking_settings';
import mountGrafanaIntegration from '~/grafana_integration';
import initIncidentsSettings from '~/incidents_settings';
import mountOperationSettings from '~/operation_settings';
-import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels';
initIncidentsSettings();
@@ -14,7 +13,3 @@ if (!IS_EE) {
initSettingsPanels();
}
mountAlertsSettings(document.querySelector('.js-alerts-settings'));
-
-document.addEventListener('DOMContentLoaded', () => {
- initSearchSettings();
-});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index c7bcbb83051..e90954c14c5 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,5 +1,4 @@
import MirrorRepos from '~/mirrors/mirror_repos';
-import initSearchSettings from '~/search_settings';
import initForm from '../form';
document.addEventListener('DOMContentLoaded', () => {
@@ -7,6 +6,4 @@ document.addEventListener('DOMContentLoaded', () => {
const mirrorReposContainer = document.querySelector('.js-mirror-settings');
if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
-
- initSearchSettings();
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
index d62df77ad2c..c110c1d4d62 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -12,6 +12,11 @@ export default {
event: 'change',
},
props: {
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
name: {
type: String,
required: false,
@@ -82,6 +87,8 @@ export default {
class="gl-mr-3"
:value="featureEnabled"
:disabled="disabledInput"
+ :label="label"
+ label-position="hidden"
@change="toggleFeature"
/>
<div class="select-wrapper gl-flex-fill-1">
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 0b58cb4731d..0b7b4c0ded1 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -22,6 +22,21 @@ const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
export default {
i18n: {
...CVE_ID_REQUEST_BUTTON_I18N,
+ analyticsLabel: s__('ProjectSettings|Analytics'),
+ containerRegistryLabel: s__('ProjectSettings|Container registry'),
+ forksLabel: s__('ProjectSettings|Forks'),
+ issuesLabel: s__('ProjectSettings|Issues'),
+ lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'),
+ mergeRequestsLabel: s__('ProjectSettings|Merge requests'),
+ operationsLabel: s__('ProjectSettings|Operations'),
+ packagesLabel: s__('ProjectSettings|Packages'),
+ pagesLabel: s__('ProjectSettings|Pages'),
+ ciCdLabel: s__('CI/CD'),
+ repositoryLabel: s__('ProjectSettings|Repository'),
+ requirementsLabel: s__('ProjectSettings|Requirements'),
+ securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
+ snippetsLabel: s__('ProjectSettings|Snippets'),
+ wikiLabel: s__('ProjectSettings|Wiki'),
},
components: {
@@ -423,11 +438,12 @@ export default {
>
<project-setting-row
ref="issues-settings"
- :label="s__('ProjectSettings|Issues')"
+ :label="$options.i18n.issuesLabel"
:help-text="s__('ProjectSettings|Lightweight issue tracking system.')"
>
<project-feature-setting
v-model="issuesAccessLevel"
+ :label="$options.i18n.issuesLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][issues_access_level]"
/>
@@ -440,6 +456,8 @@ export default {
v-model="cveIdRequestEnabled"
class="gl-my-2"
:disabled="cveIdRequestIsDisabled"
+ :label="$options.i18n.cve_request_toggle_label"
+ label-position="hidden"
name="project[project_setting_attributes][cve_id_request_enabled]"
data-testid="cve_id_request_toggle"
/>
@@ -447,11 +465,12 @@ export default {
</project-setting-row>
<project-setting-row
ref="repository-settings"
- :label="s__('ProjectSettings|Repository')"
+ :label="$options.i18n.repositoryLabel"
:help-text="repositoryHelpText"
>
<project-feature-setting
v-model="repositoryAccessLevel"
+ :label="$options.i18n.repositoryLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][repository_access_level]"
/>
@@ -459,11 +478,12 @@ export default {
<div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
<project-setting-row
ref="merge-request-settings"
- :label="s__('ProjectSettings|Merge requests')"
+ :label="$options.i18n.mergeRequestsLabel"
:help-text="s__('ProjectSettings|Submit changes to be merged upstream.')"
>
<project-feature-setting
v-model="mergeRequestsAccessLevel"
+ :label="$options.i18n.mergeRequestsLabel"
:options="repoFeatureAccessLevelOptions"
:disabled-input="!repositoryEnabled"
name="project[project_feature_attributes][merge_requests_access_level]"
@@ -471,33 +491,22 @@ export default {
</project-setting-row>
<project-setting-row
ref="fork-settings"
- :label="s__('ProjectSettings|Forks')"
+ :label="$options.i18n.forksLabel"
:help-text="s__('ProjectSettings|Users can copy the repository to a new project.')"
>
<project-feature-setting
v-model="forkingAccessLevel"
+ :label="$options.i18n.forksLabel"
:options="featureAccessLevelOptions"
:disabled-input="!repositoryEnabled"
name="project[project_feature_attributes][forking_access_level]"
/>
</project-setting-row>
<project-setting-row
- ref="pipeline-settings"
- :label="s__('ProjectSettings|Pipelines')"
- :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')"
- >
- <project-feature-setting
- v-model="buildsAccessLevel"
- :options="repoFeatureAccessLevelOptions"
- :disabled-input="!repositoryEnabled"
- name="project[project_feature_attributes][builds_access_level]"
- />
- </project-setting-row>
- <project-setting-row
v-if="registryAvailable"
ref="container-registry-settings"
:help-path="registryHelpPath"
- :label="s__('ProjectSettings|Container registry')"
+ :label="$options.i18n.containerRegistryLabel"
:help-text="
s__('ProjectSettings|Every project can have its own space to store its Docker images')
"
@@ -513,6 +522,8 @@ export default {
v-model="containerRegistryEnabled"
class="gl-my-2"
:disabled="!repositoryEnabled"
+ :label="$options.i18n.containerRegistryLabel"
+ label-position="hidden"
name="project[container_registry_enabled]"
/>
</project-setting-row>
@@ -520,7 +531,7 @@ export default {
v-if="lfsAvailable"
ref="git-lfs-settings"
:help-path="lfsHelpPath"
- :label="s__('ProjectSettings|Git Large File Storage (LFS)')"
+ :label="$options.i18n.lfsLabel"
:help-text="
s__('ProjectSettings|Manages large files such as audio, video, and graphics files.')
"
@@ -529,6 +540,8 @@ export default {
v-model="lfsEnabled"
class="gl-my-2"
:disabled="!repositoryEnabled"
+ :label="$options.i18n.lfsLabel"
+ label-position="hidden"
name="project[lfs_enabled]"
/>
<p v-if="!lfsEnabled && lfsObjectsExist">
@@ -553,7 +566,7 @@ export default {
v-if="packagesAvailable"
ref="package-settings"
:help-path="packagesHelpPath"
- :label="s__('ProjectSettings|Packages')"
+ :label="$options.i18n.packagesLabel"
:help-text="
s__('ProjectSettings|Every project can have its own space to store its packages.')
"
@@ -562,17 +575,33 @@ export default {
v-model="packagesEnabled"
class="gl-my-2"
:disabled="!repositoryEnabled"
+ :label="$options.i18n.packagesLabel"
+ label-position="hidden"
name="project[packages_enabled]"
/>
</project-setting-row>
</div>
<project-setting-row
+ ref="pipeline-settings"
+ :label="$options.i18n.ciCdLabel"
+ :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')"
+ >
+ <project-feature-setting
+ v-model="buildsAccessLevel"
+ :label="$options.i18n.ciCdLabel"
+ :options="repoFeatureAccessLevelOptions"
+ :disabled-input="!repositoryEnabled"
+ name="project[project_feature_attributes][builds_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
ref="analytics-settings"
- :label="s__('ProjectSettings|Analytics')"
+ :label="$options.i18n.analyticsLabel"
:help-text="s__('ProjectSettings|View project analytics.')"
>
<project-feature-setting
v-model="analyticsAccessLevel"
+ :label="$options.i18n.analyticsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][analytics_access_level]"
/>
@@ -580,43 +609,47 @@ export default {
<project-setting-row
v-if="requirementsAvailable"
ref="requirements-settings"
- :label="s__('ProjectSettings|Requirements')"
+ :label="$options.i18n.requirementsLabel"
:help-text="s__('ProjectSettings|Requirements management system.')"
>
<project-feature-setting
v-model="requirementsAccessLevel"
+ :label="$options.i18n.requirementsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][requirements_access_level]"
/>
</project-setting-row>
<project-setting-row
- :label="s__('ProjectSettings|Security & Compliance')"
+ :label="$options.i18n.securityAndComplianceLabel"
:help-text="s__('ProjectSettings|Security & Compliance for this project')"
>
<project-feature-setting
v-model="securityAndComplianceAccessLevel"
+ :label="$options.i18n.securityAndComplianceLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][security_and_compliance_access_level]"
/>
</project-setting-row>
<project-setting-row
ref="wiki-settings"
- :label="s__('ProjectSettings|Wiki')"
+ :label="$options.i18n.wikiLabel"
:help-text="s__('ProjectSettings|Pages for project documentation.')"
>
<project-feature-setting
v-model="wikiAccessLevel"
+ :label="$options.i18n.wikiLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][wiki_access_level]"
/>
</project-setting-row>
<project-setting-row
ref="snippet-settings"
- :label="s__('ProjectSettings|Snippets')"
+ :label="$options.i18n.snippetsLabel"
:help-text="s__('ProjectSettings|Share code with others outside the project.')"
>
<project-feature-setting
v-model="snippetsAccessLevel"
+ :label="$options.i18n.snippetsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][snippets_access_level]"
/>
@@ -625,26 +658,28 @@ export default {
v-if="pagesAvailable && pagesAccessControlEnabled"
ref="pages-settings"
:help-path="pagesHelpPath"
- :label="s__('ProjectSettings|Pages')"
+ :label="$options.i18n.pagesLabel"
:help-text="
s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab.')
"
>
<project-feature-setting
v-model="pagesAccessLevel"
+ :label="$options.i18n.pagesLabel"
:options="pagesFeatureAccessLevelOptions"
name="project[project_feature_attributes][pages_access_level]"
/>
</project-setting-row>
<project-setting-row
ref="operations-settings"
- :label="s__('ProjectSettings|Operations')"
+ :label="$options.i18n.operationsLabel"
:help-text="
s__('ProjectSettings|Configure your project resources and monitor their health.')
"
>
<project-feature-setting
v-model="operationsAccessLevel"
+ :label="$options.i18n.operationsLabel"
:options="featureAccessLevelOptions"
name="project[project_feature_attributes][operations_access_level]"
/>
diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js
index 98560c1193b..9e48dd9e463 100644
--- a/app/assets/javascripts/pages/projects/tags/index/index.js
+++ b/app/assets/javascripts/pages/projects/tags/index/index.js
@@ -1,3 +1,4 @@
+import TagSortDropdown from '~/tags';
import { initRemoveTag } from '../remove_tag';
initRemoveTag({
@@ -5,3 +6,4 @@ initRemoveTag({
document.querySelector(`[data-path="${path}"]`).closest('.js-tag-list').remove();
},
});
+TagSortDropdown();
diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
index 51028e585b8..e83c73edfde 100644
--- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js
+++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import InstallRunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
+import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
Vue.use(VueApollo);
@@ -10,7 +10,6 @@ export function initInstallRunner(componentId = 'js-install-runner') {
if (installRunnerEl) {
const defaultClient = createDefaultClient();
- const { projectPath, groupPath } = installRunnerEl.dataset;
const apolloProvider = new VueApollo({
defaultClient,
@@ -20,12 +19,8 @@ export function initInstallRunner(componentId = 'js-install-runner') {
new Vue({
el: installRunnerEl,
apolloProvider,
- provide: {
- projectPath,
- groupPath,
- },
render(createElement) {
- return createElement(InstallRunnerInstructions);
+ return createElement(RunnerInstructions);
},
});
}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
new file mode 100644
index 00000000000..6afc33ec8a5
--- /dev/null
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -0,0 +1,253 @@
+<script>
+import { GlForm, GlIcon, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
+import csrf from '~/lib/utils/csrf';
+import { setUrlFragment } from '~/lib/utils/url_utility';
+import { __, s__, sprintf } from '~/locale';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+
+const MARKDOWN_LINK_TEXT = {
+ markdown: '[Link Title](page-slug)',
+ rdoc: '{Link title}[link:page-slug]',
+ asciidoc: 'link:page-slug[Link title]',
+ org: '[[page-slug]]',
+};
+
+export default {
+ components: {
+ GlForm,
+ GlSprintf,
+ GlIcon,
+ GlLink,
+ GlButton,
+ MarkdownField,
+ },
+ inject: ['formatOptions', 'pageInfo'],
+ data() {
+ return {
+ title: this.pageInfo.title?.trim() || '',
+ format: this.pageInfo.format || 'markdown',
+ content: this.pageInfo.content?.trim() || '',
+ commitMessage: '',
+ };
+ },
+ computed: {
+ csrfToken() {
+ return csrf.token;
+ },
+ formAction() {
+ return this.pageInfo.persisted ? this.pageInfo.path : this.pageInfo.createPath;
+ },
+ helpPath() {
+ return setUrlFragment(
+ this.pageInfo.helpPath,
+ this.pageInfo.persisted ? 'move-a-wiki-page' : 'create-a-new-wiki-page',
+ );
+ },
+ commitMessageI18n() {
+ return this.pageInfo.persisted
+ ? s__('WikiPage|Update %{pageTitle}')
+ : s__('WikiPage|Create %{pageTitle}');
+ },
+ linkExample() {
+ return MARKDOWN_LINK_TEXT[this.format];
+ },
+ submitButtonText() {
+ if (this.pageInfo.persisted) return __('Save changes');
+ return s__('WikiPage|Create page');
+ },
+ cancelFormPath() {
+ if (this.pageInfo.persisted) return this.pageInfo.path;
+ return this.pageInfo.wikiPath;
+ },
+ wikiSpecificMarkdownHelpPath() {
+ return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown');
+ },
+ },
+ mounted() {
+ this.updateCommitMessage();
+ },
+ methods: {
+ handleFormSubmit() {
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ },
+
+ handleContentChange() {
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+ },
+
+ onBeforeUnload() {
+ return '';
+ },
+
+ updateCommitMessage() {
+ if (!this.title) return;
+
+ // Replace hyphens with spaces
+ const newTitle = this.title.replace(/-+/g, ' ');
+
+ const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: newTitle }, false);
+ this.commitMessage = newCommitMessage;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form
+ :action="formAction"
+ method="post"
+ class="wiki-form common-note-form gl-mt-3 js-quick-submit"
+ @submit="handleFormSubmit"
+ >
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
+ <input
+ :v-if="pageInfo.persisted"
+ type="hidden"
+ name="wiki[last_commit_sha]"
+ :value="pageInfo.lastCommitSha"
+ />
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_title">{{ s__('WikiPage|Title') }}</label>
+ </div>
+ <div class="col-sm-10">
+ <input
+ id="wiki_title"
+ v-model.trim="title"
+ name="wiki[title]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_title_textbox"
+ :required="true"
+ :autofocus="!pageInfo.persisted"
+ :placeholder="s__('WikiPage|Page title')"
+ @input="updateCommitMessage"
+ />
+ <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600">
+ <gl-icon class="gl-mr-n1" name="bulb" />
+ {{
+ pageInfo.persisted
+ ? s__(
+ 'WikiPage|Tip: You can move this page by adding the path to the beginning of the title.',
+ )
+ : s__(
+ 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.',
+ )
+ }}
+ <gl-link :href="helpPath" target="_blank" data-testid="wiki-title-help-link"
+ ><gl-icon name="question-o" /> {{ __('More Information.') }}</gl-link
+ >
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_format">{{
+ s__('WikiPage|Format')
+ }}</label>
+ </div>
+ <div class="col-sm-10">
+ <select id="wiki_format" v-model="format" class="form-control" name="wiki[format]">
+ <option v-for="(key, label) of formatOptions" :key="key" :value="key">
+ {{ label }}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_content">{{
+ s__('WikiPage|Content')
+ }}</label>
+ </div>
+ <div class="col-sm-10">
+ <markdown-field
+ :markdown-preview-path="pageInfo.markdownPreviewPath"
+ :can-attach-file="true"
+ :enable-autocomplete="true"
+ :textarea-value="content"
+ :markdown-docs-path="pageInfo.markdownHelpPath"
+ :uploads-path="pageInfo.uploadsPath"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ id="wiki_content"
+ ref="textarea"
+ v-model.trim="content"
+ name="wiki[content]"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="wiki_content_textarea"
+ :autofocus="pageInfo.persisted"
+ :aria-label="s__('WikiPage|Content')"
+ :placeholder="s__('WikiPage|Write your content or drag files here…')"
+ @input="handleContentChange"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <div class="clearfix"></div>
+ <div class="error-alert"></div>
+
+ <div class="form-text gl-text-gray-600">
+ <gl-sprintf
+ :message="
+ s__(
+ 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
+ )
+ "
+ >
+ <template #linkExample
+ ><code>{{ linkExample }}</code></template
+ >
+ <template
+ #link="// eslint-disable-next-line vue/no-template-shadow
+ { content }"
+ ><gl-link
+ :href="wikiSpecificMarkdownHelpPath"
+ target="_blank"
+ data-testid="wiki-markdown-help-link"
+ >{{ content }}</gl-link
+ ></template
+ >
+ </gl-sprintf>
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-sm-2 col-form-label">
+ <label class="control-label-full-width" for="wiki_message">{{
+ s__('WikiPage|Commit message')
+ }}</label>
+ </div>
+ <div class="col-sm-10">
+ <input
+ id="wiki_message"
+ v-model.trim="commitMessage"
+ name="wiki[message]"
+ type="text"
+ class="form-control"
+ data-qa-selector="wiki_message_textbox"
+ :placeholder="s__('WikiPage|Commit message')"
+ />
+ </div>
+ </div>
+ <div class="form-actions">
+ <gl-button
+ category="primary"
+ variant="confirm"
+ type="submit"
+ data-qa-selector="wiki_submit_button"
+ data-testid="wiki-submit-button"
+ :disabled="!content || !title"
+ >{{ submitButtonText }}</gl-button
+ >
+ <gl-button :href="cancelFormPath" class="float-right" data-testid="wiki-cancel-button">{{
+ __('Cancel')
+ }}</gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js
index c382a372260..c04cd0b3fa4 100644
--- a/app/assets/javascripts/pages/shared/wikis/index.js
+++ b/app/assets/javascripts/pages/shared/wikis/index.js
@@ -1,12 +1,14 @@
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';
import GLForm from '../../../gl_form';
import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
import wikiAlert from './components/wiki_alert.vue';
+import wikiForm from './components/wiki_form.vue';
import Wikis from './wikis';
const createModalVueApp = () => {
@@ -61,7 +63,28 @@ const createAlertVueApp = () => {
}
};
+const createWikiFormApp = () => {
+ const el = document.getElementById('js-wiki-form');
+
+ if (el) {
+ const { pageInfo, formatOptions } = el.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ provide: {
+ formatOptions: JSON.parse(formatOptions),
+ pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)),
+ },
+ render(createElement) {
+ return createElement(wikiForm);
+ },
+ });
+ }
+};
+
export default () => {
createModalVueApp();
createAlertVueApp();
+ createWikiFormApp();
};
diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js
index 4b4d2f7d238..7d0b0c90c8d 100644
--- a/app/assets/javascripts/pages/shared/wikis/wikis.js
+++ b/app/assets/javascripts/pages/shared/wikis/wikis.js
@@ -1,15 +1,7 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { s__, sprintf } from '~/locale';
import Tracking from '~/tracking';
import showToast from '~/vue_shared/plugins/global_toast';
-const MARKDOWN_LINK_TEXT = {
- markdown: '[Link Title](page-slug)',
- rdoc: '{Link title}[link:page-slug]',
- asciidoc: 'link:page-slug[Link title]',
- org: '[[page-slug]]',
-};
-
const TRACKING_EVENT_NAME = 'view_wiki_page';
const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1';
@@ -23,78 +15,11 @@ export default class Wikis {
sidebarToggles[i].addEventListener('click', (e) => this.handleToggleSidebar(e));
}
- this.isNewWikiPage = Boolean(document.querySelector('.js-new-wiki-page'));
- this.editTitleInput = document.querySelector('form.wiki-form #wiki_title');
- this.commitMessageInput = document.querySelector('form.wiki-form #wiki_message');
- this.submitButton = document.querySelector('.js-wiki-btn-submit');
- this.commitMessageI18n = this.isNewWikiPage
- ? s__('WikiPageCreate|Create %{pageTitle}')
- : s__('WikiPageEdit|Update %{pageTitle}');
-
- if (this.editTitleInput) {
- // Initialize the commit message on load
- if (this.editTitleInput.value) this.setWikiCommitMessage(this.editTitleInput.value);
-
- // Set the commit message as the page title is changed
- this.editTitleInput.addEventListener('keyup', (e) => this.handleWikiTitleChange(e));
- }
-
window.addEventListener('resize', () => this.renderSidebar());
this.renderSidebar();
- const changeFormatSelect = document.querySelector('#wiki_format');
- const linkExample = document.querySelector('.js-markup-link-example');
-
- if (changeFormatSelect) {
- changeFormatSelect.addEventListener('change', (e) => {
- linkExample.innerHTML = MARKDOWN_LINK_TEXT[e.target.value];
- });
- }
-
- this.wikiTextarea = document.querySelector('form.wiki-form #wiki_content');
- const wikiForm = document.querySelector('form.wiki-form');
-
- if (this.wikiTextarea) {
- this.wikiTextarea.addEventListener('input', () => this.handleWikiContentChange());
-
- wikiForm.addEventListener('submit', () => {
- window.onbeforeunload = null;
- });
- }
-
Wikis.trackPageView();
Wikis.showToasts();
-
- this.updateSubmitButton();
- }
-
- handleWikiContentChange() {
- this.updateSubmitButton();
-
- window.onbeforeunload = () => '';
- }
-
- handleWikiTitleChange(e) {
- this.updateSubmitButton();
- this.setWikiCommitMessage(e.target.value);
- }
-
- updateSubmitButton() {
- if (!this.wikiTextarea) return;
-
- const isEnabled = Boolean(this.wikiTextarea.value.trim() && this.editTitleInput.value.trim());
- if (isEnabled) this.submitButton.removeAttribute('disabled');
- else this.submitButton.setAttribute('disabled', 'true');
- }
-
- setWikiCommitMessage(rawTitle) {
- let title = rawTitle;
-
- // Replace hyphens with spaces
- if (title) title = title.replace(/-+/g, ' ');
-
- const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }, false);
- this.commitMessageInput.value = newCommitMessage;
}
handleToggleSidebar(e) {
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 3ff455fad32..d236dc4610a 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -39,7 +39,7 @@ function formatTooltipText({ date, count }) {
if (count > 0) {
contribText = n__('%d contribution', '%d contributions', count);
}
- return `${contribText}<br />${dateDayName} ${dateText}`;
+ return `${contribText}<br /><span class="gl-text-gray-300">${dateDayName} ${dateText}</span>`;
}
const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]);
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 9bf77239a6b..e5b26a00c4c 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,5 +1,8 @@
<script>
-import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+import { sortOrders, sortOrderOptions } from '../constants';
import RequestWarning from './request_warning.vue';
export default {
@@ -7,6 +10,7 @@ export default {
RequestWarning,
GlButton,
GlModal,
+ GlSegmentedControl,
},
directives: {
'gl-modal': GlModalDirective,
@@ -39,6 +43,7 @@ export default {
data() {
return {
openedBacktraces: [],
+ sortOrder: sortOrders.DURATION,
};
},
computed: {
@@ -48,13 +53,43 @@ export default {
metricDetails() {
return this.currentRequest.details[this.metric];
},
+ metricDetailsSummary() {
+ const summary = {};
+
+ if (!this.metricDetails.summaryOptions?.hideTotal) {
+ summary[s__('Total')] = this.metricDetails.calls;
+ }
+
+ if (!this.metricDetails.summaryOptions?.hideDuration) {
+ summary[s__('PerformanceBar|Total duration')] = this.metricDetails.duration;
+ }
+
+ return { ...summary, ...(this.metricDetails.summary || {}) };
+ },
metricDetailsLabel() {
- return this.metricDetails.duration
- ? `${this.metricDetails.duration} / ${this.metricDetails.calls}`
- : this.metricDetails.calls;
+ if (this.metricDetails.duration && this.metricDetails.calls) {
+ return `${this.metricDetails.duration} / ${this.metricDetails.calls}`;
+ } else if (this.metricDetails.calls) {
+ return this.metricDetails.calls;
+ }
+
+ return '0';
+ },
+ displaySortOrder() {
+ return (
+ this.metricDetails.details.length !== 0 &&
+ this.metricDetails.details.every((item) => item.start)
+ );
},
detailsList() {
- return this.metricDetails.details;
+ return this.metricDetails.details.map((item, index) => ({ ...item, id: index }));
+ },
+ sortedList() {
+ if (this.sortOrder === sortOrders.CHRONOLOGICAL) {
+ return this.detailsList.slice().sort(this.sortDetailChronologically);
+ }
+
+ return this.detailsList.slice().sort(this.sortDetailByDuration);
},
warnings() {
return this.metricDetails.warnings || [];
@@ -82,7 +117,17 @@ export default {
itemHasOpenedBacktrace(toggledIndex) {
return this.openedBacktraces.find((openedIndex) => openedIndex === toggledIndex) >= 0;
},
+ changeSortOrder(order) {
+ this.sortOrder = order;
+ },
+ sortDetailByDuration(a, b) {
+ return a.duration < b.duration ? 1 : -1;
+ },
+ sortDetailChronologically(a, b) {
+ return a.start < b.start ? -1 : 1;
+ },
},
+ sortOrderOptions,
};
</script>
<template>
@@ -93,18 +138,41 @@ export default {
data-qa-selector="detailed_metric_content"
>
<gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link">
- <span class="gl-text-blue-300 gl-font-weight-bold">{{ metricDetailsLabel }}</span>
+ <span
+ class="gl-text-blue-200 gl-font-weight-bold"
+ data-testid="performance-bar-details-label"
+ >
+ {{ metricDetailsLabel }}
+ </span>
</gl-button>
<gl-modal :modal-id="modalId" :title="header" size="lg" footer-class="d-none" scrollable>
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
+ <div class="gl-display-flex gl-align-items-center" data-testid="performance-bar-summary">
+ <div v-for="(value, name) in metricDetailsSummary" :key="name" class="gl-pr-8">
+ <div v-if="value" data-testid="performance-bar-summary-item">
+ <div>{{ name }}</div>
+ <div class="gl-font-size-h1 gl-font-weight-bold">{{ value }}</div>
+ </div>
+ </div>
+ </div>
+ <gl-segmented-control
+ v-if="displaySortOrder"
+ data-testid="performance-bar-sort-order"
+ :options="$options.sortOrderOptions"
+ :checked="sortOrder"
+ @input="changeSortOrder"
+ />
+ </div>
+ <hr />
<table class="table gl-table">
- <template v-if="detailsList.length">
- <tr v-for="(item, index) in detailsList" :key="index">
- <td>
+ <template v-if="sortedList.length">
+ <tr v-for="item in sortedList" :key="item.id">
+ <td data-testid="performance-item-duration">
<span v-if="item.duration">{{
sprintf(__('%{duration}ms'), { duration: item.duration })
}}</span>
</td>
- <td>
+ <td data-testid="performance-item-content">
<div>
<div
v-for="(key, keyIndex) in keys"
@@ -121,12 +189,12 @@ export default {
variant="default"
icon="ellipsis_h"
size="small"
- :selected="itemHasOpenedBacktrace(index)"
+ :selected="itemHasOpenedBacktrace(item.id)"
:aria-label="__('Toggle backtrace')"
- @click="toggleBacktrace(index)"
+ @click="toggleBacktrace(item.id)"
/>
</div>
- <pre v-if="itemHasOpenedBacktrace(index)" class="backtrace-row mt-2">{{
+ <pre v-if="itemHasOpenedBacktrace(item.id)" class="backtrace-row gl-mt-3">{{
item.backtrace
}}</pre>
</div>
@@ -135,7 +203,7 @@ export default {
</template>
<template v-else>
<tr>
- <td>
+ <td data-testid="performance-bar-empty-detail-notice">
{{ sprintf(__('No %{header} for this request.'), { header: header.toLowerCase() }) }}
</td>
</tr>
@@ -146,7 +214,7 @@ export default {
<div></div>
</template>
</gl-modal>
- {{ title }}
+ <span class="gl-text-white">{{ title }}</span>
<request-warning :html-id="htmlId" :warnings="warnings" />
</div>
</template>
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 6b446eb6073..ebe9c4eee2f 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -40,7 +40,7 @@ export default {
metric: 'active-record',
title: 'pg',
header: s__('PerformanceBar|SQL queries'),
- keys: ['sql', 'cached', 'db_role'],
+ keys: ['sql', 'cached', 'transaction', 'db_role'],
},
{
metric: 'bullet',
@@ -69,6 +69,7 @@ export default {
},
{
metric: 'external-http',
+ title: 'external',
header: s__('PerformanceBar|External Http calls'),
keys: ['label', 'code', 'proxy', 'error'],
},
@@ -135,7 +136,7 @@ export default {
<div id="peek-view-host" class="view">
<span
v-if="hasHost"
- class="current-host"
+ class="current-host gl-text-white"
:class="{ canary: currentRequest.details.host.canary }"
>
<span v-html="birdEmoji"></span>
@@ -156,16 +157,18 @@ export default {
id="peek-view-trace"
class="view"
>
- <a class="gl-text-blue-300" :href="currentRequest.details.tracing.tracing_url">{{
- s__('PerformanceBar|trace')
+ <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{
+ s__('PerformanceBar|Trace')
}}</a>
</div>
- <add-request v-on="$listeners" />
<div v-if="currentRequest.details" id="peek-download" class="view">
- <a class="gl-text-blue-300" :download="downloadName" :href="downloadPath">{{
+ <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{
s__('PerformanceBar|Download')
}}</a>
</div>
+ <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{
+ s__('PerformanceBar|Stats')
+ }}</a>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
@@ -173,9 +176,7 @@ export default {
class="ml-auto"
@change-current-request="changeCurrentRequest"
/>
- <div v-if="statsUrl" id="peek-stats" class="view">
- <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a>
- </div>
+ <add-request v-on="$listeners" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 5666e038f02..75fb7bbc5c5 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -58,12 +58,12 @@ export default {
<span v-if="request.hasWarnings">(!)</span>
</option>
</select>
- <span v-if="requestsWithWarnings.length">
+ <span v-if="requestsWithWarnings.length" class="gl-cursor-default">
<span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span>
<gl-popover
+ placement="bottom"
target="performance-bar-request-selector-warning"
:content="warningMessage"
- triggers="hover focus"
/>
</span>
</div>
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
index b61e1e5b7a9..7fe6b088ebb 100644
--- a/app/assets/javascripts/performance_bar/components/request_warning.vue
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -35,8 +35,8 @@ export default {
};
</script>
<template>
- <span v-if="hasWarnings">
+ <span v-if="hasWarnings" class="gl-cursor-default">
<span :id="htmlId" v-html="glEmojiTag('warning')"></span>
- <gl-popover :target="htmlId" :content="warningMessage" triggers="hover focus" />
+ <gl-popover placement="bottom" :target="htmlId" :content="warningMessage" />
</span>
</template>
diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js
new file mode 100644
index 00000000000..9659383edd9
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/constants.js
@@ -0,0 +1,17 @@
+import { s__ } from '~/locale';
+
+export const sortOrders = {
+ DURATION: 'duration',
+ CHRONOLOGICAL: 'chronological',
+};
+
+export const sortOrderOptions = [
+ {
+ value: sortOrders.DURATION,
+ text: s__('PerformanceBar|Sort by duration'),
+ },
+ {
+ value: sortOrders.CHRONOLOGICAL,
+ text: s__('PerformanceBar|Sort chronologically'),
+ },
+];
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 51b6108868f..d8aab25a6a8 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,6 +1,7 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { s__ } from '~/locale';
import Translate from '~/vue_shared/translate';
import initPerformanceBarLog from './performance_bar_log';
@@ -75,40 +76,53 @@ const initPerformanceBar = (el) => {
const resourceEntries = performance.getEntriesByType('resource');
let durationString = '';
+ let summary = {};
if (navigationEntries.length > 0) {
- durationString = `${Math.round(navigationEntries[0].responseEnd)} | `;
- durationString += `${Math.round(paintEntries[1].startTime)} | `;
- durationString += ` ${Math.round(navigationEntries[0].domContentLoadedEventEnd)}`;
+ const backend = Math.round(navigationEntries[0].responseEnd);
+ const firstContentfulPaint = Math.round(paintEntries[1].startTime);
+ const domContentLoaded = Math.round(navigationEntries[0].domContentLoadedEventEnd);
+
+ summary = {
+ [s__('PerformanceBar|Backend')]: backend,
+ [s__('PerformanceBar|First Contentful Paint')]: firstContentfulPaint,
+ [s__('PerformanceBar|DOM Content Loaded')]: domContentLoaded,
+ };
+
+ durationString = `${backend} | ${firstContentfulPaint} | ${domContentLoaded}`;
}
let newEntries = resourceEntries.map(this.transformResourceEntry);
- this.updateFrontendPerformanceMetrics(durationString, newEntries);
+ this.updateFrontendPerformanceMetrics(durationString, summary, newEntries);
if ('PerformanceObserver' in window) {
// We start observing for more incoming timings
const observer = new PerformanceObserver((list) => {
newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry));
- this.updateFrontendPerformanceMetrics(durationString, newEntries);
+ this.updateFrontendPerformanceMetrics(durationString, summary, newEntries);
});
observer.observe({ entryTypes: ['resource'] });
}
}
},
- updateFrontendPerformanceMetrics(durationString, requestEntries) {
+ updateFrontendPerformanceMetrics(durationString, summary, requestEntries) {
this.store.setRequestDetailsData(this.requestId, 'total', {
duration: durationString,
calls: requestEntries.length,
details: requestEntries,
+ summaryOptions: {
+ hideDuration: true,
+ },
+ summary,
});
},
transformResourceEntry(entry) {
- const nf = new Intl.NumberFormat();
return {
+ start: entry.startTime,
name: entry.name.replace(document.location.origin, ''),
duration: Math.round(entry.duration),
- size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached',
+ size: entry.transferSize ? numberToHumanSize(entry.transferSize) : 'cached',
};
},
},
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 9d12d228d35..51a8eb5ca69 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -47,10 +47,15 @@ export default class PerformanceBarStore {
}
canTrackRequest(requestUrl) {
- return (
- requestUrl.endsWith('/api/graphql') ||
- this.requests.filter((request) => request.url === requestUrl).length < 2
- );
+ // We want to store at most 2 unique requests per URL, as additional
+ // requests to the same URL probably aren't very interesting.
+ //
+ // GraphQL requests are the exception: because all GraphQL requests
+ // go to the same URL, we set a higher limit of 10 to allow
+ // capturing different queries a page may make.
+ const requestsLimit = requestUrl.endsWith('/api/graphql') ? 10 : 2;
+
+ return this.requests.filter((request) => request.url === requestUrl).length < requestsLimit;
}
static truncateUrl(requestUrl) {
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index c177fe25985..cadcab16f16 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -7,7 +7,9 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-buy-pipeline-minutes-notification-callout',
'.js-token-expiry-callout',
'.js-registration-enabled-callout',
+ '.js-service-templates-deprecated-callout',
'.js-new-user-signups-cap-reached',
+ '.js-eoa-bronze-plan-banner',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue
new file mode 100644
index 00000000000..7b33d98bca0
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_SETTINGS } from './constants';
+
+export default {
+ name: 'CodeSnippetAlert',
+ components: {
+ GlAlert,
+ },
+ inject: ['configurationPaths'],
+ props: {
+ source: {
+ type: String,
+ required: true,
+ validator: (source) => CODE_SNIPPET_SOURCES.includes(source),
+ },
+ },
+ computed: {
+ settings() {
+ return CODE_SNIPPET_SOURCE_SETTINGS[this.source];
+ },
+ configurationPath() {
+ return this.configurationPaths[this.source];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ variant="tip"
+ :title="__('Code snippet copied. Insert it in the correct location in the YAML file.')"
+ :dismiss-label="__('Dismiss')"
+ :primary-button-link="settings.docsPath"
+ :primary-button-text="__('Read documentation')"
+ :secondary-button-link="configurationPath"
+ :secondary-button-text="__('Go back to configuration')"
+ v-on="$listeners"
+ >
+ {{ __('Before inserting code, be sure to read the comment that separated each code group.') }}
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js
new file mode 100644
index 00000000000..582fdfea6c9
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js
@@ -0,0 +1,11 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from';
+export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing';
+export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING];
+export const CODE_SNIPPET_SOURCE_SETTINGS = {
+ [CODE_SNIPPET_SOURCE_API_FUZZING]: {
+ datasetKey: 'apiFuzzingConfigurationPath',
+ docsPath: helpPagePath('user/application_security/api_fuzzing/index'),
+ },
+};
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index b088678fee8..f6e88738002 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -124,7 +124,7 @@ export default {
type="submit"
class="js-no-auto-disable"
category="primary"
- variant="success"
+ variant="confirm"
:disabled="submitDisabled"
:loading="isSaving"
>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
index f36b22f33c3..455990f2791 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue
@@ -1,22 +1,15 @@
<script>
-import { GlAlert, GlIcon } from '@gitlab/ui';
+import { GlIcon } from '@gitlab/ui';
import { uniqueId } from 'lodash';
-import { __, s__ } from '~/locale';
-import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants';
+import { s__ } from '~/locale';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
export default {
i18n: {
viewOnlyMessage: s__('Pipelines|Merged YAML is view only'),
},
- errorTexts: {
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
- [DEFAULT]: __('An unknown error occurred.'),
- },
components: {
EditorLite,
- GlAlert,
GlIcon,
},
inject: ['ciConfigPath'],
@@ -32,69 +25,30 @@ export default {
};
},
computed: {
- failure() {
- switch (this.failureType) {
- case INVALID_CI_CONFIG:
- return this.$options.errorTexts[INVALID_CI_CONFIG];
- default:
- return this.$options.errorTexts[DEFAULT];
- }
- },
fileGlobalId() {
return `${this.ciConfigPath}-${uniqueId()}`;
},
- hasError() {
- return this.failureType;
- },
- isInvalidConfiguration() {
- return this.ciConfigData.status === CI_CONFIG_STATUS_INVALID;
- },
mergedYaml() {
return this.ciConfigData.mergedYaml;
},
},
- watch: {
- ciConfigData: {
- immediate: true,
- handler() {
- if (this.isInvalidConfiguration) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else if (this.hasError) {
- this.resetFailure();
- }
- },
- },
- },
- methods: {
- reportFailure(errorType) {
- this.failureType = errorType;
- },
- resetFailure() {
- this.failureType = null;
- },
- },
};
</script>
<template>
<div>
- <gl-alert v-if="hasError" variant="danger" :dismissible="false">
- {{ failure }}
- </gl-alert>
- <div v-else>
- <div class="gl-display-flex gl-align-items-center">
- <gl-icon :size="18" name="lock" use-deprecated-sizes class="gl-text-gray-500 gl-mr-3" />
- {{ $options.i18n.viewOnlyMessage }}
- </div>
- <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
- <editor-lite
- ref="editor"
- :value="mergedYaml"
- :file-name="ciConfigPath"
- :file-global-id="fileGlobalId"
- :editor-options="{ readOnly: true }"
- v-on="$listeners"
- />
- </div>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" />
+ {{ $options.i18n.viewOnlyMessage }}
+ </div>
+ <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <editor-lite
+ ref="editor"
+ :value="mergedYaml"
+ :file-name="ciConfigPath"
+ :file-global-id="fileGlobalId"
+ :editor-options="{ readOnly: true }"
+ v-on="$listeners"
+ />
</div>
</div>
</template>
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 872da88d3e6..a3410d7b837 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -27,7 +27,7 @@ export default {
registerCiSchema() {
const editorInstance = this.$refs.editor.getEditor();
- editorInstance.use(new CiSchemaExtension());
+ editorInstance.use(new CiSchemaExtension({ instance: editorInstance }));
editorInstance.registerCiSchema({
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
new file mode 100644
index 00000000000..b3eba0fcc19
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
+import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql';
+import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql';
+
+export default {
+ i18n: {
+ title: s__('Branches'),
+ fetchError: s__('Unable to fetch branch list for this project.'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlIcon,
+ },
+ inject: ['projectFullPath'],
+ apollo: {
+ branches: {
+ query: getAvailableBranches,
+ variables() {
+ return {
+ projectFullPath: this.projectFullPath,
+ };
+ },
+ update(data) {
+ return data.project?.repository?.branches || [];
+ },
+ error() {
+ this.$emit('showError', {
+ type: DEFAULT_FAILURE,
+ reasons: [this.$options.i18n.fetchError],
+ });
+ },
+ },
+ currentBranch: {
+ query: getCurrentBranch,
+ },
+ },
+ computed: {
+ hasBranchList() {
+ return this.branches?.length > 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch">
+ <gl-dropdown-section-header>
+ {{ this.$options.i18n.title }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="branch in branches"
+ :key="branch.name"
+ :is-checked="currentBranch === branch.name"
+ :is-check-item="true"
+ >
+ <gl-icon name="check" class="gl-visibility-hidden" />
+ {{ branch.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
new file mode 100644
index 00000000000..a945fc542a5
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -0,0 +1,21 @@
+<script>
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import BranchSwitcher from './branch_switcher.vue';
+
+export default {
+ components: {
+ BranchSwitcher,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ showBranchSwitcher() {
+ return this.glFeatures.pipelineEditorBranchSwitcher;
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-mb-5">
+ <branch-switcher v-if="showBranchSwitcher" v-on="$listeners" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
index 7a35e31e9ce..fefa784f060 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -31,22 +31,18 @@ export default {
},
mixins: [glFeatureFlagsMixin()],
props: {
- ciFileContent: {
- type: String,
- required: true,
- },
ciConfigData: {
type: Object,
required: true,
},
- isCiConfigDataLoading: {
+ isNewCiConfigFile: {
type: Boolean,
required: true,
},
},
computed: {
showPipelineStatus() {
- return this.glFeatures.pipelineStatusForPipelineEditor;
+ return this.glFeatures.pipelineStatusForPipelineEditor && !this.isNewCiConfigFile;
},
// make sure corners are rounded correctly depending on if
// pipeline status is rendered
@@ -61,11 +57,6 @@ export default {
<template>
<div class="gl-mb-5">
<pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
- <validation-segment
- :class="validationStyling"
- :loading="isCiConfigDataLoading"
- :ci-file-content="ciFileContent"
- :ci-config="ciConfigData"
- />
+ <validation-segment :class="validationStyling" :ci-config="ciConfigData" />
</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 b1ea464be99..4a92e106da1 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -1,9 +1,11 @@
<script>
import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
+import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const POLL_INTERVAL = 10000;
@@ -38,13 +40,11 @@ export default {
};
},
update: (data) => {
- const { id, commitPath = '', shortSha = '', detailedStatus = {} } =
- data.project?.pipeline || {};
+ const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {};
return {
id,
commitPath,
- shortSha,
detailedStatus,
};
},
@@ -61,24 +61,34 @@ export default {
},
computed: {
hasPipelineData() {
- return Boolean(this.$apollo.queries.pipeline?.id);
+ return Boolean(this.pipeline?.id);
},
- isQueryLoading() {
- return this.$apollo.queries.pipeline.loading && !this.hasPipelineData;
+ pipelineId() {
+ return getIdFromGraphQLId(this.pipeline.id);
+ },
+ showLoadingState() {
+ // the query is set to poll regularly, so if there is no pipeline data
+ // (e.g. pipeline is null during fetch when the pipeline hasn't been
+ // triggered yet), we can just show the loading state until the pipeline
+ // details are ready to be fetched
+ return this.$apollo.queries.pipeline.loading || (!this.hasPipelineData && !this.hasError);
+ },
+ shortSha() {
+ return truncateSha(this.commitSha);
},
status() {
return this.pipeline.detailedStatus;
},
- pipelineId() {
- return getIdFromGraphQLId(this.pipeline.id);
- },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL);
},
};
</script>
<template>
<div class="gl-white-space-nowrap gl-max-w-full">
- <template v-if="isQueryLoading">
+ <template v-if="showLoadingState">
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
<span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
</template>
@@ -88,7 +98,7 @@ export default {
</template>
<template v-else>
<a :href="status.detailsPath" class="gl-mr-auto">
- <ci-icon :status="status" :size="18" />
+ <ci-icon :status="status" :size="16" />
</a>
<span class="gl-font-weight-bold">
<gl-sprintf :message="$options.i18n.pipelineInfo">
@@ -110,7 +120,7 @@ export default {
target="_blank"
data-testid="pipeline-commit"
>
- {{ pipeline.shortSha }}
+ {{ shortSha }}
</gl-link>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
index 541ab74b177..d1534655a00 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue
@@ -1,8 +1,13 @@
<script>
import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
+import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import { CI_CONFIG_STATUS_VALID } from '../../constants';
+import {
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
+} from '../../constants';
export const i18n = {
empty: __(
@@ -29,47 +34,51 @@ export default {
},
},
props: {
- ciFileContent: {
- type: String,
- required: true,
- },
ciConfig: {
type: Object,
required: false,
default: () => ({}),
},
- loading: {
- type: Boolean,
- required: false,
- default: false,
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
},
},
computed: {
isEmpty() {
- return !this.ciFileContent;
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
isValid() {
- return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
+ return this.appStatus === EDITOR_APP_STATUS_VALID;
},
icon() {
- if (this.isValid || this.isEmpty) {
- return 'check';
+ switch (this.appStatus) {
+ case EDITOR_APP_STATUS_EMPTY:
+ return 'check';
+ case EDITOR_APP_STATUS_VALID:
+ return 'check';
+ default:
+ return 'warning-solid';
}
- return 'warning-solid';
},
message() {
- if (this.isEmpty) {
- return this.$options.i18n.empty;
- } else if (this.isValid) {
- return this.$options.i18n.valid;
- }
-
- // Only display first error as a reason
const [reason] = this.ciConfig?.errors || [];
- if (reason) {
- return sprintf(this.$options.i18n.invalidWithReason, { reason }, false);
+
+ switch (this.appStatus) {
+ case EDITOR_APP_STATUS_EMPTY:
+ return this.$options.i18n.empty;
+ case EDITOR_APP_STATUS_VALID:
+ return this.$options.i18n.valid;
+ default:
+ // Only display first error as a reason
+ return this.ciConfig?.errors.length > 0
+ ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false)
+ : this.$options.i18n.invalid;
}
- return this.$options.i18n.invalid;
},
},
};
@@ -77,7 +86,7 @@ export default {
<template>
<div>
- <template v-if="loading">
+ <template v-if="isLoading">
<gl-loading-icon inline />
{{ $options.i18n.loading }}
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
index b27ab9a39d3..f1cf5630fbf 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
@@ -1,6 +1,5 @@
<script>
import { flatten } from 'lodash';
-import { CI_CONFIG_STATUS_VALID } from '../../constants';
import CiLintResults from './ci_lint_results.vue';
export default {
@@ -13,15 +12,16 @@ export default {
},
},
props: {
+ isValid: {
+ type: Boolean,
+ required: true,
+ },
ciConfig: {
type: Object,
required: true,
},
},
computed: {
- isValid() {
- return this.ciConfig?.status === CI_CONFIG_STATUS_VALID;
- },
stages() {
return this.ciConfig?.stages || [];
},
@@ -45,9 +45,9 @@ export default {
<template>
<ci-lint-results
- :valid="isValid"
- :jobs="jobs"
:errors="ciConfig.errors"
+ :is-valid="isValid"
+ :jobs="jobs"
:lint-help-page-path="lintHelpPagePath"
/>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
index 5d9697c9427..7f6dce05b6e 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
@@ -42,34 +42,34 @@ export default {
CiLintResultsParam,
},
props: {
- valid: {
- type: Boolean,
- required: true,
- },
- jobs: {
- type: Array,
- required: false,
- default: () => [],
- },
errors: {
type: Array,
required: false,
default: () => [],
},
- warnings: {
- type: Array,
- required: false,
- default: () => [],
- },
dryRun: {
type: Boolean,
required: false,
default: false,
},
+ isValid: {
+ type: Boolean,
+ required: true,
+ },
+ jobs: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
lintHelpPagePath: {
type: String,
required: true,
},
+ warnings: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -78,7 +78,7 @@ export default {
},
computed: {
status() {
- return this.valid ? this.$options.correct : this.$options.incorrect;
+ return this.isValid ? this.$options.correct : this.$options.incorrect;
},
shouldShowTable() {
return this.errors.length === 0;
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index 3bdcf383bee..5acb3355b23 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -1,15 +1,20 @@
<script>
-import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { s__ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
- CI_CONFIG_STATUS_INVALID,
CREATE_TAB,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_ERROR,
+ EDITOR_APP_STATUS_INVALID,
+ EDITOR_APP_STATUS_LOADING,
+ EDITOR_APP_STATUS_VALID,
LINT_TAB,
MERGED_TAB,
VISUALIZE_TAB,
} from '../constants';
+import getAppStatus from '../graphql/queries/client/app_status.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue';
@@ -21,6 +26,17 @@ export default {
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
tabMergedYaml: s__('Pipelines|View merged YAML'),
+ empty: {
+ visualization: s__(
+ 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ lint: s__(
+ 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.',
+ ),
+ merge: s__(
+ 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.',
+ ),
+ },
},
errorTexts: {
loadMergedYaml: s__('Pipelines|Could not load merged YAML content'),
@@ -37,7 +53,6 @@ export default {
EditorTab,
GlAlert,
GlLoadingIcon,
- GlTab,
GlTabs,
PipelineGraph,
TextEditor,
@@ -52,17 +67,28 @@ export default {
type: String,
required: true,
},
- isCiConfigDataLoading: {
- type: Boolean,
- required: false,
- default: false,
+ },
+ apollo: {
+ appStatus: {
+ query: getAppStatus,
},
},
computed: {
- hasMergedYamlLoadError() {
- return (
- !this.ciConfigData?.mergedYaml && this.ciConfigData.status !== CI_CONFIG_STATUS_INVALID
- );
+ hasAppError() {
+ // Not an invalid config and with `mergedYaml` data missing
+ return this.appStatus === EDITOR_APP_STATUS_ERROR;
+ },
+ isEmpty() {
+ return this.appStatus === EDITOR_APP_STATUS_EMPTY;
+ },
+ isInvalid() {
+ return this.appStatus === EDITOR_APP_STATUS_INVALID;
+ },
+ isValid() {
+ return this.appStatus === EDITOR_APP_STATUS_VALID;
+ },
+ isLoading() {
+ return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
},
methods: {
@@ -83,39 +109,48 @@ export default {
>
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
- <gl-tab
+ <editor-tab
v-if="glFeatures.ciConfigVisualizationTab"
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.visualization"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
:title="$options.i18n.tabGraph"
lazy
data-testid="visualization-tab"
@click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)"
>
- <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
<pipeline-graph v-else :pipeline-data="ciConfigData" />
- </gl-tab>
+ </editor-tab>
<editor-tab
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.lint"
+ :is-empty="isEmpty"
:title="$options.i18n.tabLint"
data-testid="lint-tab"
@click="setCurrentTab($options.tabConstants.LINT_TAB)"
>
- <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
- <ci-lint v-else :ci-config="ciConfigData" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
</editor-tab>
- <gl-tab
+ <editor-tab
v-if="glFeatures.ciConfigMergedTab"
class="gl-mb-3"
+ :empty-message="$options.i18n.empty.merge"
+ :keep-component-mounted="false"
+ :is-empty="isEmpty"
+ :is-invalid="isInvalid"
:title="$options.i18n.tabMergedYaml"
lazy
data-testid="merged-tab"
@click="setCurrentTab($options.tabConstants.MERGED_TAB)"
>
- <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" />
- <gl-alert v-else-if="hasMergedYamlLoadError" variant="danger" :dismissible="false">
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false">
{{ $options.errorTexts.loadMergedYaml }}
</gl-alert>
<ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" />
- </gl-tab>
+ </editor-tab>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
index b0acd3ca2ee..7c032441a04 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue
@@ -1,6 +1,6 @@
<script>
-import { GlTab } from '@gitlab/ui';
-
+import { GlAlert, GlTab } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
/**
* Wrapper of <gl-tab> to optionally lazily render this tab's content
* when its shown **without dismounting after its hidden**.
@@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui';
* API is the same as <gl-tab>, for example:
*
* <gl-tabs>
- * <editor-tab title="Tab 1" :lazy="true">
+ * <editor-tab title="Tab 1" lazy>
* lazily mounted content (gets mounted if this is first tab)
* </editor-tab>
- * <editor-tab title="Tab 2" :lazy="true">
+ * <editor-tab title="Tab 2" lazy>
* lazily mounted content
* </editor-tab>
* <editor-tab title="Tab 3">
@@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui';
* so it's contents are not dismounted.
*
* lazy is "false" by default, as in <gl-tab>.
+ *
+ * It is also possible to pass the `isEmpty` and or `isInvalid` to let
+ * the tab component handle that state on its own. For example:
+ *
+ * * <gl-tabs>
+ * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid">
+ * ...
+ * </editor-tab-with-status>
+ * Will be the same as normal, except it will only render the slot component
+ * if the status is not empty and not invalid. In any of these 2 cases, it will render
+ * a generic component and avoid mounting whatever it received in the slot.
+ * </gl-tabs>
*/
export default {
+ i18n: {
+ invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'),
+ },
components: {
+ GlAlert,
GlTab,
// Use a small renderless component to know when the tab content mounts because:
// - gl-tab always gets mounted, even if lazy is `true`. See:
@@ -40,29 +56,63 @@ export default {
},
inheritAttrs: false,
props: {
+ emptyMessage: {
+ type: String,
+ required: false,
+ default: s__(
+ 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.',
+ ),
+ },
+ isEmpty: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ isInvalid: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
lazy: {
type: Boolean,
required: false,
default: false,
},
+ keepComponentMounted: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
isLazy: this.lazy,
};
},
+ computed: {
+ slots() {
+ return Object.keys(this.$slots);
+ },
+ },
methods: {
onContentMounted() {
// When a child is first mounted make the entire tab
- // permanently mounted by setting 'lazy' to false.
- this.isLazy = false;
+ // permanently mounted by setting 'lazy' to false unless
+ // explicitly opted out.
+ if (this.keepComponentMounted) {
+ this.isLazy = false;
+ }
},
},
};
</script>
<template>
<gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners">
- <slot v-for="slot in Object.keys($slots)" :slot="slot" :name="slot"></slot>
- <mount-spy @hook:mounted="onContentMounted" />
+ <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert>
+ <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert>
+ <template v-else>
+ <slot v-for="slot in slots" :name="slot"></slot>
+ <mount-spy @hook:mounted="onContentMounted" />
+ </template>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 353deafe770..8d0ec6c3e2d 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -1,5 +1,14 @@
-export const CI_CONFIG_STATUS_VALID = 'VALID';
+// Values for CI_CONFIG_STATUS_* comes from lint graphQL
export const CI_CONFIG_STATUS_INVALID = 'INVALID';
+export const CI_CONFIG_STATUS_VALID = 'VALID';
+
+// Values for EDITOR_APP_STATUS_* are frontend specifics and
+// represent the global state of the pipeline editor app.
+export const EDITOR_APP_STATUS_EMPTY = 'EMPTY';
+export const EDITOR_APP_STATUS_ERROR = 'ERROR';
+export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID;
+export const EDITOR_APP_STATUS_LOADING = 'LOADING';
+export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID;
export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
new file mode 100644
index 00000000000..f162bb11d47
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
@@ -0,0 +1,9 @@
+query getAvailableBranches($projectFullPath: ID!) {
+ project(fullPath: $projectFullPath) @client {
+ repository {
+ branches {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql
new file mode 100644
index 00000000000..938f36c7d5c
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql
@@ -0,0 +1,3 @@
+query getAppStatus {
+ appStatus @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
index 7cc7f92fb60..d3a7387ad2d 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql
@@ -1,10 +1,9 @@
query getPipeline($fullPath: ID!, $sha: String!) {
- project(fullPath: $fullPath) @client {
+ project(fullPath: $fullPath) {
pipeline(sha: $sha) {
commitPath
id
iid
- shortSha
status
detailedStatus {
detailsPath
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 13f6200693b..caa2a65d424 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -11,25 +11,19 @@ export const resolvers = {
}),
};
},
-
/* eslint-disable @gitlab/require-i18n-strings */
project() {
return {
__typename: 'Project',
- pipeline: {
- __typename: 'Pipeline',
- commitPath: `/-/commit/aabbccdd`,
- id: 'gid://gitlab/Ci::Pipeline/118',
- iid: '28',
- shortSha: 'aabbccdd',
- status: 'SUCCESS',
- detailedStatus: {
- __typename: 'DetailedStatus',
- detailsPath: '/root/sample-ci-project/-/pipelines/118"',
- group: 'success',
- icon: 'status_success',
- text: 'passed',
- },
+ repository: {
+ __typename: 'Repository',
+ branches: [
+ { __typename: 'Branch', name: 'master' },
+ { __typename: 'Branch', name: 'main' },
+ { __typename: 'Branch', name: 'develop' },
+ { __typename: 'Branch', name: 'production' },
+ { __typename: 'Branch', name: 'test' },
+ ],
},
};
},
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index b17ec2d5c25..8a1e26f9bff 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -3,6 +3,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
+import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
+import getCommitSha from './graphql/queries/client/commit_sha.graphql';
+import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
@@ -35,15 +38,30 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
ymlHelpPagePath,
} = el?.dataset;
+ const configurationPaths = Object.fromEntries(
+ Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [
+ source,
+ el.dataset[datasetKey],
+ ]),
+ );
+
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(resolvers, { typeDefs }),
});
+ const { cache } = apolloProvider.clients.defaultClient;
- apolloProvider.clients.defaultClient.cache.writeData({
+ cache.writeQuery({
+ query: getCurrentBranch,
data: {
currentBranch: initialBranchName || defaultBranch,
+ },
+ });
+
+ cache.writeQuery({
+ query: getCommitSha,
+ data: {
commitSha,
},
});
@@ -61,6 +79,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectPath,
projectNamespace,
ymlHelpPagePath,
+ configurationPaths,
},
render(h) {
return h(PipelineEditorApp);
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index c1168979e9f..e0fb38004ec 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,14 +1,29 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
+import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue';
+import {
+ CODE_SNIPPET_SOURCE_URL_PARAM,
+ CODE_SNIPPET_SOURCES,
+} from './components/code_snippet_alert/constants';
import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue';
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
-import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants';
+import {
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+ DEFAULT_FAILURE,
+ EDITOR_APP_STATUS_EMPTY,
+ EDITOR_APP_STATUS_ERROR,
+ EDITOR_APP_STATUS_LOADING,
+ LOAD_FAILURE_UNKNOWN,
+} from './constants';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
+import getAppStatus from './graphql/queries/client/app_status.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import PipelineEditorHome from './pipeline_editor_home.vue';
@@ -20,6 +35,7 @@ export default {
GlLoadingIcon,
PipelineEditorEmptyState,
PipelineEditorHome,
+ CodeSnippetAlert,
},
inject: {
ciConfigPath: {
@@ -32,7 +48,6 @@ export default {
data() {
return {
ciConfigData: {},
- // Success and failure state
failureType: null,
failureReasons: [],
showStartScreen: false,
@@ -43,8 +58,10 @@ export default {
showFailureAlert: false,
showSuccessAlert: false,
successType: null,
+ codeSnippetCopiedFrom: '',
};
},
+
apollo: {
initialCiFileContent: {
query: getBlobContent,
@@ -77,8 +94,7 @@ export default {
},
ciConfigData: {
query: getCiConfigData,
- // If content is not loaded, we can't lint the data
- skip: ({ currentCiFileContent }) => {
+ skip({ currentCiFileContent }) {
return !currentCiFileContent;
},
variables() {
@@ -94,9 +110,20 @@ export default {
return { ...ciConfig, stages };
},
+ result({ data }) {
+ this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR);
+ },
error() {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
},
+ watchLoading(isLoading) {
+ if (isLoading) {
+ this.setAppStatus(EDITOR_APP_STATUS_LOADING);
+ }
+ },
+ },
+ appStatus: {
+ query: getAppStatus,
},
currentBranch: {
query: getCurrentBranch,
@@ -115,6 +142,9 @@ export default {
isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading;
},
+ isEmpty() {
+ return this.currentCiFileContent === '';
+ },
failure() {
switch (this.failureType) {
case LOAD_FAILURE_UNKNOWN:
@@ -159,6 +189,16 @@ export default {
successTexts: {
[COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
},
+ watch: {
+ isEmpty(flag) {
+ if (flag) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
+ }
+ },
+ },
+ created() {
+ this.parseCodeSnippetSourceParam();
+ },
methods: {
handleBlobContentError(error = {}) {
const { networkError } = error;
@@ -170,6 +210,7 @@ export default {
response?.status === httpStatusCodes.NOT_FOUND ||
response?.status === httpStatusCodes.BAD_REQUEST
) {
+ this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
this.showStartScreen = true;
} else {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
@@ -183,6 +224,8 @@ export default {
this.showSuccessAlert = false;
},
reportFailure(type, reasons = []) {
+ this.setAppStatus(EDITOR_APP_STATUS_ERROR);
+
window.scrollTo({ top: 0, behavior: 'smooth' });
this.showFailureAlert = true;
this.failureType = type;
@@ -196,6 +239,9 @@ export default {
resetContent() {
this.currentCiFileContent = this.lastCommittedContent;
},
+ setAppStatus(appStatus) {
+ this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } });
+ },
setNewEmptyCiConfigFile() {
this.$apollo
.getClient()
@@ -220,6 +266,20 @@ export default {
// if the user has made changes to the file that are unsaved.
this.lastCommittedContent = this.currentCiFileContent;
},
+ parseCodeSnippetSourceParam() {
+ const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM);
+ if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) {
+ this.codeSnippetCopiedFrom = codeSnippetCopiedFrom;
+ window.history.replaceState(
+ {},
+ document.title,
+ removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]),
+ );
+ }
+ },
+ dismissCodeSnippetAlert() {
+ this.codeSnippetCopiedFrom = '';
+ },
},
};
</script>
@@ -232,19 +292,35 @@ export default {
@createEmptyConfigFile="setNewEmptyCiConfigFile"
/>
<div v-else>
- <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess">
+ <code-snippet-alert
+ v-if="codeSnippetCopiedFrom"
+ :source="codeSnippetCopiedFrom"
+ class="gl-mb-5"
+ @dismiss="dismissCodeSnippetAlert"
+ />
+ <gl-alert
+ v-if="showSuccessAlert"
+ :variant="success.variant"
+ class="gl-mb-5"
+ @dismiss="dismissSuccess"
+ >
{{ success.text }}
</gl-alert>
- <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure">
+ <gl-alert
+ v-if="showFailureAlert"
+ :variant="failure.variant"
+ class="gl-mb-5"
+ @dismiss="dismissFailure"
+ >
{{ failure.text }}
<ul v-if="failureReasons.length" class="gl-mb-0">
<li v-for="reason in failureReasons" :key="reason">{{ reason }}</li>
</ul>
</gl-alert>
<pipeline-editor-home
- :is-ci-config-data-loading="isCiConfigDataLoading"
:ci-config-data="ciConfigData"
:ci-file-content="currentCiFileContent"
+ :is-new-ci-config-file="isNewCiConfigFile"
@commit="updateOnCommit"
@resetContent="resetContent"
@showError="showErrorAlert"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index ef46040153f..adba55f9f4b 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,5 +1,6 @@
<script>
import CommitSection from './components/commit/commit_section.vue';
+import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
@@ -7,6 +8,7 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
export default {
components: {
CommitSection,
+ PipelineEditorFileNav,
PipelineEditorHeader,
PipelineEditorTabs,
},
@@ -19,7 +21,7 @@ export default {
type: String,
required: true,
},
- isCiConfigDataLoading: {
+ isNewCiConfigFile: {
type: Boolean,
required: true,
},
@@ -44,15 +46,14 @@ export default {
<template>
<div>
+ <pipeline-editor-file-nav v-on="$listeners" />
<pipeline-editor-header
- :ci-file-content="ciFileContent"
:ci-config-data="ciConfigData"
- :is-ci-config-data-loading="isCiConfigDataLoading"
+ :is-new-ci-config-file="isNewCiConfigFile"
/>
<pipeline-editor-tabs
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
- :is-ci-config-data-loading="isCiConfigDataLoading"
v-on="$listeners"
@set-current-tab="setCurrentTab"
/>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index ff6a354f673..e44d80ee9d1 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -33,6 +33,7 @@ const i18n = {
submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
warningTitle: __('The form contains the following warning:'),
maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+ removeVariableLabel: s__('CiVariables|Remove variable'),
};
export default {
@@ -416,15 +417,17 @@ export default {
data-testid="remove-ci-variable-row"
variant="danger"
category="secondary"
+ :aria-label="$options.i18n.removeVariableLabel"
@click="removeVariable(index)"
>
<gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" />
- <span class="gl-md-display-none">{{ s__('CiVariables|Remove variable') }}</span>
+ <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span>
</gl-button>
<gl-button
v-else
class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden"
icon="clear"
+ :aria-label="$options.i18n.removeVariableLabel"
/>
</template>
</div>
@@ -441,18 +444,16 @@ export default {
</gl-sprintf></template
>
</gl-form-group>
- <div
- class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between"
- >
+ <div class="gl-pt-5 gl-display-flex">
<gl-button
type="submit"
category="primary"
- variant="success"
- class="js-no-auto-disable"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-3"
data-qa-selector="run_pipeline_button"
data-testid="run_pipeline_button"
:disabled="submitted"
- >{{ s__('Pipeline|Run Pipeline') }}</gl-button
+ >{{ s__('Pipeline|Run pipeline') }}</gl-button
>
<gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
</div>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index e44dedfe2ee..16fb931ec2b 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -50,6 +50,10 @@ export default {
};
},
update(data) {
+ if (!data?.project?.pipeline) {
+ return this.graphData;
+ }
+
const {
stages: { nodes: stages },
} = data.project.pipeline;
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index caa269f5095..dd9cdae518f 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -10,3 +10,12 @@ export const ONE_COL_WIDTH = 180;
export const REST = 'rest';
export const GRAPHQL = 'graphql';
+
+export const STAGE_VIEW = 'stage';
+export const LAYER_VIEW = 'layer';
+export const VIEW_TYPE_KEY = 'pipeline_graph_view_type';
+
+export const SINGLE_JOB = 'single_job';
+export const JOB_DROPDOWN = 'job_dropdown';
+
+export const IID_FAILURE = 'missing_iid';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 363226a0d85..63048777724 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,10 +1,11 @@
<script>
+import { reportToSentry } from '../../utils';
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
-import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants';
+import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
-import { reportToSentry, validateConfigPaths } from './utils';
+import { validateConfigPaths } from './utils';
export default {
name: 'PipelineGraph',
@@ -24,11 +25,20 @@ export default {
type: Object,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
+ pipelineLayers: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
type: {
type: String,
required: false,
@@ -44,6 +54,7 @@ export default {
data() {
return {
hoveredJobName: '',
+ hoveredSourceJobName: '',
highlightedJobs: [],
measurements: {
width: 0,
@@ -62,8 +73,8 @@ export default {
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
- graph() {
- return this.pipeline.stages;
+ layout() {
+ return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList();
},
hasDownstreamPipelines() {
return Boolean(this.pipeline?.downstream?.length > 0);
@@ -71,12 +82,21 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
+ isStageView() {
+ return this.viewType === STAGE_VIEW;
+ },
metricsConfig() {
return {
path: this.configPaths.metricsPath,
collectMetrics: true,
};
},
+ shouldHideLinks() {
+ return this.isStageView;
+ },
+ shouldShowStageName() {
+ return !this.isStageView;
+ },
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
@@ -100,6 +120,26 @@ export default {
this.getMeasurements();
},
methods: {
+ generateColumnsFromLayersList() {
+ return this.pipelineLayers.map((layers, idx) => {
+ /*
+ look up the groups in each layer,
+ then add each set of layer groups to a stage-like object
+ */
+
+ const groups = layers.map((id) => {
+ const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id];
+ return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx];
+ });
+
+ return {
+ name: '',
+ id: `layer-${idx}`,
+ status: { action: null },
+ groups: groups.filter(Boolean),
+ };
+ });
+ },
getMeasurements() {
this.measurements = {
width: this.$refs[this.containerId].scrollWidth,
@@ -112,6 +152,9 @@ export default {
setJob(jobName) {
this.hoveredJobName = jobName;
},
+ setSourceJob(jobName) {
+ this.hoveredSourceJobName = jobName;
+ },
slidePipelineContainer() {
this.$refs.mainPipelineContainer.scrollBy({
left: ONE_COL_WIDTH,
@@ -146,31 +189,35 @@ export default {
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
+ :view-type="viewType"
@error="onError"
/>
</template>
<template #main>
<div :id="containerId" :ref="containerId">
<links-layer
- :pipeline-data="graph"
+ :pipeline-data="layout"
:pipeline-id="pipeline.id"
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
:metrics-config="metricsConfig"
- :never-show-links="true"
+ :never-show-links="shouldHideLinks"
+ :view-type="viewType"
default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
>
<stage-column-component
- v-for="stage in graph"
- :key="stage.name"
- :title="stage.name"
- :groups="stage.groups"
- :action="stage.status.action"
+ v-for="column in layout"
+ :key="column.id || column.name"
+ :name="column.name"
+ :groups="column.groups"
+ :action="column.status.action"
:highlighted-jobs="highlightedJobs"
+ :show-stage-name="shouldShowStageName"
:job-hovered="hoveredJobName"
+ :source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
@@ -188,7 +235,8 @@ export default {
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
- @downstreamHovered="setJob"
+ :view-type="viewType"
+ @downstreamHovered="setSourceJob"
@pipelineExpandToggle="togglePipelineExpanded"
@scrollContainer="slidePipelineContainer"
@error="onError"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
index abbf8df6eed..39d0fa8a8ca 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
@@ -2,10 +2,10 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { escape, capitalize } from 'lodash';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
+import { reportToSentry } from '../../utils';
import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue';
import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
-import { reportToSentry } from './utils';
export default {
name: 'PipelineGraphLegacy',
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 962f2ca2a4c..0bc6d883245 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -2,11 +2,16 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { __ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
+import { reportToSentry } from '../../utils';
+import { listByLayers } from '../parsing_utils';
+import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants';
import PipelineGraph from './graph_component.vue';
+import GraphViewSelector from './graph_view_selector.vue';
import {
getQueryHeaders,
- reportToSentry,
serializeLoadErrors,
toggleQueryPollingByVisibility,
unwrapPipelineData,
@@ -17,8 +22,11 @@ export default {
components: {
GlAlert,
GlLoadingIcon,
+ GraphViewSelector,
+ LocalStorageSync,
PipelineGraph,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
graphqlResourceEtag: {
default: '',
@@ -35,13 +43,18 @@ export default {
},
data() {
return {
- pipeline: null,
alertType: null,
+ currentViewType: STAGE_VIEW,
+ pipeline: null,
+ pipelineLayers: null,
showAlert: false,
};
},
errorTexts: {
[DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'),
+ [IID_FAILURE]: __(
+ 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.',
+ ),
[LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
@@ -58,6 +71,9 @@ export default {
iid: this.pipelineIid,
};
},
+ skip() {
+ return !(this.pipelineProjectPath && this.pipelineIid);
+ },
update(data) {
/*
This check prevents the pipeline from being overwritten
@@ -98,6 +114,11 @@ export default {
text: this.$options.errorTexts[DRAW_FAILURE],
variant: 'danger',
};
+ case IID_FAILURE:
+ return {
+ text: this.$options.errorTexts[IID_FAILURE],
+ variant: 'info',
+ };
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
@@ -123,14 +144,28 @@ export default {
*/
return this.$apollo.queries.pipeline.loading && !this.pipeline;
},
+ showGraphViewSelector() {
+ return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds);
+ },
},
mounted() {
+ if (!this.pipelineIid) {
+ this.reportFailure({ type: IID_FAILURE, skipSentry: true });
+ }
+
toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
methods: {
+ getPipelineLayers() {
+ if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) {
+ this.pipelineLayers = listByLayers(this.pipeline);
+ }
+
+ return this.pipelineLayers;
+ },
hideAlert() {
this.showAlert = false;
this.alertType = null;
@@ -147,7 +182,11 @@ export default {
}
},
/* eslint-enable @gitlab/require-i18n-strings */
+ updateViewType(type) {
+ this.currentViewType = type;
+ },
},
+ viewTypeKey: VIEW_TYPE_KEY,
};
</script>
<template>
@@ -155,11 +194,24 @@ export default {
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
{{ alert.text }}
</gl-alert>
+ <local-storage-sync
+ :storage-key="$options.viewTypeKey"
+ :value="currentViewType"
+ @input="updateViewType"
+ >
+ <graph-view-selector
+ v-if="showGraphViewSelector"
+ :type="currentViewType"
+ @updateViewType="updateViewType"
+ />
+ </local-storage-sync>
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph
v-if="pipeline"
:config-paths="configPaths"
:pipeline="pipeline"
+ :pipeline-layers="getPipelineLayers()"
+ :view-type="currentViewType"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
new file mode 100644
index 00000000000..f33e6290e37
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { STAGE_VIEW, LAYER_VIEW } from './constants';
+
+export default {
+ name: 'GraphViewSelector',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentViewType: STAGE_VIEW,
+ };
+ },
+ i18n: {
+ labelText: __('Order jobs by'),
+ },
+ views: {
+ [STAGE_VIEW]: {
+ type: STAGE_VIEW,
+ text: {
+ primary: __('Stage'),
+ secondary: __('View the jobs grouped into stages'),
+ },
+ },
+ [LAYER_VIEW]: {
+ type: LAYER_VIEW,
+ text: {
+ primary: __('%{codeStart}needs:%{codeEnd} relationships'),
+ secondary: __('View what jobs are needed for a job to run'),
+ },
+ },
+ },
+ computed: {
+ currentDropdownText() {
+ return this.$options.views[this.type].text.primary;
+ },
+ },
+ methods: {
+ itemClick(type) {
+ this.$emit('updateViewType', type);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-my-4">
+ <span>{{ $options.i18n.labelText }}</span>
+ <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4">
+ <template #button-content>
+ <gl-sprintf :message="currentDropdownText">
+ <template #code="{ content }">
+ <code> {{ content }} </code>
+ </template>
+ </gl-sprintf>
+ <gl-icon class="gl-px-2" name="angle-down" :size="16" />
+ </template>
+ <gl-dropdown-item
+ v-for="view in $options.views"
+ :key="view.type"
+ :secondary-text="view.text.secondary"
+ @click="itemClick(view.type)"
+ >
+ <b>
+ <gl-sprintf :message="view.text.primary">
+ <template #code="{ content }">
+ <code> {{ content }} </code>
+ </template>
+ </gl-sprintf>
+ </b>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index f6aee8c5fcf..6451605a222 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -1,8 +1,7 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
+import { JOB_DROPDOWN, SINGLE_JOB } from './constants';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
/**
* Renders the dropdown for the pipeline graph.
@@ -11,12 +10,8 @@ import { reportToSentry } from './utils';
*
*/
export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
JobItem,
- CiIcon,
},
props: {
group: {
@@ -28,6 +23,15 @@ export default {
required: false,
default: -1,
},
+ stageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ jobItemTypes: {
+ jobDropdown: JOB_DROPDOWN,
+ singleJob: SINGLE_JOB,
},
computed: {
computedJobId() {
@@ -51,22 +55,20 @@ export default {
<template>
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button
- v-gl-tooltip.hover="{ boundary: 'viewport' }"
- :title="tooltipText"
type="button"
data-toggle="dropdown"
data-display="static"
- class="dropdown-menu-toggle build-content gl-build-content"
+ class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!"
>
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
- <span class="gl-display-flex gl-align-items-center gl-min-w-0">
- <ci-icon :status="group.status" :size="24" class="gl-line-height-0" />
- <span class="gl-text-truncate mw-70p gl-pl-3">
- {{ group.name }}
- </span>
- </span>
+ <job-item
+ :type="$options.jobItemTypes.jobDropdown"
+ :group-tooltip="tooltipText"
+ :job="group"
+ :stage-name="stageName"
+ />
- <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span>
+ <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
</div>
</button>
@@ -77,6 +79,7 @@ export default {
<job-item
:dropdown-length="group.size"
:job="job"
+ :type="$options.jobItemTypes.singleJob"
css-class-job-name="mini-pipeline-graph-dropdown-item"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 46ef0457d40..6584d89d87c 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -3,11 +3,12 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { sprintf } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
import { accessValue } from './accessors';
-import ActionComponent from './action_component.vue';
-import { REST } from './constants';
-import JobNameComponent from './job_name_component.vue';
-import { reportToSentry } from './utils';
+import { REST, SINGLE_JOB } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -38,6 +39,7 @@ export default {
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: {
ActionComponent,
+ CiIcon,
JobNameComponent,
GlLink,
},
@@ -65,6 +67,11 @@ export default {
required: false,
default: Infinity,
},
+ groupTooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
jobHovered: {
type: String,
required: false,
@@ -80,24 +87,55 @@ export default {
required: false,
default: -1,
},
+ sourceJobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ type: {
+ type: String,
+ required: false,
+ default: SINGLE_JOB,
+ },
},
computed: {
boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
},
+ computedJobId() {
+ return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
+ },
detailsPath() {
return accessValue(this.dataMethod, 'detailsPath', this.status);
},
hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status);
},
- computedJobId() {
- return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
+ isSingleItem() {
+ return this.type === SINGLE_JOB;
+ },
+ nameComponent() {
+ return this.hasDetails ? 'gl-link' : 'div';
+ },
+ showStageName() {
+ return Boolean(this.stageName);
},
status() {
return this.job && this.job.status ? this.job.status : {};
},
+ testId() {
+ return this.hasDetails ? 'job-with-link' : 'job-without-link';
+ },
tooltipText() {
+ if (this.groupTooltip) {
+ return this.groupTooltip;
+ }
+
const textBuilder = [];
const { name: jobName } = this.job;
@@ -129,7 +167,7 @@ export default {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
relatedDownstreamHovered() {
- return this.job.name === this.jobHovered;
+ return this.job.name === this.sourceJobHovered;
},
relatedDownstreamExpanded() {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
@@ -147,6 +185,17 @@ export default {
hideTooltips() {
this.$root.$emit(BV_HIDE_TOOLTIP);
},
+ jobItemClick(evt) {
+ if (this.isSingleItem) {
+ /*
+ This is so the jobDropdown still toggles. Issue to refactor:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/267117
+ */
+ evt.stopPropagation();
+ }
+
+ this.hideTooltips();
+ },
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
@@ -156,40 +205,45 @@ export default {
<template>
<div
:id="computedJobId"
- class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full"
data-qa-selector="job_item_container"
>
- <gl-link
- v-if="hasDetails"
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
- :href="detailsPath"
- :title="tooltipText"
- :class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
- data-testid="job-with-link"
- @click.stop="hideTooltips"
- @mouseout="hideTooltips"
- >
- <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
- </gl-link>
-
- <div
- v-else
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ <component
+ :is="nameComponent"
+ v-gl-tooltip="{
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ }"
:title="tooltipText"
:class="jobClasses"
- class="js-job-component-tooltip non-details-job-component menu-item"
- data-testid="job-without-link"
+ :href="detailsPath"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
+ :data-testid="testId"
+ @click="jobItemClick"
@mouseout="hideTooltips"
>
- <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
- </div>
+ <div class="ci-job-name-component gl-display-flex gl-align-items-center">
+ <ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
+ <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
+ <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div>
+ <div
+ v-if="showStageName"
+ data-testid="stage-name-in-job"
+ class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
+ >
+ {{ stageName }}
+ </div>
+ </div>
+ </div>
+ </component>
<action-component
v-if="hasAction"
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
+ class="gl-mr-1"
data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index add7b3445f7..3f746731e34 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -3,9 +3,9 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@g
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
import { accessValue } from './accessors';
import { DOWNSTREAM, REST, UPSTREAM } from './constants';
-import { reportToSentry } from './utils';
export default {
directives: {
@@ -183,6 +183,7 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon"
+ :aria-label="__('Expand pipeline')"
data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index b55a77a3c4f..7f772e35e55 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,11 +1,12 @@
<script>
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { LOAD_FAILURE } from '../../constants';
-import { ONE_COL_WIDTH, UPSTREAM } from './constants';
+import { reportToSentry } from '../../utils';
+import { listByLayers } from '../parsing_utils';
+import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
import {
getQueryHeaders,
- reportToSentry,
serializeLoadErrors,
toggleQueryPollingByVisibility,
unwrapPipelineData,
@@ -35,11 +36,16 @@ export default {
type: String,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
currentPipeline: null,
loadingPipelineId: null,
+ pipelineLayers: {},
pipelineExpanded: false,
};
},
@@ -123,6 +129,13 @@ export default {
toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline);
},
+ getPipelineLayers(id) {
+ if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) {
+ this.pipelineLayers[id] = listByLayers(this.currentPipeline);
+ }
+
+ return this.pipelineLayers[id];
+ },
isExpanded(id) {
return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
},
@@ -203,7 +216,9 @@ export default {
class="d-inline-block gl-mt-n2"
:config-paths="configPaths"
:pipeline="currentPipeline"
+ :pipeline-layers="getPipelineLayers(pipeline.id)"
:is-linked-pipeline="true"
+ :view-type="viewType"
/>
</div>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
index 0d1ff94c275..39baeb6e1c3 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
@@ -1,7 +1,7 @@
<script>
+import { reportToSentry } from '../../utils';
import { UPSTREAM } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
-import { reportToSentry } from './utils';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 0a762563114..fa2f381c8a4 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,12 +1,13 @@
<script>
import { capitalize, escape, isEmpty } from 'lodash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { reportToSentry } from '../../utils';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
+import ActionComponent from '../jobs_shared/action_component.vue';
import { accessValue } from './accessors';
-import ActionComponent from './action_component.vue';
import { GRAPHQL } from './constants';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
export default {
components: {
@@ -15,17 +16,18 @@ export default {
JobItem,
MainGraphWrapper,
},
+ mixins: [glFeatureFlagMixin()],
props: {
groups: {
type: Array,
required: true,
},
- pipelineId: {
- type: Number,
+ name: {
+ type: String,
required: true,
},
- title: {
- type: String,
+ pipelineId: {
+ type: Number,
required: true,
},
action: {
@@ -48,6 +50,16 @@ export default {
required: false,
default: () => ({}),
},
+ showStageName: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ sourceJobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
titleClasses: [
'gl-font-weight-bold',
@@ -57,8 +69,23 @@ export default {
'gl-pl-3',
],
computed: {
+ /*
+ currentGroups and filteredGroups are part of
+ a test to hunt down a bug
+ (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142).
+
+ They should be removed when the bug is rectified.
+ */
+ currentGroups() {
+ return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups;
+ },
+ filteredGroups() {
+ return this.groups.map((group) => {
+ return { ...group, jobs: group.jobs.filter(Boolean) };
+ });
+ },
formattedTitle() {
- return capitalize(escape(this.title));
+ return capitalize(escape(this.name));
},
hasAction() {
return !isEmpty(this.action);
@@ -80,6 +107,18 @@ export default {
isFadedOut(jobName) {
return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName);
},
+ isParallel(group) {
+ return group.size > 1 && group.jobs.length > 1;
+ },
+ singleJobExists(group) {
+ const firstJobDefined = Boolean(group.jobs?.[0]);
+
+ if (!firstJobDefined) {
+ reportToSentry('stage_column_component', 'undefined_job_hunt');
+ }
+
+ return group.size === 1 && firstJobDefined;
+ },
},
};
</script>
@@ -104,7 +143,7 @@ export default {
</template>
<template #jobs>
<div
- v-for="group in groups"
+ v-for="group in currentGroups"
:id="groupId(group)"
:key="getGroupId(group)"
data-testid="stage-column-group"
@@ -113,17 +152,23 @@ export default {
@mouseleave="$emit('jobHover', '')"
>
<job-item
- v-if="group.size === 1"
+ v-if="singleJobExists(group)"
:job="group.jobs[0]"
:job-hovered="jobHovered"
+ :source-job-hovered="sourceJobHovered"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
+ :stage-name="showStageName ? group.stageName : ''"
css-class-job-name="gl-build-content"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
- <div v-else :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
- <job-group-dropdown :group="group" :pipeline-id="pipelineId" />
+ <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
+ <job-group-dropdown
+ :group="group"
+ :stage-name="showStageName ? group.stageName : ''"
+ :pipeline-id="pipelineId"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
index 2cee2fbbd8f..cbaf07c05cf 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
@@ -1,10 +1,10 @@
<script>
import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
-import ActionComponent from './action_component.vue';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index b9a8e2638bc..373aa6bf9a1 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,7 +1,6 @@
-import * as Sentry from '@sentry/browser';
import Visibility from 'visibilityjs';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { unwrapStagesWithNeeds } from '../unwrapping_utils';
+import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils';
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return {
@@ -24,13 +23,6 @@ const getQueryHeaders = (etagResource) => {
};
};
-const reportToSentry = (component, failureType) => {
- Sentry.withScope((scope) => {
- scope.setTag('component', component);
- Sentry.captureException(failureType);
- });
-};
-
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
@@ -94,12 +86,13 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => {
stages: { nodes: stages },
} = pipeline;
- const nodes = unwrapStagesWithNeeds(stages);
+ const { stages: updatedStages, lookup } = unwrapStagesWithNeedsAndLookup(stages);
return {
...pipeline,
id: getIdFromGraphQLId(pipeline.id),
- stages: nodes,
+ stages: updatedStages,
+ stagesLookup: lookup,
upstream: upstream
? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
@@ -113,7 +106,6 @@ const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0;
export {
getQueryHeaders,
- reportToSentry,
serializeGqlErr,
serializeLoadErrors,
toggleQueryPollingByVisibility,
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js
index 04ac15ae24c..49cd04d11e9 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/api.js
+++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js
@@ -1,5 +1,5 @@
import axios from '~/lib/utils/axios_utils';
-import { reportToSentry } from '../graph/utils';
+import { reportToSentry } from '../../utils';
export const reportPerformance = (path, stats) => {
axios.post(path, stats).catch((err) => {
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
index fad57084992..0ed5b8a5f09 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
@@ -10,8 +10,8 @@ import {
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants';
-import { createJobsHash, generateJobNeedsDict } from '../../utils';
-import { reportToSentry } from '../graph/utils';
+import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils';
+import { STAGE_VIEW } from '../graph/constants';
import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import { generateLinksData } from './drawing_utils';
@@ -55,11 +55,17 @@ export default {
required: false,
default: '',
},
+ viewType: {
+ type: String,
+ required: false,
+ default: STAGE_VIEW,
+ },
},
data() {
return {
links: [],
needsObject: null,
+ parsedData: {},
};
},
computed: {
@@ -109,6 +115,15 @@ export default {
highlightedJobs(jobs) {
this.$emit('highlightedJobsChange', jobs);
},
+ viewType() {
+ /*
+ We need to wait a tick so that the layout reflows
+ before the links refresh.
+ */
+ this.$nextTick(() => {
+ this.refreshLinks();
+ });
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
@@ -167,14 +182,17 @@ export default {
this.beginPerfMeasure();
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
- const parsedData = parseData(arrayOfJobs);
- this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
+ this.parsedData = parseData(arrayOfJobs);
+ this.refreshLinks();
} catch (err) {
this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false });
reportToSentry(this.$options.name, err);
}
this.finishPerfMeasureAndSend();
},
+ refreshLinks() {
+ this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`);
+ },
getLinkClasses(link) {
return [
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor,
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
index 42eab13b0bd..8dbab245f44 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
@@ -11,7 +11,7 @@ import {
PIPELINES_DETAIL_LINKS_JOB_RATIO,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import { reportToSentry } from '../graph/utils';
+import { reportToSentry } from '../../utils';
import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import LinksInner from './links_inner.vue';
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 4ce43b92c93..d8e7b83a8c1 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -8,6 +8,7 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import { getQueryHeaders } from './graph/utils';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
const POLL_INTERVAL = 10000;
@@ -34,7 +35,9 @@ export default {
[DEFAULT]: __('An unknown error occurred.'),
},
inject: {
- // Receive `fullProject` and `pipelinesPath`
+ graphqlResourceEtag: {
+ default: '',
+ },
paths: {
default: {},
},
@@ -47,6 +50,9 @@ export default {
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getPipelineQuery,
variables() {
return {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index 1df693704d4..3972c126673 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -5,7 +5,7 @@ import axios from '~/lib/utils/axios_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
-import { reportToSentry } from './utils';
+import { reportToSentry } from '../../utils';
/**
* Renders either a cancel, retry or play icon button and handles the post request
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
index fffd8e1818a..fffd8e1818a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
new file mode 100644
index 00000000000..6982586ab12
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
+import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
+
+const featureName = 'pipeline_needs_banner';
+const enumFeatureName = featureName.toUpperCase();
+
+export default {
+ i18n: {
+ title: __('View job dependencies in the pipeline graph!'),
+ description: __(
+ 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}',
+ ),
+ buttonText: __('Provide feedback'),
+ },
+ components: {
+ GlBanner,
+ GlLink,
+ GlSprintf,
+ },
+ apollo: {
+ callouts: {
+ query: getUserCallouts,
+ update(data) {
+ return data?.currentUser?.callouts?.nodes.map((c) => c.featureName);
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ inject: ['dagDocPath'],
+ data() {
+ return {
+ callouts: [],
+ dismissedAlert: false,
+ hasError: false,
+ };
+ },
+ computed: {
+ showBanner() {
+ return (
+ !this.$apollo.queries.callouts?.loading &&
+ !this.hasError &&
+ !this.dismissedAlert &&
+ !this.callouts.includes(enumFeatureName)
+ );
+ },
+ },
+ methods: {
+ handleClose() {
+ this.dismissedAlert = true;
+ try {
+ this.$apollo.mutate({
+ mutation: DismissPipelineNotification,
+ variables: {
+ featureName,
+ },
+ });
+ } catch {
+ createFlash(__('There was a problem dismissing this notification.'));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-banner
+ v-if="showBanner"
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.buttonText"
+ button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688"
+ variant="introduction"
+ @close="handleClose"
+ >
+ <p>
+ <gl-sprintf :message="$options.i18n.description">
+ <template #link="{ content }">
+ <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-banner>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index 9c97fa832d0..f5ab869633b 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -1,4 +1,5 @@
import { uniqWith, isEqual } from 'lodash';
+import { createSankey } from './dag/drawing_utils';
/*
The following functions are the main engine in transforming the data as
@@ -144,3 +145,28 @@ export const getMaxNodes = (nodes) => {
export const removeOrphanNodes = (sankeyfiedNodes) => {
return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length);
};
+
+/*
+ This utility accepts unwrapped pipeline data in the format returned from
+ our standard pipeline GraphQL query and returns a list of names by layer
+ for the layer view. It can be combined with the stageLookup on the pipeline
+ to generate columns by layer.
+*/
+
+export const listByLayers = ({ stages }) => {
+ const arrayOfJobs = stages.flatMap(({ groups }) => groups);
+ const parsedData = parseData(arrayOfJobs);
+ const dataWithLayers = createSankey()(parsedData);
+
+ return dataWithLayers.nodes.reduce((acc, { layer, name }) => {
+ /* sort groups by layer */
+
+ if (!acc[layer]) {
+ acc[layer] = [];
+ }
+
+ acc[layer].push(name);
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 51a95612d3f..01baf0a42d5 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,6 +10,10 @@ export default {
type: String,
required: true,
},
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
isHighlighted: {
type: Boolean,
required: false,
@@ -32,6 +36,9 @@ export default {
},
},
computed: {
+ id() {
+ return `${this.jobName}-${this.pipelineId}`;
+ },
jobPillClasses() {
return [
{ 'gl-opacity-3': this.isFadedOut },
@@ -52,7 +59,7 @@ export default {
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- :id="jobName"
+ :id="id"
class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
:class="jobPillClasses"
@mouseover="onMouseEnter"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 707d6966e77..3ba0d7d0120 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -1,11 +1,8 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
-import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
-import { createJobsHash, generateJobNeedsDict } from '../../utils';
-import { generateLinksData } from '../graph_shared/drawing_utils';
-import { parseData } from '../parsing_utils';
+import { DRAW_FAILURE, DEFAULT } from '../../constants';
+import LinksLayer from '../graph_shared/links_layer.vue';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
@@ -13,18 +10,16 @@ export default {
components: {
GlAlert,
JobPill,
+ LinksLayer,
StagePill,
},
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
- CONTAINER_ID: 'pipeline-graph-container',
+ BASE_CONTAINER_ID: 'pipeline-graph-container',
+ PIPELINE_ID: 0,
STROKE_WIDTH: 2,
errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
- [EMPTY_PIPELINE_DATA]: __(
- 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
- ),
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
},
props: {
pipelineData: {
@@ -36,33 +31,16 @@ export default {
return {
failureType: null,
highlightedJob: null,
- links: [],
- needsObject: null,
- height: 0,
- width: 0,
+ highlightedJobs: [],
+ measurements: {
+ height: 0,
+ width: 0,
+ },
};
},
computed: {
- hideGraph() {
- // We won't even try to render the graph with these condition
- // because it would cause additional errors down the line for the user
- // which is confusing.
- return this.isPipelineDataEmpty || this.isInvalidCiConfig;
- },
- pipelineStages() {
- return this.pipelineData?.stages || [];
- },
- isPipelineDataEmpty() {
- return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
- },
- isInvalidCiConfig() {
- return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
- },
- hasError() {
- return this.failureType;
- },
- hasHighlightedJob() {
- return Boolean(this.highlightedJob);
+ containerId() {
+ return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`;
},
failure() {
switch (this.failureType) {
@@ -72,18 +50,6 @@ export default {
variant: 'danger',
dismissible: true,
};
- case EMPTY_PIPELINE_DATA:
- return {
- text: this.$options.errorTexts[EMPTY_PIPELINE_DATA],
- variant: 'tip',
- dismissible: false,
- };
- case INVALID_CI_CONFIG:
- return {
- text: this.$options.errorTexts[INVALID_CI_CONFIG],
- variant: 'danger',
- dismissible: false,
- };
default:
return {
text: this.$options.errorTexts[DEFAULT],
@@ -92,56 +58,32 @@ export default {
};
}
},
- viewBox() {
- return [0, 0, this.width, this.height];
+ hasError() {
+ return this.failureType;
},
- highlightedJobs() {
- // If you are hovering on a job, then the jobs we want to highlight are:
- // The job you are currently hovering + all of its needs.
- return [this.highlightedJob, ...this.needsObject[this.highlightedJob]];
+ hasHighlightedJob() {
+ return Boolean(this.highlightedJob);
},
- highlightedLinks() {
- // If you are hovering on a job, then the links we want to highlight are:
- // All the links whose `source` and `target` are highlighted jobs.
- if (this.hasHighlightedJob) {
- const filteredLinks = this.links.filter((link) => {
- return (
- this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
- );
- });
-
- return filteredLinks.map((link) => link.ref);
- }
-
- return [];
+ pipelineStages() {
+ return this.pipelineData?.stages || [];
},
},
watch: {
pipelineData: {
immediate: true,
handler() {
- if (this.isPipelineDataEmpty) {
- this.reportFailure(EMPTY_PIPELINE_DATA);
- } else if (this.isInvalidCiConfig) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else {
- this.$nextTick(() => {
- this.computeGraphDimensions();
- this.prepareLinkData();
- });
- }
+ this.$nextTick(() => {
+ this.computeGraphDimensions();
+ });
},
},
},
methods: {
- prepareLinkData() {
- try {
- const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups);
- const parsedData = parseData(arrayOfJobs);
- this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
- } catch {
- this.reportFailure(DRAW_FAILURE);
- }
+ computeGraphDimensions() {
+ this.measurements = {
+ width: this.$refs[this.$options.CONTAINER_REF].scrollWidth,
+ height: this.$refs[this.$options.CONTAINER_REF].scrollHeight,
+ };
},
getStageBackgroundClasses(index) {
const { length } = this.pipelineStages;
@@ -161,22 +103,14 @@ export default {
return '';
},
- highlightNeeds(uniqueJobId) {
- // The first time we hover, we create the object where
- // we store all the data to properly highlight the needs.
- if (!this.needsObject) {
- const jobs = createJobsHash(this.pipelineStages);
- this.needsObject = generateJobNeedsDict(jobs) ?? {};
- }
-
- this.highlightedJob = uniqueJobId;
+ isJobHighlighted(jobName) {
+ return this.highlightedJobs.includes(jobName);
},
- removeHighlightNeeds() {
- this.highlightedJob = null;
+ onError(error) {
+ this.reportFailure(error.type);
},
- computeGraphDimensions() {
- this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
- this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
+ removeHoveredJob() {
+ this.highlightedJob = null;
},
reportFailure(errorType) {
this.failureType = errorType;
@@ -184,17 +118,11 @@ export default {
resetFailure() {
this.failureType = null;
},
- isJobHighlighted(jobName) {
- return this.highlightedJobs.includes(jobName);
+ setHoveredJob(jobName) {
+ this.highlightedJob = jobName;
},
- isLinkHighlighted(linkRef) {
- return this.highlightedLinks.includes(linkRef);
- },
- getLinkClasses(link) {
- return [
- this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
- { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
- ];
+ updateHighlightedJobs(jobs) {
+ this.highlightedJobs = jobs;
},
},
};
@@ -209,50 +137,44 @@ export default {
>
{{ failure.text }}
</gl-alert>
- <div
- v-if="!hideGraph"
- :id="$options.CONTAINER_ID"
- :ref="$options.CONTAINER_REF"
- class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
- data-testid="graph-container"
- >
- <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
- <path
- v-for="link in links"
- :key="link.path"
- :ref="link.ref"
- :d="link.path"
- class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
- :class="getLinkClasses(link)"
- :stroke-width="$options.STROKE_WIDTH"
- />
- </svg>
- <div
- v-for="(stage, index) in pipelineStages"
- :key="`${stage.name}-${index}`"
- class="gl-flex-direction-column"
+ <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
+ <links-layer
+ :pipeline-data="pipelineStages"
+ :pipeline-id="$options.PIPELINE_ID"
+ :container-id="containerId"
+ :container-measurements="measurements"
+ :highlighted-job="highlightedJob"
+ @highlightedJobsChange="updateHighlightedJobs"
+ @error="onError"
>
<div
- class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
- :class="getStageBackgroundClasses(index)"
- data-testid="stage-background"
- >
- <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
- </div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ v-for="(stage, index) in pipelineStages"
+ :key="`${stage.name}-${index}`"
+ class="gl-flex-direction-column"
>
- <job-pill
- v-for="group in stage.groups"
- :key="group.name"
- :job-name="group.name"
- :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
- :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
- @on-mouse-enter="highlightNeeds"
- @on-mouse-leave="removeHighlightNeeds"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
+ :class="getStageBackgroundClasses(index)"
+ data-testid="stage-background"
+ >
+ <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ >
+ <job-pill
+ v-for="group in stage.groups"
+ :key="group.name"
+ :job-name="group.name"
+ :pipeline-id="$options.PIPELINE_ID"
+ :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
+ :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
+ @on-mouse-enter="setHoveredJob"
+ @on-mouse-leave="removeHoveredJob"
+ />
+ </div>
</div>
- </div>
+ </links-layer>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
deleted file mode 100644
index 6c3a4a27606..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<script>
-export default {
- name: 'PipelinesSvgState',
- props: {
- svgPath: {
- type: String,
- required: true,
- },
-
- message: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="row empty-state">
- <div class="col-12">
- <div class="svg-content"><img :src="svgPath" /></div>
- </div>
-
- <div class="col-12 text-center">
- <div class="text-content">
- <h4>{{ message }}</h4>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index f8107d288d9..c3bcfcb18fb 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,7 +1,9 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
+import Experiment from '~/experimentation/components/experiment.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import PipelinesCiTemplates from './pipelines_ci_templates.vue';
export default {
i18n: {
@@ -15,6 +17,8 @@ export default {
name: 'PipelinesEmptyState',
components: {
GlEmptyState,
+ Experiment,
+ PipelinesCiTemplates,
},
props: {
emptyStateSvgPath: {
@@ -35,19 +39,26 @@ export default {
</script>
<template>
<div>
- <gl-empty-state
- v-if="canSetCi"
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- :primary-button-text="$options.i18n.btnText"
- :primary-button-link="ciHelpPagePath"
- />
- <gl-empty-state
- v-else
- title=""
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.noCiDescription"
- />
+ <experiment name="pipeline_empty_state_templates">
+ <template #control>
+ <gl-empty-state
+ v-if="canSetCi"
+ :title="$options.i18n.title"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.description"
+ :primary-button-text="$options.i18n.btnText"
+ :primary-button-link="ciHelpPagePath"
+ />
+ <gl-empty-state
+ v-else
+ title=""
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.noCiDescription"
+ />
+ </template>
+ <template #candidate>
+ <pipelines-ci-templates />
+ </template>
+ </experiment>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
new file mode 100644
index 00000000000..670fa398536
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
@@ -0,0 +1,190 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+import { sprintf } from '~/locale';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
+
+/**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "tooltip": "passed",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+export default {
+ hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
+ components: {
+ ActionComponent,
+ JobNameComponent,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [delayedJobMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ dropdownLength: {
+ type: Number,
+ required: false,
+ default: Infinity,
+ },
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ pipelineExpanded: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ pipelineId: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
+ },
+ computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
+ detailsPath() {
+ return this.status.details_path;
+ },
+ hasDetails() {
+ return this.status.has_details;
+ },
+ status() {
+ return this.job && this.job.status ? this.job.status : {};
+ },
+ tooltipText() {
+ const textBuilder = [];
+ const { name: jobName } = this.job;
+
+ if (jobName) {
+ textBuilder.push(jobName);
+ }
+
+ const { tooltip: statusTooltip } = this.status;
+ if (jobName && statusTooltip) {
+ textBuilder.push('-');
+ }
+
+ if (statusTooltip) {
+ if (this.isDelayedJob) {
+ textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime }));
+ } else {
+ textBuilder.push(statusTooltip);
+ }
+ }
+
+ return textBuilder.join(' ');
+ },
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasAction() {
+ return this.job.status && this.job.status.action && this.job.status.action.path;
+ },
+ relatedDownstreamHovered() {
+ return this.job.name === this.jobHovered;
+ },
+ relatedDownstreamExpanded() {
+ return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
+ },
+ jobClasses() {
+ return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
+ ? `${this.$options.hoverClass} ${this.cssClassJobName}`
+ : this.cssClassJobName;
+ },
+ },
+ errorCaptured(err, _vm, info) {
+ reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`);
+ },
+ methods: {
+ hideTooltips() {
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ },
+ pipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ data-qa-selector="job_item_container"
+ >
+ <gl-link
+ v-if="hasDetails"
+ v-gl-tooltip="{
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ }"
+ :href="detailsPath"
+ :title="tooltipText"
+ :class="jobClasses"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
+ data-testid="job-with-link"
+ @click.stop="hideTooltips"
+ @mouseout="hideTooltips"
+ >
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
+ </gl-link>
+
+ <div
+ v-else
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ :title="tooltipText"
+ :class="jobClasses"
+ class="js-job-component-tooltip non-details-job-component menu-item"
+ data-testid="job-without-link"
+ @mouseout="hideTooltips"
+ >
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
+ </div>
+
+ <action-component
+ v-if="hasAction"
+ :tooltip-text="status.action.title"
+ :link="status.action.path"
+ :action-icon="status.action.icon"
+ data-qa-selector="action_button"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index cf0849751df..235126fea0c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -41,29 +41,29 @@ export default {
<template>
<div class="nav-controls">
<gl-button
- v-if="newPipelinePath"
- :href="newPipelinePath"
- variant="success"
- category="primary"
- class="js-run-pipeline"
- data-testid="run-pipeline-button"
- data-qa-selector="run_pipeline_button"
- >
- {{ s__('Pipelines|Run Pipeline') }}
- </gl-button>
-
- <gl-button
v-if="resetCachePath"
:loading="isResetCacheButtonLoading"
class="js-clear-cache"
data-testid="clear-cache-button"
@click="onClickResetCache"
>
- {{ s__('Pipelines|Clear Runner Caches') }}
+ {{ s__('Pipelines|Clear runner caches') }}
</gl-button>
<gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button">
- {{ s__('Pipelines|CI Lint') }}
+ {{ s__('Pipelines|CI lint') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newPipelinePath"
+ :href="newPipelinePath"
+ variant="confirm"
+ category="primary"
+ class="js-run-pipeline"
+ data-testid="run-pipeline-button"
+ data-qa-selector="run_pipeline_button"
+ >
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
index 05372010d0f..2b33467e948 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div data-testid="widget-mini-pipeline-graph">
+ <div data-testid="pipeline-mini-graph">
<div
v-for="stage in stages"
:key="stage.name"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
index bdb7dd06620..bf992b84387 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -17,7 +17,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import eventHub from '../../event_hub';
-import JobItem from '../graph/job_item.vue';
+import JobItem from './job_item.vue';
export default {
components: {
@@ -103,7 +103,7 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.ds0
data-testid="mini-pipeline-graph-dropdown"
:title="stage.title"
variant="link"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index c707b395192..0528e4c147c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -17,19 +17,11 @@ export default {
user() {
return this.pipeline.user;
},
- classes() {
- const triggererClass = 'pipeline-triggerer';
-
- if (this.glFeatures.newPipelinesTable) {
- return triggererClass;
- }
- return `table-section section-10 d-none d-md-block ${triggererClass}`;
- },
},
};
</script>
<template>
- <div :class="classes" data-testid="pipeline-triggerer">
+ <div class="pipeline-triggerer" data-testid="pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 0de520a2ca7..d39e120dc6c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -49,19 +49,11 @@ export default {
autoDevopsHelpPath() {
return helpPagePath('topics/autodevops/index.md');
},
- classes() {
- const tagsClass = 'pipeline-tags';
-
- if (this.glFeatures.newPipelinesTable) {
- return tagsClass;
- }
- return `table-section section-10 d-none d-md-block ${tagsClass}`;
- },
},
};
</script>
<template>
- <div :class="classes" data-testid="pipeline-url-table-cell">
+ <div class="pipeline-tags" data-testid="pipeline-url-table-cell">
<gl-link
:href="pipeline.path"
data-testid="pipeline-url-link"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 19d93e7d083..f14a582d731 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { isEqual } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -10,7 +10,6 @@ import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../
import PipelinesMixin from '../../mixins/pipelines_mixin';
import PipelinesService from '../../services/pipelines_service';
import { validateParams } from '../../utils';
-import SvgBlankState from './blank_state.vue';
import EmptyState from './empty_state.vue';
import NavigationControls from './nav_controls.vue';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
@@ -19,13 +18,13 @@ import PipelinesTableComponent from './pipelines_table.vue';
export default {
components: {
EmptyState,
+ GlEmptyState,
GlIcon,
GlLoadingIcon,
NavigationTabs,
NavigationControls,
PipelinesFilteredSearch,
PipelinesTableComponent,
- SvgBlankState,
TablePagination,
},
mixins: [PipelinesMixin],
@@ -314,6 +313,7 @@ export default {
</div>
<pipelines-filtered-search
+ v-if="stateToRender !== $options.stateMap.emptyState"
:project-id="projectId"
:params="validatedParams"
@filterPipelines="filterPipelines"
@@ -333,19 +333,19 @@ export default {
:can-set-ci="canCreatePipeline"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="stateToRender === $options.stateMap.error"
:svg-path="errorStateSvgPath"
- :message="
+ :title="
s__(`Pipelines|There was an error fetching the pipelines.
Try again in a few moments or contact your support team.`)
"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="stateToRender === $options.stateMap.emptyTab"
:svg-path="noPipelinesSvgPath"
- :message="emptyTabMessage"
+ :title="emptyTabMessage"
/>
<div v-else-if="stateToRender === $options.stateMap.tableList">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
new file mode 100644
index 00000000000..c2ec8c57fd7
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { s__, sprintf } from '~/locale';
+import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlCard,
+ GlSprintf,
+ },
+ HELLO_WORLD_TEMPLATE_KEY,
+ i18n: {
+ cta: s__('Pipelines|Use template'),
+ testTemplates: {
+ title: s__('Pipelines|Use a sample CI/CD template'),
+ subtitle: s__(
+ 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
+ ),
+ helloWorld: {
+ title: s__('Pipelines|“Hello world” with GitLab CI/CD'),
+ description: s__(
+ 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.',
+ ),
+ },
+ },
+ templates: {
+ title: s__('Pipelines|Use a CI/CD template'),
+ subtitle: s__(
+ "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
+ ),
+ description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ },
+ },
+ inject: ['addCiYmlPath', 'suggestedCiTemplates'],
+ data() {
+ const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
+ return {
+ name,
+ logo,
+ link: mergeUrlParams({ template: name }, this.addCiYmlPath),
+ description: sprintf(this.$options.i18n.templates.description, { name }),
+ };
+ });
+
+ return {
+ templates,
+ helloWorldTemplateUrl: mergeUrlParams(
+ { template: HELLO_WORLD_TEMPLATE_KEY },
+ this.addCiYmlPath,
+ ),
+ };
+ },
+ methods: {
+ trackEvent(template) {
+ const tracking = new ExperimentTracking('pipeline_empty_state_templates', {
+ label: template,
+ });
+ tracking.event('template_clicked');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.i18n.testTemplates.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div class="row gl-mb-8">
+ <div class="col-lg-3">
+ <gl-card>
+ <div class="gl-flex-direction-row">
+ <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800 gl-mb-2">{{
+ $options.i18n.testTemplates.helloWorld.title
+ }}</strong>
+ </div>
+ <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p>
+ </div>
+
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="helloWorldTemplateUrl"
+ data-testid="test-template-link"
+ @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </gl-card>
+ </div>
+ </div>
+
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p>
+
+ <ul class="gl-list-style-none gl-pl-0">
+ <li v-for="template in templates" :key="template.name">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
+ >
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <img
+ width="64"
+ height="64"
+ :src="template.logo"
+ class="gl-mr-6"
+ data-testid="template-logo"
+ />
+ <div class="gl-flex-direction-row">
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800" data-testid="template-name">{{
+ template.name
+ }}</strong>
+ </div>
+ <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
+ {{ template.description }}
+ </p>
+ </div>
+ </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="template.link"
+ data-testid="template-link"
+ @click="trackEvent(template.name)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index aa27aa7e50d..47fc7023222 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,7 +1,6 @@
<script>
import { GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineOperations from './pipeline_operations.vue';
@@ -10,7 +9,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
import PipelinesCommit from './pipelines_commit.vue';
import PipelinesStatusBadge from './pipelines_status_badge.vue';
-import PipelinesTableRowComponent from './pipelines_table_row.vue';
import PipelinesTimeago from './time_ago.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
@@ -83,7 +81,6 @@ export default {
PipelineOperations,
PipelinesStatusBadge,
PipelineStopModal,
- PipelinesTableRowComponent,
PipelinesTimeago,
PipelineTriggerer,
PipelineUrl,
@@ -91,7 +88,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -149,41 +145,7 @@ export default {
</script>
<template>
<div class="ci-table">
- <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status" role="rowheader">
- {{ s__('Pipeline|Status') }}
- </div>
- <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
- {{ s__('Pipeline|Pipeline') }}
- </div>
- <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
- {{ s__('Pipeline|Triggerer') }}
- </div>
- <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
- {{ s__('Pipeline|Commit') }}
- </div>
- <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
- {{ s__('Pipeline|Stages') }}
- </div>
- <div class="table-section section-15" role="rowheader"></div>
- <div class="table-section section-20" role="rowheader">
- <slot name="table-header-actions"></slot>
- </div>
- </div>
- <pipelines-table-row-component
- v-for="model in pipelines"
- :key="model.id"
- :pipeline="model"
- :pipeline-schedule-url="pipelineScheduleUrl"
- :update-graph-dropdown="updateGraphDropdown"
- :view-type="viewType"
- :canceling-pipeline="cancelingPipeline"
- />
- </div>
-
<gl-table
- v-else
:fields="$options.fields"
:items="pipelines"
tbody-tr-class="commit"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
deleted file mode 100644
index f684a0b0fcd..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ /dev/null
@@ -1,269 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import CommitComponent from '~/vue_shared/components/commit.vue';
-import eventHub from '../../event_hub';
-import PipelineMiniGraph from './pipeline_mini_graph.vue';
-import PipelineTriggerer from './pipeline_triggerer.vue';
-import PipelineUrl from './pipeline_url.vue';
-import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import PipelinesManualActionsComponent from './pipelines_manual_actions.vue';
-import PipelinesTimeago from './time_ago.vue';
-
-export default {
- i18n: {
- cancelTitle: __('Cancel'),
- redeployTitle: __('Retry'),
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- GlModalDirective,
- },
- components: {
- PipelinesManualActionsComponent,
- PipelinesArtifactsComponent,
- CommitComponent,
- PipelineMiniGraph,
- PipelineUrl,
- PipelineTriggerer,
- CiBadge,
- PipelinesTimeago,
- GlButton,
- },
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- pipelineScheduleUrl: {
- type: String,
- required: false,
- default: '',
- },
- updateGraphDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
- viewType: {
- type: String,
- required: true,
- },
- cancelingPipeline: {
- type: Number,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- isRetrying: false,
- };
- },
- computed: {
- actions() {
- if (!this.pipeline || !this.pipeline.details) {
- return [];
- }
- const { details } = this.pipeline;
- return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
- },
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * This field needs a lot of verification, because of different possible cases:
- *
- * 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar
- * 3. If GitLab user does not have avatar they might have a Gravatar
- * 4. If committer is not a GitLab User they can have a Gravatar
- * 5. We do not have consistent API object in this case
- * 6. We should improve API and the code
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- let commitAuthorInformation;
-
- if (!this.pipeline || !this.pipeline.commit) {
- return null;
- }
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // they can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar, they might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = {
- ...this.pipeline.commit.author,
- avatar_url: this.pipeline.commit.author_gravatar_url,
- };
- }
- // 4. If committer is not a GitLab User, they can have a Gravatar
- } else {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- path: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
-
- return commitAuthorInformation;
- },
- commitTag() {
- return this.pipeline?.ref?.tag;
- },
- commitRef() {
- return this.pipeline?.ref;
- },
- commitUrl() {
- return this.pipeline?.commit?.commit_path;
- },
- commitShortSha() {
- return this.pipeline?.commit?.short_id;
- },
- commitTitle() {
- return this.pipeline?.commit?.title;
- },
- pipelineStatus() {
- return this.pipeline?.details?.status ?? {};
- },
- hasStages() {
- return this.pipeline?.details?.stages?.length > 0;
- },
- displayPipelineActions() {
- return (
- this.pipeline.flags.retryable ||
- this.pipeline.flags.cancelable ||
- this.pipeline.details.manual_actions.length ||
- this.pipeline.details.artifacts.length
- );
- },
- isChildView() {
- return this.viewType === 'child';
- },
- isCancelling() {
- return this.cancelingPipeline === this.pipeline.id;
- },
- },
- watch: {
- pipeline() {
- this.isRetrying = false;
- },
- },
- methods: {
- handleCancelClick() {
- eventHub.$emit('openConfirmationModal', {
- pipeline: this.pipeline,
- endpoint: this.pipeline.cancel_path,
- });
- },
- handleRetryClick() {
- this.isRetrying = true;
- eventHub.$emit('retryPipeline', this.pipeline.retry_path);
- },
- handlePipelineActionRequestComplete() {
- // warn the pipelines table to update
- eventHub.$emit('refreshPipelinesTable');
- },
- },
-};
-</script>
-<template>
- <div class="commit gl-responsive-table-row">
- <div class="table-section section-10 commit-link">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div>
- <div class="table-mobile-content">
- <ci-badge
- :status="pipelineStatus"
- :show-text="!isChildView"
- :icon-classes="'gl-vertical-align-middle!'"
- data-qa-selector="pipeline_commit_status"
- />
- </div>
- </div>
-
- <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" />
- <pipeline-triggerer :pipeline="pipeline" />
-
- <div class="table-section section-wrap section-20">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div>
- <div class="table-mobile-content">
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :merge-request-ref="pipeline.merge_request"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"
- :show-ref-info="!isChildView"
- />
- </div>
- </div>
-
- <div class="table-section section-wrap section-15 stage-cell">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
- <div class="table-mobile-content">
- <pipeline-mini-graph
- v-if="hasStages"
- :stages="pipeline.details.stages"
- :update-dropdown="updateGraphDropdown"
- @pipelineActionRequestComplete="handlePipelineActionRequestComplete"
- />
- </div>
- </div>
-
- <pipelines-timeago class="gl-text-right" :pipeline="pipeline" />
-
- <div
- v-if="displayPipelineActions"
- class="table-section section-20 table-button-footer pipeline-actions"
- >
- <div class="btn-group table-action-buttons">
- <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" />
-
- <pipelines-artifacts-component
- v-if="pipeline.details.artifacts.length"
- :artifacts="pipeline.details.artifacts"
- />
-
- <gl-button
- v-if="pipeline.flags.retryable"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.redeployTitle"
- :title="$options.i18n.redeployTitle"
- :disabled="isRetrying"
- :loading="isRetrying"
- class="js-pipelines-retry-button"
- data-qa-selector="pipeline_retry_button"
- icon="repeat"
- variant="default"
- category="secondary"
- @click="handleRetryClick"
- />
-
- <gl-button
- v-if="pipeline.flags.cancelable"
- v-gl-tooltip.hover
- v-gl-modal-directive="'confirmation-modal'"
- :aria-label="$options.i18n.cancelTitle"
- :title="$options.i18n.cancelTitle"
- :loading="isCancelling"
- :disabled="isCancelling"
- icon="close"
- variant="danger"
- category="primary"
- class="js-pipelines-cancel-button"
- @click="handleCancelClick"
- />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 543bdf94307..e6b03751350 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -22,6 +22,12 @@ export default {
finishedTime() {
return this.pipeline?.details?.finished_at;
},
+ skipped() {
+ return this.pipeline?.details?.status?.label === 'skipped';
+ },
+ stuck() {
+ return this.pipeline.flags.stuck;
+ },
durationFormatted() {
const date = new Date(this.duration * 1000);
@@ -42,46 +48,50 @@ export default {
return `${hh}:${mm}:${ss}`;
},
- legacySectionClass() {
- return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : '';
- },
- legacyTableMobileClass() {
- return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : '';
- },
showInProgress() {
- return !this.duration && !this.finishedTime;
+ return !this.duration && !this.finishedTime && !this.skipped;
+ },
+ showSkipped() {
+ return !this.duration && !this.finishedTime && this.skipped;
},
},
};
</script>
<template>
- <div :class="legacySectionClass">
- <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader">
- {{ s__('Pipeline|Duration') }}
- </div>
- <div :class="legacyTableMobileClass">
- <span v-if="showInProgress" data-testid="pipeline-in-progress">
- <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" />
- {{ s__('Pipeline|In progress') }}
- </span>
+ <div>
+ <span v-if="showInProgress" data-testid="pipeline-in-progress">
+ <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
+ <gl-icon
+ v-else
+ name="hourglass"
+ class="gl-vertical-align-baseline! gl-mr-2"
+ :size="12"
+ data-testid="hourglass-icon"
+ />
+ {{ s__('Pipeline|In progress') }}
+ </span>
+
+ <span v-if="showSkipped" data-testid="pipeline-skipped">
+ <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" />
+ {{ s__('Pipeline|Skipped') }}
+ </span>
- <p v-if="duration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
- {{ durationFormatted }}
- </p>
+ <p v-if="duration" class="duration">
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
+ {{ durationFormatted }}
+ </p>
- <p v-if="finishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
+ <p v-if="finishedTime" class="finished-at d-none d-md-block">
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
- <time
- v-gl-tooltip
- :title="tooltipTitle(finishedTime)"
- data-placement="top"
- data-container="body"
- >
- {{ timeFormatted(finishedTime) }}
- </time>
- </p>
- </div>
+ <time
+ v-gl-tooltip
+ :title="tooltipTitle(finishedTime)"
+ data-placement="top"
+ data-container="body"
+ >
+ {{ timeFormatted(finishedTime) }}
+ </time>
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index d33d4e7dfd0..79b1b6af38b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -72,6 +72,7 @@ export default {
size="small"
class="gl-mr-3 js-back-button"
icon="angle-left"
+ :aria-label="__('Go back')"
@click="onBackClick"
/>
diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
index 15073079c0a..2d24beb8323 100644
--- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js
+++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
@@ -1,15 +1,33 @@
+import { reportToSentry } from '../utils';
+
const unwrapGroups = (stages) => {
- return stages.map((stage) => {
+ return stages.map((stage, idx) => {
const {
groups: { nodes: groups },
} = stage;
- return { ...stage, groups };
+
+ /*
+ Being peformance conscious here means we don't want to spread and copy the
+ group value just to add one parameter.
+ */
+ /* eslint-disable no-param-reassign */
+ const groupsWithStageName = groups.map((group) => {
+ group.stageName = stage.name;
+ return group;
+ });
+ /* eslint-enable no-param-reassign */
+
+ return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } };
});
};
const unwrapNodesWithName = (jobArray, prop, field = 'name') => {
+ if (jobArray.length < 1) {
+ reportToSentry('unwrapping_utils', 'undefined_job_hunt, array empty from backend');
+ }
+
return jobArray.map((job) => {
- return { ...job, [prop]: job[prop].nodes.map((item) => item[field]) };
+ return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') };
});
};
@@ -17,20 +35,34 @@ const unwrapJobWithNeeds = (denodedJobArray) => {
return unwrapNodesWithName(denodedJobArray, 'needs');
};
-const unwrapStagesWithNeeds = (denodedStages) => {
+const unwrapStagesWithNeedsAndLookup = (denodedStages) => {
const unwrappedNestedGroups = unwrapGroups(denodedStages);
- const nodes = unwrappedNestedGroups.map((node) => {
+ const lookupMap = {};
+
+ const nodes = unwrappedNestedGroups.map(({ node, lookup }) => {
const { groups } = node;
- const groupsWithJobs = groups.map((group) => {
+ const groupsWithJobs = groups.map((group, idx) => {
const jobs = unwrapJobWithNeeds(group.jobs.nodes);
+
+ lookupMap[group.name] = { ...lookup, groupIdx: idx };
return { ...group, jobs };
});
return { ...node, groups: groupsWithJobs };
});
- return nodes;
+ return { stages: nodes, lookup: lookupMap };
+};
+
+const unwrapStagesWithNeeds = (denodedStages) => {
+ return unwrapStagesWithNeedsAndLookup(denodedStages).stages;
};
-export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds };
+export {
+ unwrapGroups,
+ unwrapJobWithNeeds,
+ unwrapNodesWithName,
+ unwrapStagesWithNeeds,
+ unwrapStagesWithNeedsAndLookup,
+};
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 21b114825a6..01705e7726f 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -35,3 +35,6 @@ export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const CHILD_VIEW = 'child';
+
+// The key of the template is the same as the filename
+export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World';
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
new file mode 100644
index 00000000000..e4fd55a28be
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
@@ -0,0 +1,5 @@
+mutation DismissPipelineNotification($featureName: String!) {
+ userCalloutCreate(input: { featureName: $featureName }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
index c73b186739e..887c217da41 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
@@ -1,6 +1,7 @@
query getDagVisData($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
+ id
stages {
nodes {
name
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
new file mode 100644
index 00000000000..12b391e41ac
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
@@ -0,0 +1,13 @@
+query getUser {
+ currentUser {
+ id
+ __typename
+ callouts {
+ __typename
+ nodes {
+ __typename
+ featureName
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 2321728e30c..d9c9289f66e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -190,7 +190,7 @@ export default {
.then(() => this.updateTable())
.catch(() => {
createFlash(
- __('An error occurred while trying to run a new pipeline for this Merge Request.'),
+ __('An error occurred while trying to run a new pipeline for this merge request.'),
);
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index c3444f38ea0..a2bc049c3c7 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,11 +3,15 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import { __ } from '~/locale';
import Translate from '~/vue_shared/translate';
import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue';
-import { reportToSentry } from './components/graph/utils';
import TestReports from './components/test_reports/test_reports.vue';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import createDagApp from './pipeline_details_dag';
+import { createPipelinesDetailApp } from './pipeline_details_graph';
+import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineNotificationApp } from './pipeline_details_notification';
+import { apolloProvider } from './pipeline_shared_client';
import createTestReportsStore from './stores/test_reports';
+import { reportToSentry } from './utils';
Vue.use(Translate);
@@ -15,6 +19,7 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
};
@@ -79,21 +84,28 @@ const createTestDetails = () => {
};
export default async function initPipelineDetailsBundle() {
- createTestDetails();
- createDagApp();
-
const canShowNewPipelineDetails =
gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers;
const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS);
- if (canShowNewPipelineDetails) {
+ try {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+
+ if (gon.features.pipelineGraphLayersView) {
try {
- const { createPipelinesDetailApp } = await import(
- /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
- );
+ createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+ }
- createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset);
+ if (canShowNewPipelineDetails) {
+ try {
+ createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
@@ -107,12 +119,6 @@ export default async function initPipelineDetailsBundle() {
createLegacyPipelinesDetailApp(mediator);
}
- try {
- const { createPipelineHeaderApp } = await import(
- /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header'
- );
- createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
- } catch {
- Flash(__('An error occurred while loading a section of this page.'));
- }
+ createDagApp(apolloProvider);
+ createTestDetails();
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js
index 4ee0ad462d2..e2835ecc4d1 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_dag.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js
@@ -1,15 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import Dag from './components/dag/dag.vue';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-const createDagApp = () => {
+const createDagApp = (apolloProvider) => {
const el = document.querySelector('#js-pipeline-dag-vue');
if (!el) {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 9eba39738dc..39c3c2ea5c5 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -1,23 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import { GRAPHQL } from './components/graph/constants';
import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue';
-import { reportToSentry } from './components/graph/utils';
+import { reportToSentry } from './utils';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- useGet: true,
- },
- ),
-});
-
const createPipelinesDetailApp = (
selector,
+ apolloProvider,
{ pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {},
) => {
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index cba29acdb32..1c619768764 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,15 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import pipelineHeader from './components/header_component.vue';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-export const createPipelineHeaderApp = (elSelector) => {
+export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => {
const el = document.querySelector(elSelector);
if (!el) {
@@ -27,6 +22,7 @@ export const createPipelineHeaderApp = (elSelector) => {
provide: {
paths: {
fullProject: fullPath,
+ graphqlResourceEtag,
pipelinesPath,
},
pipelineId,
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
new file mode 100644
index 00000000000..be234e8972d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import PipelineNotification from './components/notification/pipeline_notification.vue';
+
+Vue.use(VueApollo);
+
+export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { dagDocPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ PipelineNotification,
+ },
+ provide: {
+ dagDocPath,
+ },
+ apolloProvider,
+ render(createElement) {
+ return createElement('pipeline-notification');
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
new file mode 100644
index 00000000000..c3be487caae
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -0,0 +1,11 @@
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ useGet: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 0e2e9785956..9ed4365ad75 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -27,6 +27,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
errorStateSvgPath,
noPipelinesSvgPath,
newPipelinePath,
+ addCiYmlPath,
+ suggestedCiTemplates,
canCreatePipeline,
hasGitlabCi,
ciLintPath,
@@ -37,6 +39,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
return new Vue({
el,
+ provide: {
+ addCiYmlPath,
+ suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
+ },
data() {
return {
store: new PipelinesStore(),
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 22820fca43e..0a6c326fa3d 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import { pickBy } from 'lodash';
import { createNodeDict } from './components/parsing_utils';
import { SUPPORTED_FILTER_PARAMETERS } from './constants';
@@ -65,3 +66,10 @@ export const generateJobNeedsDict = (jobs = {}) => {
return { ...acc, [value]: uniqueValues };
}, {});
};
+
+export const reportToSentry = (component, failureType) => {
+ Sentry.withScope((scope) => {
+ scope.setTag('component', component);
+ Sentry.captureException(failureType);
+ });
+};
diff --git a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue
new file mode 100644
index 00000000000..67b5e1e512c
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { n__ } from '~/locale';
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ commentsCount: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ tooltipText() {
+ return n__('%d comment on this commit', '%d comments on this commit', this.commentsCount);
+ },
+ showCommentButton() {
+ return this.commentsCount > 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ v-if="showCommentButton"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-inline-block"
+ tabindex="0"
+ :title="tooltipText"
+ data-testid="comment-button-wrapper"
+ >
+ <gl-button icon="comment" class="gl-mr-3" disabled>
+ {{ commentsCount }}
+ </gl-button>
+ </span>
+</template>
diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
new file mode 100644
index 00000000000..d96d1035ed0
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui';
+import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '../constants';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ },
+ inject: {
+ newProjectTagPath: {
+ default: '',
+ },
+ emailPatchesPath: {
+ default: '',
+ },
+ plainDiffPath: {
+ default: '',
+ },
+ },
+ props: {
+ canRevert: {
+ type: Boolean,
+ required: true,
+ },
+ canCherryPick: {
+ type: Boolean,
+ required: true,
+ },
+ canTag: {
+ type: Boolean,
+ required: true,
+ },
+ canEmailPatches: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ showDivider() {
+ return this.canRevert || this.canCherryPick || this.canTag;
+ },
+ },
+ methods: {
+ showModal(modalId) {
+ eventHub.$emit(modalId);
+ },
+ },
+ openRevertModal: OPEN_REVERT_MODAL,
+ openCherryPickModal: OPEN_CHERRY_PICK_MODAL,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :text="__('Options')"
+ right
+ data-testid="commit-options-dropdown"
+ data-qa-selector="options_button"
+ class="gl-xs-w-full"
+ >
+ <gl-dropdown-item
+ v-if="canRevert"
+ data-testid="revert-link"
+ @click="showModal($options.openRevertModal)"
+ >
+ {{ s__('ChangeTypeAction|Revert') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="canCherryPick"
+ data-testid="cherry-pick-link"
+ data-qa-selector="cherry_pick_button"
+ @click="showModal($options.openCherryPickModal)"
+ >
+ {{ s__('ChangeTypeAction|Cherry-pick') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="canTag" :href="newProjectTagPath" data-testid="tag-link">
+ {{ s__('CreateTag|Tag') }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="showDivider" />
+ <gl-dropdown-section-header>
+ {{ __('Download') }}
+ </gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-if="canEmailPatches"
+ :href="emailPatchesPath"
+ download
+ rel="nofollow"
+ data-testid="email-patches-link"
+ data-qa-selector="email_patches"
+ >
+ {{ s__('DownloadCommit|Email Patches') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ :href="plainDiffPath"
+ download
+ rel="nofollow"
+ data-testid="plain-diff-link"
+ data-qa-selector="plain_diff"
+ >
+ {{ s__('DownloadCommit|Plain Diff') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index 30968d29cde..6eefa5f55e4 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -37,6 +37,11 @@ export default {
type: String,
required: true,
},
+ isCherryPick: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -47,6 +52,7 @@ export default {
{ variant: 'success' },
{ category: 'primary' },
{ 'data-testid': 'submit-commit' },
+ { 'data-qa-selector': 'submit_commit_button' },
],
},
actionCancel: {
@@ -110,7 +116,7 @@ export default {
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-form-group
- v-if="glFeatures.pickIntoProject"
+ v-if="glFeatures.pickIntoProject && isCherryPick"
:label="i18n.projectLabel"
label-for="start_project"
data-testid="dropdown-group"
diff --git a/app/assets/javascripts/projects/commit/components/form_trigger.vue b/app/assets/javascripts/projects/commit/components/form_trigger.vue
deleted file mode 100644
index 3561b5c2473..00000000000
--- a/app/assets/javascripts/projects/commit/components/form_trigger.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-import { GlLink } from '@gitlab/ui';
-import eventHub from '../event_hub';
-
-export default {
- components: {
- GlLink,
- },
- inject: {
- displayText: {
- default: '',
- },
- testId: {
- default: '',
- },
- },
- props: {
- openModal: {
- type: String,
- required: true,
- },
- },
- methods: {
- showModal() {
- eventHub.$emit(this.openModal);
- },
- },
-};
-</script>
-
-<template>
- <gl-link data-is-link="true" :data-testid="testId" @click="showModal">
- {{ displayText }}
- </gl-link>
-</template>
diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js
index d6bb4e9483f..d553bca360e 100644
--- a/app/assets/javascripts/projects/commit/constants.js
+++ b/app/assets/javascripts/projects/commit/constants.js
@@ -2,10 +2,8 @@ import { s__, __ } from '~/locale';
export const OPEN_REVERT_MODAL = 'openRevertModal';
export const REVERT_MODAL_ID = 'revert-commit-modal';
-export const REVERT_LINK_TEST_ID = 'revert-commit-link';
export const OPEN_CHERRY_PICK_MODAL = 'openCherryPickModal';
export const CHERRY_PICK_MODAL_ID = 'cherry-pick-commit-modal';
-export const CHERRY_PICK_LINK_TEST_ID = 'cherry-pick-commit-link';
export const I18N_MODAL = {
startMergeRequest: s__('ChangeTypeAction|Start a %{newMergeRequest} with these changes'),
diff --git a/app/assets/javascripts/projects/commit/index.js b/app/assets/javascripts/projects/commit/index.js
index b5fdfc25236..d8d30c4332c 100644
--- a/app/assets/javascripts/projects/commit/index.js
+++ b/app/assets/javascripts/projects/commit/index.js
@@ -1,11 +1,11 @@
import initCherryPickCommitModal from './init_cherry_pick_commit_modal';
-import initCherryPickCommitTrigger from './init_cherry_pick_commit_trigger';
+import initCommitCommentsButton from './init_commit_comments_button';
+import initCommitOptionsDropdown from './init_commit_options_dropdown';
import initRevertCommitModal from './init_revert_commit_modal';
-import initRevertCommitTrigger from './init_revert_commit_trigger';
export default () => {
initRevertCommitModal();
- initRevertCommitTrigger();
initCherryPickCommitModal();
- initCherryPickCommitTrigger();
+ initCommitCommentsButton();
+ initCommitOptionsDropdown();
};
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
index ad31ad14b2a..47ee8237fea 100644
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
+++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js
@@ -51,6 +51,7 @@ export default function initInviteMembersModal() {
i18n: { ...I18N_CHERRY_PICK_MODAL, ...I18N_MODAL },
openModal: OPEN_CHERRY_PICK_MODAL,
modalId: CHERRY_PICK_MODAL_ID,
+ isCherryPick: true,
},
}),
});
diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js
deleted file mode 100644
index 942451dc96a..00000000000
--- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import CommitFormTrigger from './components/form_trigger.vue';
-import { OPEN_CHERRY_PICK_MODAL, CHERRY_PICK_LINK_TEST_ID } from './constants';
-
-export default function initInviteMembersTrigger() {
- const el = document.querySelector('.js-cherry-pick-commit-trigger');
-
- if (!el) {
- return false;
- }
-
- const { displayText } = el.dataset;
-
- return new Vue({
- el,
- provide: { displayText, testId: CHERRY_PICK_LINK_TEST_ID },
- render: (createElement) =>
- createElement(CommitFormTrigger, { props: { openModal: OPEN_CHERRY_PICK_MODAL } }),
- });
-}
diff --git a/app/assets/javascripts/projects/commit/init_commit_comments_button.js b/app/assets/javascripts/projects/commit/init_commit_comments_button.js
new file mode 100644
index 00000000000..d70f7cb65f3
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/init_commit_comments_button.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import CommitCommentsButton from './components/commit_comments_button.vue';
+
+export default function initCommitCommentsButton() {
+ const el = document.querySelector('#js-commit-comments-button');
+
+ if (!el) {
+ return false;
+ }
+
+ const { commentsCount } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(CommitCommentsButton, { props: { commentsCount: Number(commentsCount) } }),
+ });
+}
diff --git a/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js b/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js
new file mode 100644
index 00000000000..339918e7661
--- /dev/null
+++ b/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import CommitOptionsDropdown from './components/commit_options_dropdown.vue';
+
+export default function initCommitOptionsDropdown() {
+ const el = document.querySelector('#js-commit-options-dropdown');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ newProjectTagPath,
+ emailPatchesPath,
+ plainDiffPath,
+ canRevert,
+ canCherryPick,
+ canTag,
+ canEmailPatches,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: { newProjectTagPath, emailPatchesPath, plainDiffPath },
+ render: (createElement) =>
+ createElement(CommitOptionsDropdown, {
+ props: {
+ canRevert: parseBoolean(canRevert),
+ canCherryPick: parseBoolean(canCherryPick),
+ canTag: parseBoolean(canTag),
+ canEmailPatches: parseBoolean(canEmailPatches),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js b/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js
deleted file mode 100644
index dc5168524ca..00000000000
--- a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import CommitFormTrigger from './components/form_trigger.vue';
-import { OPEN_REVERT_MODAL, REVERT_LINK_TEST_ID } from './constants';
-
-export default function initInviteMembersTrigger() {
- const el = document.querySelector('.js-revert-commit-trigger');
-
- if (!el) {
- return false;
- }
-
- const { displayText } = el.dataset;
-
- return new Vue({
- el,
- provide: { displayText, testId: REVERT_LINK_TEST_ID },
- render: (createElement) =>
- createElement(CommitFormTrigger, { props: { openModal: OPEN_REVERT_MODAL } }),
- });
-}
diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js
index c72704303ca..2b25082eced 100644
--- a/app/assets/javascripts/projects/commit/store/actions.js
+++ b/app/assets/javascripts/projects/commit/store/actions.js
@@ -22,8 +22,8 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => {
.get(state.branchesEndpoint, {
params: { search: query },
})
- .then(({ data }) => {
- commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches || []);
+ .then(({ data = [] }) => {
+ commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches?.length ? data.Branches : data);
})
.catch(() => {
createFlash({ message: PROJECT_BRANCHES_ERROR });
diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js
index 17c63ecf66b..69fe2d30489 100644
--- a/app/assets/javascripts/projects/commit_box/info/index.js
+++ b/app/assets/javascripts/projects/commit_box/info/index.js
@@ -1,27 +1,17 @@
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
-import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown';
import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph';
import { initDetailsButton } from './init_details_button';
import { loadBranches } from './load_branches';
-export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => {
- const containerEl = document.querySelector(containerSelector);
-
+export const initCommitBoxInfo = () => {
// Display commit related branches
- loadBranches(containerEl);
+ loadBranches();
// Related merge requests to this commit
fetchCommitMergeRequests();
// Display pipeline mini graph for this commit
- // Feature flag ci_commit_pipeline_mini_graph_vue
- if (gon.features.ciCommitPipelineMiniGraphVue) {
- initCommitPipelineMiniGraph();
- } else {
- new MiniPipelineGraph({
- container: '.js-commit-pipeline-graph',
- }).bindEvents();
- }
+ initCommitPipelineMiniGraph();
initDetailsButton();
};
diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js
index 8a0b2c30abe..d1136817cb3 100644
--- a/app/assets/javascripts/projects/commit_box/info/load_branches.js
+++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js
@@ -2,7 +2,8 @@ import axios from 'axios';
import { sanitize } from '~/lib/dompurify';
import { __ } from '~/locale';
-export const loadBranches = (containerEl) => {
+export const loadBranches = (containerSelector = '.js-commit-box-info') => {
+ const containerEl = document.querySelector(containerSelector);
if (!containerEl) {
return;
}
diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue
index c0ff58ee074..d3f09f7d69f 100644
--- a/app/assets/javascripts/projects/compare/components/app_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/app_legacy.vue
@@ -37,10 +37,22 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ from: this.paramsFrom,
+ to: this.paramsTo,
+ };
+ },
methods: {
onSubmit() {
this.$refs.form.submit();
},
+ onSwapRevision() {
+ [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to'
+ },
+ onSelectRevision({ direction, revision }) {
+ this[direction] = revision; // direction is either 'from' or 'to'
+ },
},
};
</script>
@@ -57,19 +69,30 @@ export default {
:refs-project-path="refsProjectPath"
revision-text="Source"
params-name="to"
- :params-branch="paramsTo"
+ :params-branch="to"
+ data-testid="sourceRevisionDropdown"
+ @selectRevision="onSelectRevision"
/>
<div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div>
<revision-dropdown
:refs-project-path="refsProjectPath"
revision-text="Target"
params-name="from"
- :params-branch="paramsFrom"
+ :params-branch="from"
+ data-testid="targetRevisionDropdown"
+ @selectRevision="onSelectRevision"
/>
<gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }}
</gl-button>
<gl-button
+ data-testid="swapRevisionsButton"
+ class="btn btn-default gl-button gl-ml-3"
+ @click="onSwapRevision"
+ >
+ {{ s__('CompareRevisions|Swap revisions') }}
+ </gl-button>
+ <gl-button
v-if="projectMergeRequestPath"
:href="projectMergeRequestPath"
data-testid="projectMrButton"
diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
index 822dfc09d81..cb9d8b64b33 100644
--- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
@@ -46,14 +46,7 @@ export default {
this.emitTargetProject(repo.name);
},
setDefaultRepo() {
- if (this.isSourceRevision) {
- this.selectedRepo = this.projectTo;
- return;
- }
-
- const [defaultTargetProject] = this.projectsFrom;
- this.emitTargetProject(defaultTargetProject.name);
- this.selectedRepo = defaultTargetProject;
+ this.selectedRepo = this.projectTo;
},
emitTargetProject(name) {
if (!this.isSourceRevision) {
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index a175af2f32e..d0b69344c12 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -1,10 +1,12 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui';
+import { debounce } from 'lodash';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
-const emptyDropdownText = s__('CompareRevisions|Select branch/tag');
+const EMPTY_DROPDOWN_TEXT = s__('CompareRevisions|Select branch/tag');
+const SEARCH_DEBOUNCE_MS = 300;
export default {
components: {
@@ -38,19 +40,11 @@ export default {
};
},
computed: {
- filteredBranches() {
- return this.branches.filter((branch) =>
- branch.toLowerCase().includes(this.searchTerm.toLowerCase()),
- );
+ hasBranches() {
+ return Boolean(this.branches?.length);
},
- hasFilteredBranches() {
- return this.filteredBranches.length;
- },
- filteredTags() {
- return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase()));
- },
- hasFilteredTags() {
- return this.filteredTags.length;
+ hasTags() {
+ return Boolean(this.tags?.length);
},
},
watch: {
@@ -59,13 +53,34 @@ export default {
this.fetchBranchesAndTags(true);
}
},
+ searchTerm: debounce(function debounceSearch() {
+ this.searchBranchesAndTags();
+ }, SEARCH_DEBOUNCE_MS),
},
mounted() {
this.fetchBranchesAndTags();
},
methods: {
+ searchBranchesAndTags() {
+ return axios
+ .get(this.refsProjectPath, {
+ params: {
+ search: this.searchTerm,
+ },
+ })
+ .then(({ data }) => {
+ this.branches = data.Branches || [];
+ this.tags = data.Tags || [];
+ })
+ .catch(() => {
+ createFlash({
+ message: s__(
+ 'CompareRevisions|There was an error while searching the branch/tag list. Please try again.',
+ ),
+ });
+ });
+ },
fetchBranchesAndTags(reset = false) {
- const endpoint = this.refsProjectPath;
this.loading = true;
if (reset) {
@@ -73,7 +88,7 @@ export default {
}
return axios
- .get(endpoint)
+ .get(this.refsProjectPath)
.then(({ data }) => {
this.branches = data.Branches || [];
this.tags = data.Tags || [];
@@ -90,7 +105,7 @@ export default {
});
},
getDefaultBranch() {
- return this.paramsBranch || emptyDropdownText;
+ return this.paramsBranch || EMPTY_DROPDOWN_TEXT;
},
onClick(revision) {
this.selectedRevision = revision;
@@ -119,24 +134,24 @@ export default {
@keyup.enter="onSearchEnter"
/>
</template>
- <gl-dropdown-section-header v-if="hasFilteredBranches">
+ <gl-dropdown-section-header v-if="hasBranches">
{{ s__('CompareRevisions|Branches') }}
</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(branch, index) in filteredBranches"
- :key="`branch${index}`"
+ v-for="branch in branches"
+ :key="branch"
is-check-item
:is-checked="selectedRevision === branch"
@click="onClick(branch)"
>
{{ branch }}
</gl-dropdown-item>
- <gl-dropdown-section-header v-if="hasFilteredTags">
+ <gl-dropdown-section-header v-if="hasTags">
{{ s__('CompareRevisions|Tags') }}
</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(tag, index) in filteredTags"
- :key="`tag${index}`"
+ v-for="tag in tags"
+ :key="tag"
is-check-item
:is-checked="selectedRevision === tag"
@click="onClick(tag)"
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
index 13d80b5ae0b..f57a8942a77 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -55,6 +55,11 @@ export default {
return this.filteredTags.length;
},
},
+ watch: {
+ paramsBranch(newBranch) {
+ this.setSelectedRevision(newBranch);
+ },
+ },
mounted() {
this.fetchBranchesAndTags();
},
@@ -83,10 +88,14 @@ export default {
return this.paramsBranch || s__('CompareRevisions|Select branch/tag');
},
onClick(revision) {
- this.selectedRevision = revision;
+ this.setSelectedRevision(revision);
},
onSearchEnter() {
- this.selectedRevision = this.searchTerm;
+ this.setSelectedRevision(this.searchTerm);
+ },
+ setSelectedRevision(revision) {
+ this.selectedRevision = revision || s__('CompareRevisions|Select branch/tag');
+ this.$emit('selectRevision', { direction: this.paramsName, revision });
},
},
};
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
index ef61fba88fe..1060b37067e 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
@@ -1,8 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { experiment } from '~/experimentation/utils';
import { __, s__ } from '~/locale';
-
+import { NEW_REPO_EXPERIMENT } from '../constants';
import blankProjectIllustration from '../illustrations/blank-project.svg';
import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg';
import createFromTemplateIllustration from '../illustrations/create-from-template.svg';
@@ -13,8 +14,10 @@ import WelcomePage from './welcome.vue';
const BLANK_PANEL = 'blank_project';
const CI_CD_PANEL = 'cicd_for_external_repo';
const LAST_ACTIVE_TAB_KEY = 'new_project_last_active_tab';
+
const PANELS = [
{
+ key: 'blank',
name: BLANK_PANEL,
selector: '#blank-project-pane',
title: s__('ProjectsNew|Create blank project'),
@@ -24,6 +27,7 @@ const PANELS = [
illustration: blankProjectIllustration,
},
{
+ key: 'template',
name: 'create_from_template',
selector: '#create-from-template-pane',
title: s__('ProjectsNew|Create from template'),
@@ -33,6 +37,7 @@ const PANELS = [
illustration: createFromTemplateIllustration,
},
{
+ key: 'import',
name: 'import_project',
selector: '#import-project-pane',
title: s__('ProjectsNew|Import project'),
@@ -42,6 +47,7 @@ const PANELS = [
illustration: importProjectIllustration,
},
{
+ key: 'ci',
name: CI_CD_PANEL,
selector: '#ci-cd-project-pane',
title: s__('ProjectsNew|Run CI/CD for external repository'),
@@ -85,16 +91,34 @@ export default {
},
computed: {
+ decoratedPanels() {
+ const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, {
+ use: () => ({
+ blank: s__('ProjectsNew|Create blank project'),
+ import: s__('ProjectsNew|Import project'),
+ }),
+ try: () => ({
+ blank: s__('ProjectsNew|Create blank project/repository'),
+ import: s__('ProjectsNew|Import project/repository'),
+ }),
+ });
+
+ return PANELS.map(({ key, title, ...el }) => ({
+ ...el,
+ title: PANEL_TITLES[key] !== undefined ? PANEL_TITLES[key] : title,
+ }));
+ },
+
availablePanels() {
if (this.isCiCdAvailable) {
- return PANELS;
+ return this.decoratedPanels;
}
- return PANELS.filter((p) => p.name !== CI_CD_PANEL);
+ return this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL);
},
activePanel() {
- return PANELS.find((p) => p.name === this.activeTab);
+ return this.decoratedPanels.find((p) => p.name === this.activeTab);
},
breadcrumbs() {
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
index ed82a635b1f..d342ce4c9c2 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
@@ -1,9 +1,10 @@
<script>
/* eslint-disable vue/no-v-html */
import Tracking from '~/tracking';
+import { NEW_REPO_EXPERIMENT } from '../constants';
import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
-const trackingMixin = Tracking.mixin(gon.tracking_data);
+const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: NEW_REPO_EXPERIMENT });
export default {
components: {
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js
new file mode 100644
index 00000000000..402ca887cf1
--- /dev/null
+++ b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js
@@ -0,0 +1 @@
+export const NEW_REPO_EXPERIMENT = 'new_repo';
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 4a8e1424fa8..8d005373508 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -3,8 +3,6 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import PipelineCharts from './pipeline_charts.vue';
-const charts = ['pipelines', 'deployments'];
-
export default {
components: {
GlTabs,
@@ -12,9 +10,11 @@ export default {
PipelineCharts,
DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
+ LeadTimeCharts: () =>
+ import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'),
},
inject: {
- shouldRenderDeploymentFrequencyCharts: {
+ shouldRenderDoraCharts: {
type: Boolean,
default: false,
},
@@ -24,20 +24,31 @@ export default {
selectedTab: 0,
};
},
+ computed: {
+ charts() {
+ const chartsToShow = ['pipelines'];
+
+ if (this.shouldRenderDoraCharts) {
+ chartsToShow.push('deployments', 'lead-time');
+ }
+
+ return chartsToShow;
+ },
+ },
created() {
this.selectTab();
window.addEventListener('popstate', this.selectTab);
},
methods: {
selectTab() {
- const [chart] = getParameterValues('chart') || charts;
- const tab = charts.indexOf(chart);
+ const [chart] = getParameterValues('chart') || this.charts;
+ const tab = this.charts.indexOf(chart);
this.selectedTab = tab >= 0 ? tab : 0;
},
onTabChange(index) {
if (index !== this.selectedTab) {
this.selectedTab = index;
- const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname);
+ const path = mergeUrlParams({ chart: this.charts[index] }, window.location.pathname);
updateHistory({ url: path, title: window.title });
}
},
@@ -46,13 +57,18 @@ export default {
</script>
<template>
<div>
- <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange">
+ <gl-tabs v-if="charts.length > 1" :value="selectedTab" @input="onTabChange">
<gl-tab :title="__('Pipelines')">
<pipeline-charts />
</gl-tab>
- <gl-tab :title="__('Deployments')">
- <deployment-frequency-charts />
- </gl-tab>
+ <template v-if="shouldRenderDoraCharts">
+ <gl-tab :title="__('Deployments')">
+ <deployment-frequency-charts />
+ </gl-tab>
+ <gl-tab :title="__('Lead Time')">
+ <lead-time-charts />
+ </gl-tab>
+ </template>
</gl-tabs>
<pipeline-charts v-else />
</div>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
index 3590e2c4632..ad3e6713e45 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
@@ -30,12 +30,16 @@ export default {
<resizable-chart-container>
<gl-area-chart
slot-scope="{ width }"
+ v-bind="$attrs"
:width="width"
:height="$options.chartContainerHeight"
:data="chartData"
:include-legend-avg-max="false"
:option="areaChartOptions"
- />
+ >
+ <slot slot="tooltip-title" name="tooltip-title"></slot>
+ <slot slot="tooltip-content" name="tooltip-content"></slot>
+ </gl-area-chart>
</resizable-chart-container>
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
index 43b36da8b2c..f4fd57e4cdc 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
@@ -41,10 +41,14 @@ export default {
<gl-segmented-control v-model="selectedChart" :options="chartRanges" class="gl-mb-4" />
<ci-cd-analytics-area-chart
v-if="chart"
+ v-bind="$attrs"
:chart-data="chart.data"
:area-chart-options="chartOptions"
>
{{ dateRange }}
+
+ <slot slot="tooltip-title" name="tooltip-title"></slot>
+ <slot slot="tooltip-content" name="tooltip-content"></slot>
</ci-cd-analytics-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 7e746423b6a..5f5ee44c204 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -13,9 +13,7 @@ const apolloProvider = new VueApollo({
const mountPipelineChartsApp = (el) => {
const { projectPath } = el.dataset;
- const shouldRenderDeploymentFrequencyCharts = parseBoolean(
- el.dataset.shouldRenderDeploymentFrequencyCharts,
- );
+ const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
return new Vue({
el,
@@ -26,7 +24,7 @@ const mountPipelineChartsApp = (el) => {
apolloProvider,
provide: {
projectPath,
- shouldRenderDeploymentFrequencyCharts,
+ shouldRenderDoraCharts,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 625d491db6a..589b88d7bbe 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -11,6 +11,8 @@ import {
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import createFlash from '~/flash';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
import Tracking from '~/tracking';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import DeleteImage from '../components/delete_image.vue';
@@ -81,6 +83,9 @@ export default {
searchConfig: SORT_FIELDS,
apollo: {
baseImages: {
+ skip() {
+ return !this.fetchBaseQuery;
+ },
query: getContainerRepositoriesQuery,
variables() {
return this.queryVariables;
@@ -124,15 +129,19 @@ export default {
sorting: { orderBy: 'UPDATED', sort: 'desc' },
name: null,
mutationLoading: false,
+ fetchBaseQuery: false,
fetchAdditionalDetails: false,
};
},
computed: {
images() {
- return this.baseImages.map((image, index) => ({
- ...image,
- ...get(this.additionalDetails, index, {}),
- }));
+ if (this.baseImages) {
+ return this.baseImages.map((image, index) => ({
+ ...image,
+ ...get(this.additionalDetails, index, {}),
+ }));
+ }
+ return [];
},
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
@@ -171,8 +180,15 @@ export default {
},
},
mounted() {
+ const { sorting, filters } = extractFilterAndSorting(this.$route.query);
+
+ this.filter = [...filters];
+ this.name = filters[0]?.value.data;
+ this.sorting = { ...this.sorting, ...sorting };
+
// If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart
+ this.fetchBaseQuery = true;
setTimeout(() => {
this.fetchAdditionalDetails = true;
}, 200);
@@ -241,9 +257,12 @@ export default {
};
},
doFilter() {
- const search = this.filter.find((i) => i.type === 'filtered-search-term');
+ const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
this.name = search?.value?.data;
},
+ updateUrlQueryString(query) {
+ this.$router.push({ query });
+ },
},
};
</script>
@@ -303,6 +322,7 @@ export default {
@sorting:changed="updateSorting"
@filter:changed="filter = $event"
@filter:submit="doFilter"
+ @query:changed="updateUrlQueryString"
/>
<div v-if="isLoading" class="gl-mt-5">
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index eb731c382e1..1360e09a75d 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -110,12 +110,12 @@ export default {
mutationVariables() {
return {
projectPath: this.projectPath,
- enabled: this.value.enabled,
- cadence: this.value.cadence,
- olderThan: this.value.olderThan,
- keepN: this.value.keepN,
- nameRegex: this.value.nameRegex,
- nameRegexKeep: this.value.nameRegexKeep,
+ enabled: this.prefilledForm.enabled,
+ cadence: this.prefilledForm.cadence,
+ olderThan: this.prefilledForm.olderThan,
+ keepN: this.prefilledForm.keepN,
+ nameRegex: this.prefilledForm.nameRegex,
+ nameRegexKeep: this.prefilledForm.nameRegexKeep,
};
},
},
@@ -291,8 +291,8 @@ export default {
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
- variant="success"
category="primary"
+ variant="confirm"
class="js-no-auto-disable gl-mr-4"
>
{{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index a8c7b7c857a..aecd0d6371e 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -22,7 +22,7 @@ export default {
TagField,
},
computed: {
- ...mapState('detail', [
+ ...mapState('editNew', [
'isFetchingRelease',
'isUpdatingRelease',
'fetchError',
@@ -36,13 +36,13 @@ export default {
'groupId',
'groupMilestonesAvailable',
]),
- ...mapGetters('detail', ['isValid', 'isExistingRelease']),
+ ...mapGetters('editNew', ['isValid', 'isExistingRelease']),
showForm() {
return Boolean(!this.isFetchingRelease && !this.fetchError && this.release);
},
releaseTitle: {
get() {
- return this.$store.state.detail.release.name;
+ return this.$store.state.editNew.release.name;
},
set(title) {
this.updateReleaseTitle(title);
@@ -50,7 +50,7 @@ export default {
},
releaseNotes: {
get() {
- return this.$store.state.detail.release.description;
+ return this.$store.state.editNew.release.description;
},
set(notes) {
this.updateReleaseNotes(notes);
@@ -58,7 +58,7 @@ export default {
},
releaseMilestones: {
get() {
- return this.$store.state.detail.release.milestones;
+ return this.$store.state.editNew.release.milestones;
},
set(milestones) {
this.updateReleaseMilestones(milestones);
@@ -93,7 +93,7 @@ export default {
this.$el.querySelector('input:enabled, button:enabled').focus();
},
methods: {
- ...mapActions('detail', [
+ ...mapActions('editNew', [
'initializeRelease',
'saveRelease',
'updateReleaseTitle',
@@ -114,7 +114,7 @@ export default {
<gl-sprintf
:message="
__(
- 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.',
+ 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.',
)
"
>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 32183e454c8..262b5614d65 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -20,7 +20,7 @@ export default {
ReleasesSort,
},
computed: {
- ...mapState('list', [
+ ...mapState('index', [
'documentationPath',
'illustrationPath',
'newReleasePath',
@@ -46,7 +46,7 @@ export default {
window.addEventListener('popstate', this.fetchReleases);
},
methods: {
- ...mapActions('list', {
+ ...mapActions('index', {
fetchReleasesStoreAction: 'fetchReleases',
}),
fetchReleases() {
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index 9ef38503c10..c38e93d420b 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,5 +1,8 @@
<script>
-import { mapState, mapActions } from 'vuex';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import oneReleaseQuery from '../queries/one_release.query.graphql';
+import { convertGraphQLRelease } from '../util';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
@@ -9,21 +12,58 @@ export default {
ReleaseBlock,
ReleaseSkeletonLoader,
},
- computed: {
- ...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']),
+ inject: {
+ fullPath: {
+ default: '',
+ },
+ tagName: {
+ default: '',
+ },
},
- created() {
- this.fetchRelease();
+ apollo: {
+ release: {
+ query: oneReleaseQuery,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ tagName: this.tagName,
+ };
+ },
+ update(data) {
+ if (data.project?.release) {
+ return convertGraphQLRelease(data.project.release);
+ }
+
+ return null;
+ },
+ result(result) {
+ // Handle the case where the query succeeded but didn't return any data
+ if (!result.error && !this.release) {
+ this.showFlash(
+ new Error(`No release found in project "${this.fullPath}" with tag "${this.tagName}"`),
+ );
+ }
+ },
+ error(error) {
+ this.showFlash(error);
+ },
+ },
},
methods: {
- ...mapActions('detail', ['fetchRelease']),
+ showFlash(error) {
+ createFlash({
+ message: s__('Release|Something went wrong while getting the release details.'),
+ captureError: true,
+ error,
+ });
+ },
},
};
</script>
<template>
<div class="gl-mt-3">
- <release-skeleton-loader v-if="isFetchingRelease" />
+ <release-skeleton-loader v-if="$apollo.queries.release.loading" />
- <release-block v-else-if="!fetchError" :release="release" />
+ <release-block v-else-if="release" :release="release" />
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index cfcb9f6978d..b9601428850 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -26,14 +26,14 @@ export default {
},
directives: { GlTooltip: GlTooltipDirective },
computed: {
- ...mapState('detail', ['release', 'releaseAssetsDocsPath']),
- ...mapGetters('detail', ['validationErrors']),
+ ...mapState('editNew', ['release', 'releaseAssetsDocsPath']),
+ ...mapGetters('editNew', ['validationErrors']),
},
created() {
this.ensureAtLeastOneLink();
},
methods: {
- ...mapActions('detail', [
+ ...mapActions('editNew', [
'addEmptyAssetLink',
'updateAssetLinkUrl',
'updateAssetLinkName',
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 356fc0f3bf3..89bc314db89 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -1,9 +1,13 @@
<script>
import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui';
import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
import { BACK_URL_PARAM } from '~/releases/constants';
export default {
+ i18n: {
+ editButton: __('Edit this release'),
+ },
name: 'ReleaseBlockHeader',
components: {
GlLink,
@@ -69,7 +73,8 @@ export default {
variant="default"
icon="pencil"
class="gl-mr-3 js-edit-button ml-2 pb-2"
- :title="__('Edit this release')"
+ :title="$options.i18n.editButton"
+ :aria-label="$options.i18n.editButton"
:href="editLink"
/>
</div>
diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
index cf4a6e07af7..de10b210ecd 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -179,7 +179,7 @@ export default {
/>
<issuable-stats
v-if="showMergeRequestStats"
- :label="__('Merge Requests')"
+ :label="__('Merge requests')"
:total="mergeRequestCounts.total"
:merged="mergeRequestCounts.merged"
:closed="mergeRequestCounts.closed"
diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
index 7d024c47fb9..13cbf95b9af 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
@@ -7,13 +7,13 @@ export default {
name: 'ReleasesPaginationGraphql',
components: { GlKeysetPagination },
computed: {
- ...mapState('list', ['graphQlPageInfo']),
+ ...mapState('index', ['graphQlPageInfo']),
showPagination() {
return this.graphQlPageInfo.hasPreviousPage || this.graphQlPageInfo.hasNextPage;
},
},
methods: {
- ...mapActions('list', ['fetchReleases']),
+ ...mapActions('index', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
this.fetchReleases({ before });
diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
index 24abb0f4498..5e97a5a0450 100644
--- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
@@ -7,10 +7,10 @@ export default {
name: 'ReleasesPaginationRest',
components: { TablePagination },
computed: {
- ...mapState('list', ['restPageInfo']),
+ ...mapState('index', ['restPageInfo']),
},
methods: {
- ...mapActions('list', ['fetchReleases']),
+ ...mapActions('index', ['fetchReleases']),
onChangePage(page) {
historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
this.fetchReleases({ page });
diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue
index c8e6e0e4996..4988904a2cd 100644
--- a/app/assets/javascripts/releases/components/releases_sort.vue
+++ b/app/assets/javascripts/releases/components/releases_sort.vue
@@ -10,7 +10,7 @@ export default {
GlSortingItem,
},
computed: {
- ...mapState('list', {
+ ...mapState('index', {
orderBy: (state) => state.sorting.orderBy,
sort: (state) => state.sorting.sort,
}),
@@ -26,7 +26,7 @@ export default {
},
},
methods: {
- ...mapActions('list', ['setSorting']),
+ ...mapActions('index', ['setSorting']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
this.setSorting({ sort });
diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue
index ed8d6e62926..f4c0fd5e9ce 100644
--- a/app/assets/javascripts/releases/components/tag_field.vue
+++ b/app/assets/javascripts/releases/components/tag_field.vue
@@ -9,7 +9,7 @@ export default {
TagFieldNew,
},
computed: {
- ...mapGetters('detail', ['isExistingRelease']),
+ ...mapGetters('editNew', ['isExistingRelease']),
},
};
</script>
diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue
index 3345bbecf6e..11945fbaf3d 100644
--- a/app/assets/javascripts/releases/components/tag_field_existing.vue
+++ b/app/assets/javascripts/releases/components/tag_field_existing.vue
@@ -8,7 +8,7 @@ export default {
name: 'TagFieldExisting',
components: { GlFormGroup, GlFormInput, FormFieldContainer },
computed: {
- ...mapState('detail', ['release']),
+ ...mapState('editNew', ['release']),
inputId() {
return uniqueId('tag-name-input-');
},
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 21360a5c6cb..9df646ca798 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -27,8 +27,8 @@ export default {
};
},
computed: {
- ...mapState('detail', ['projectId', 'release', 'createFrom']),
- ...mapGetters('detail', ['validationErrors']),
+ ...mapState('editNew', ['projectId', 'release', 'createFrom']),
+ ...mapGetters('editNew', ['validationErrors']),
tagName: {
get() {
return this.release.tagName;
@@ -62,7 +62,7 @@ export default {
},
},
methods: {
- ...mapActions('detail', ['updateReleaseTagName', 'updateCreateFrom']),
+ ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom']),
markInputAsDirty() {
this.isInputDirty = true;
},
diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js
index 1232d55847b..fad0451ceef 100644
--- a/app/assets/javascripts/releases/mount_edit.js
+++ b/app/assets/javascripts/releases/mount_edit.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
-import createDetailModule from './stores/modules/detail';
+import createEditNewModule from './stores/modules/edit_new';
Vue.use(Vuex);
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- detail: createDetailModule(el.dataset),
+ editNew: createEditNewModule(el.dataset),
},
});
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index a9538cbc9e5..0b453467c13 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import ReleaseListApp from './components/app_index.vue';
+import ReleaseIndexApp from './components/app_index.vue';
import createStore from './stores';
-import createListModule from './stores/modules/list';
+import createIndexModule from './stores/modules/index';
Vue.use(Vuex);
@@ -13,7 +13,7 @@ export default () => {
el,
store: createStore({
modules: {
- list: createListModule(el.dataset),
+ index: createIndexModule(el.dataset),
},
featureFlags: {
graphqlReleaseData: Boolean(gon.features?.graphqlReleaseData),
@@ -21,6 +21,6 @@ export default () => {
graphqlMilestoneStats: Boolean(gon.features?.graphqlMilestoneStats),
},
}),
- render: (h) => h(ReleaseListApp),
+ render: (h) => h(ReleaseIndexApp),
});
};
diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js
index d85f4cf77d5..b358a27f06d 100644
--- a/app/assets/javascripts/releases/mount_new.js
+++ b/app/assets/javascripts/releases/mount_new.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import ReleaseEditNewApp from './components/app_edit_new.vue';
import createStore from './stores';
-import createDetailModule from './stores/modules/detail';
+import createEditNewModule from './stores/modules/edit_new';
Vue.use(Vuex);
@@ -11,7 +11,7 @@ export default () => {
const store = createStore({
modules: {
- detail: createDetailModule(el.dataset),
+ editNew: createEditNewModule(el.dataset),
},
});
diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js
index f3ed7d6c5ff..7272880197a 100644
--- a/app/assets/javascripts/releases/mount_show.js
+++ b/app/assets/javascripts/releases/mount_show.js
@@ -1,26 +1,28 @@
import Vue from 'vue';
-import Vuex from 'vuex';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import ReleaseShowApp from './components/app_show.vue';
-import createStore from './stores';
-import createDetailModule from './stores/modules/detail';
-Vue.use(Vuex);
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
export default () => {
const el = document.getElementById('js-show-release-page');
- const store = createStore({
- modules: {
- detail: createDetailModule(el.dataset),
- },
- featureFlags: {
- graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage),
- },
- });
+ if (!el) return false;
+
+ const { projectPath, tagName } = el.dataset;
return new Vue({
el,
- store,
+ apolloProvider,
+ provide: {
+ fullPath: projectPath,
+ tagName,
+ },
render: (h) => h(ReleaseShowApp),
});
};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 5fa002706c6..8dc2083dd2b 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -43,7 +43,7 @@ export const fetchRelease = ({ commit, state, rootState }) => {
})
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details'));
+ createFlash(s__('Release|Something went wrong while getting the release details.'));
});
}
@@ -54,7 +54,7 @@ export const fetchRelease = ({ commit, state, rootState }) => {
})
.catch((error) => {
commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details'));
+ createFlash(s__('Release|Something went wrong while getting the release details.'));
});
};
diff --git a/app/assets/javascripts/releases/stores/modules/detail/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 831037c8861..831037c8861 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/index.js b/app/assets/javascripts/releases/stores/modules/edit_new/index.js
index e1b7e69accc..e1b7e69accc 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/index.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/index.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
index 1b2f5f33f02..1b2f5f33f02 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index cf282f9ab2c..cf282f9ab2c 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index 315d07ac664..315d07ac664 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js
index f1add54626a..f1add54626a 100644
--- a/app/assets/javascripts/releases/stores/modules/list/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/index/actions.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js
index d5ca191153a..d5ca191153a 100644
--- a/app/assets/javascripts/releases/stores/modules/list/index.js
+++ b/app/assets/javascripts/releases/stores/modules/index/index.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
index 669168efb88..669168efb88 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js
index e1aaa2e2a19..e1aaa2e2a19 100644
--- a/app/assets/javascripts/releases/stores/modules/list/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/index/mutations.js
diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js
index 164a496d450..164a496d450 100644
--- a/app/assets/javascripts/releases/stores/modules/list/state.js
+++ b/app/assets/javascripts/releases/stores/modules/index/state.js
diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
index c272e3b1dc4..99cdeae545e 100644
--- a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
+++ b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
@@ -46,6 +46,7 @@ export default {
:loading-text="groupedSummaryText"
:error-text="groupedSummaryText"
:has-issues="shouldRenderIssuesList"
+ track-action="users_expanding_testing_accessibility_report"
class="mr-widget-section grouped-security-reports mr-report"
>
<template #body>
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 654508f0736..d293165ef2f 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
@@ -62,7 +62,7 @@ export default {
helpPath: this.codequalityHelpPath,
});
- this.fetchReports(this.glFeatures.codequalityBackendComparison);
+ this.fetchReports();
},
methods: {
...mapActions(['fetchReports', 'setPaths']),
@@ -87,6 +87,7 @@ export default {
:component="$options.componentNames.CodequalityIssueBody"
:popover-options="codequalityPopover"
:show-report-section-status-icon="false"
+ track-action="users_expanding_testing_code_quality_report"
class="js-codequality-widget mr-widget-border-top mr-report"
>
<template v-if="hasError" #sub-heading>{{ statusReason }}</template>
diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue
index 585127f901e..ca369022938 100644
--- a/app/assets/javascripts/reports/components/grouped_issues_list.vue
+++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue
@@ -66,8 +66,8 @@ export default {
},
listClasses() {
return {
- 'gl-pl-7': this.nestedLevel === 1,
- 'gl-pl-9': this.nestedLevel === 2,
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
};
},
},
diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue
index ea3f0d78d8c..9df0a1953b6 100644
--- a/app/assets/javascripts/reports/components/issues_list.vue
+++ b/app/assets/javascripts/reports/components/issues_list.vue
@@ -88,8 +88,8 @@ export default {
},
listClasses() {
return {
- 'gl-pl-7': this.nestedLevel === 1,
- 'gl-pl-8': this.nestedLevel === 2,
+ 'gl-pl-9': this.nestedLevel === 1,
+ 'gl-pl-11-5': this.nestedLevel === 2,
};
},
},
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index ff58cd20ca1..12b5cb9f207 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -1,17 +1,22 @@
<script>
+import { GlButton } from '@gitlab/ui';
+import api from '~/api';
import { __ } from '~/locale';
import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue';
import Popover from '~/vue_shared/components/help_popover.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
import IssuesList from './issues_list.vue';
export default {
name: 'ReportSection',
components: {
+ GlButton,
IssuesList,
- StatusIcon,
Popover,
+ StatusIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
alwaysOpen: {
type: Boolean,
@@ -96,6 +101,11 @@ export default {
required: false,
default: false,
},
+ trackAction: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
@@ -162,6 +172,10 @@ export default {
},
methods: {
toggleCollapsed() {
+ if (this.trackAction && this.glFeatures.usersExpandingWidgetsUsageData) {
+ api.trackRedisHllUserEvent(this.trackAction);
+ }
+
if (this.shouldEmitToggleEvent) {
this.$emit('toggleEvent');
}
@@ -186,16 +200,15 @@ export default {
<slot name="action-buttons" :is-collapsible="isCollapsible"></slot>
- <button
+ <gl-button
v-if="isCollapsible"
- type="button"
+ class="js-collapse-btn"
data-testid="report-section-expand-button"
- class="js-collapse-btn btn float-right btn-sm align-self-center"
data-qa-selector="expand_report_button"
@click="toggleCollapsed"
>
{{ collapseText }}
- </button>
+ </gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 8eb43bcf1ba..6b7d81c4878 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -51,7 +51,7 @@ export default {
if (!this.nestedSummary) {
return ['gl-px-5'];
}
- return ['gl-pl-7', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
+ return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }];
},
statusIconSize() {
if (!this.nestedSummary) {
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 9250bfd7678..acd90ebf1b1 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -1,5 +1,5 @@
export const fieldTypes = {
- codeBock: 'codeBlock',
+ codeBlock: 'codeBlock',
link: 'link',
seconds: 'seconds',
text: 'text',
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
index b0310fd003e..af93e5bc639 100644
--- a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue
@@ -25,6 +25,14 @@ export default {
required: true,
},
},
+ computed: {
+ filteredModalData() {
+ // Filter out the properties that don't have a value
+ return Object.fromEntries(
+ Object.entries(this.modalData).filter((data) => Boolean(data[1].value)),
+ );
+ },
+ },
fieldTypes,
};
</script>
@@ -36,23 +44,18 @@ export default {
:hide-footer="true"
@hide="$emit('hide')"
>
- <div
- v-for="(field, key, index) in modalData"
- v-if="field.value"
- :key="index"
- class="row gl-mt-3 gl-mb-3"
- >
+ <div v-for="(field, key, index) in filteredModalData" :key="index" class="row gl-mt-3 gl-mb-3">
<strong class="col-sm-3 text-right"> {{ field.text }}: </strong>
<div class="col-sm-9 text-secondary">
- <code-block v-if="field.type === $options.fieldTypes.codeBock" :code="field.value" />
+ <code-block v-if="field.type === $options.fieldTypes.codeBlock" :code="field.value" />
<gl-link
v-else-if="field.type === $options.fieldTypes.link"
- :href="field.value"
+ :href="field.value.path"
target="_blank"
>
- {{ field.value }}
+ {{ field.value.text }}
</gl-link>
<gl-sprintf
diff --git a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
index 522245a442d..8913046d62f 100644
--- a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue
@@ -24,7 +24,7 @@ export default {
n__(
'Reports|Failed %{count} time in %{base_branch} in the last 14 days',
'Reports|Failed %{count} times in %{base_branch} in the last 14 days',
- this.issue.recent_failures.count,
+ this.issue.recent_failures?.count,
),
this.issue.recent_failures,
);
@@ -44,20 +44,20 @@ export default {
<template>
<div class="gl-display-flex gl-mt-2 gl-mb-2">
<issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" />
- <gl-badge
- v-if="showRecentFailures"
- variant="warning"
- class="gl-mr-2"
- data-testid="test-issue-body-recent-failures"
- >
- {{ recentFailureMessage }}
- </gl-badge>
<gl-button
button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left"
variant="link"
data-testid="test-issue-body-description"
@click="openModal({ issue })"
>
+ <gl-badge
+ v-if="showRecentFailures"
+ variant="warning"
+ class="gl-mr-2"
+ data-testid="test-issue-body-recent-failures"
+ >
+ {{ recentFailureMessage }}
+ </gl-badge>
{{ issue.name }}
</gl-button>
</div>
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 b863e55ae94..82806793401 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
@@ -1,9 +1,9 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
-import { once } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
+import api from '~/api';
import { sprintf, s__ } from '~/locale';
-import Tracking from '~/tracking';
+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 +28,7 @@ export default {
GlButton,
GlIcon,
},
- mixins: [Tracking.mixin()],
+ mixins: [glFeatureFlagsMixin()],
props: {
endpoint: {
type: String,
@@ -39,6 +39,10 @@ export default {
required: false,
default: '',
},
+ headBlobPath: {
+ type: String,
+ required: true,
+ },
},
componentNames,
computed: {
@@ -66,19 +70,22 @@ export default {
showViewFullReport() {
return this.pipelinePath.length;
},
- handleToggleEvent() {
- return once(() => {
- this.track(this.$options.expandEvent);
- });
- },
},
created() {
- this.setEndpoint(this.endpoint);
+ this.setPaths({
+ endpoint: this.endpoint,
+ headBlobPath: this.headBlobPath,
+ });
this.fetchReports();
},
methods: {
- ...mapActions(['setEndpoint', 'fetchReports', 'closeModal']),
+ ...mapActions(['setPaths', 'fetchReports', 'closeModal']),
+ handleToggleEvent() {
+ if (this.glFeatures.usageDataITestingSummaryWidgetTotal) {
+ api.trackRedisHllUserEvent(this.$options.expandEvent);
+ }
+ },
reportText(report) {
const { name, summary } = report || {};
@@ -123,7 +130,7 @@ export default {
return report.resolved_failures.concat(report.resolved_errors);
},
},
- expandEvent: 'expand_test_report_widget',
+ expandEvent: 'i_testing_summary_widget_total',
};
</script>
<template>
@@ -135,7 +142,7 @@ export default {
:has-issues="reports.length > 0"
:should-emit-toggle-event="true"
class="mr-widget-section grouped-security-reports mr-report"
- @toggleEvent="handleToggleEvent"
+ @toggleEvent.once="handleToggleEvent"
>
<template v-if="showViewFullReport" #action-buttons>
<gl-button
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
index ebc8c735b03..e3db57ad846 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/actions.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js
@@ -4,7 +4,7 @@ import httpStatusCodes from '../../../lib/utils/http_status';
import Poll from '../../../lib/utils/poll';
import * as types from './mutation_types';
-export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
export const requestReports = ({ commit }) => commit(types.REQUEST_REPORTS);
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
index 337085f9bf0..ff839c564b6 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js
@@ -1,4 +1,4 @@
-export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_PATHS = 'SET_PATHS';
export const REQUEST_REPORTS = 'REQUEST_REPORTS';
export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS';
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
index 3bb31d71d8f..2b88776815b 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js
@@ -1,9 +1,10 @@
import * as types from './mutation_types';
-import { countRecentlyFailedTests } from './utils';
+import { countRecentlyFailedTests, formatFilePath } from './utils';
export default {
- [types.SET_ENDPOINT](state, endpoint) {
+ [types.SET_PATHS](state, { endpoint, headBlobPath }) {
state.endpoint = endpoint;
+ state.headBlobPath = headBlobPath;
},
[types.REQUEST_REPORTS](state) {
state.isLoading = true;
@@ -42,17 +43,25 @@ export default {
state.status = null;
},
[types.SET_ISSUE_MODAL_DATA](state, payload) {
- state.modal.title = payload.issue.name;
+ const { issue } = payload;
+ state.modal.title = issue.name;
- Object.keys(payload.issue).forEach((key) => {
+ Object.keys(issue).forEach((key) => {
if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) {
state.modal.data[key] = {
...state.modal.data[key],
- value: payload.issue[key],
+ value: issue[key],
};
}
});
+ if (issue.file) {
+ state.modal.data.filename.value = {
+ text: issue.file,
+ path: `${state.headBlobPath}/${formatFilePath(issue.file)}`,
+ };
+ }
+
state.modal.open = true;
},
[types.RESET_ISSUE_MODAL_DATA](state) {
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/state.js b/app/assets/javascripts/reports/grouped_test_report/store/state.js
index dd55c7abab4..46909bde337 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/state.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/state.js
@@ -41,16 +41,16 @@ export default () => ({
open: false,
data: {
- class: {
- value: null,
- text: s__('Reports|Class'),
- type: fieldTypes.link,
- },
classname: {
value: null,
text: s__('Reports|Classname'),
type: fieldTypes.text,
},
+ filename: {
+ value: null,
+ text: s__('Reports|Filename'),
+ type: fieldTypes.link,
+ },
execution_time: {
value: null,
text: s__('Reports|Execution time'),
@@ -59,12 +59,12 @@ export default () => ({
failure: {
value: null,
text: s__('Reports|Failure'),
- type: fieldTypes.codeBock,
+ type: fieldTypes.codeBlock,
},
system_output: {
value: null,
text: s__('Reports|System output'),
- type: fieldTypes.codeBock,
+ type: fieldTypes.codeBlock,
},
},
},
diff --git a/app/assets/javascripts/reports/grouped_test_report/store/utils.js b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
index 189b87bfa8d..df5dd73b66c 100644
--- a/app/assets/javascripts/reports/grouped_test_report/store/utils.js
+++ b/app/assets/javascripts/reports/grouped_test_report/store/utils.js
@@ -100,3 +100,12 @@ export const statusIcon = (status) => {
return ICON_NOTFOUND;
};
+
+/**
+ * Removes `./` from the beginning of a file path so it can be appended onto a blob path
+ * @param {String} file
+ * @returns {String} - formatted value
+ */
+export const formatFilePath = (file) => {
+ return file.replace(/^\.?\/*/, '');
+};
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
new file mode 100644
index 00000000000..58b42fb7859
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -0,0 +1,100 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import BlobContent from '~/blob/components/blob_content.vue';
+import BlobHeader from '~/blob/components/blob_header.vue';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import blobInfoQuery from '../queries/blob_info.query.graphql';
+import projectPathQuery from '../queries/project_path.query.graphql';
+
+export default {
+ components: {
+ BlobHeader,
+ BlobContent,
+ GlLoadingIcon,
+ },
+ apollo: {
+ projectPath: {
+ query: projectPathQuery,
+ },
+ blobInfo: {
+ query: blobInfoQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ filePath: this.path,
+ };
+ },
+ error() {
+ createFlash({ message: __('An error occurred while loading the file. Please try again.') });
+ },
+ },
+ },
+ provide() {
+ return {
+ blobHash: uniqueId(),
+ };
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ blobInfo: {
+ name: '',
+ size: '',
+ rawBlob: '',
+ type: '',
+ fileType: '',
+ tooLarge: false,
+ path: '',
+ editBlobPath: '',
+ ideEditPath: '',
+ storedExternally: false,
+ rawPath: '',
+ externalStorageUrl: '',
+ replacePath: '',
+ deletePath: '',
+ canLock: false,
+ isLocked: false,
+ lockLink: '',
+ canModifyBlob: true,
+ forkPath: '',
+ simpleViewer: '',
+ richViewer: '',
+ },
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.blobInfo.loading;
+ },
+ viewer() {
+ const { fileType, tooLarge, type } = this.blobInfo;
+
+ return { fileType, tooLarge, type };
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="isLoading" />
+ <div v-if="blobInfo && !isLoading">
+ <blob-header :blob="blobInfo" />
+ <blob-content
+ :blob="blobInfo"
+ :content="blobInfo.rawBlob"
+ :is-raw-content="true"
+ :active-viewer="viewer"
+ :loading="false"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 28d7dec85f4..0b8408643ac 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -5,6 +5,7 @@ import {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
+ GlModalDirective,
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
@@ -12,12 +13,15 @@ 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';
const ROW_TYPES = {
header: 'header',
divider: 'divider',
};
+const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
+
export default {
components: {
GlDropdown,
@@ -25,6 +29,7 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
GlIcon,
+ UploadBlobModal,
},
apollo: {
projectShortPath: {
@@ -46,6 +51,9 @@ export default {
},
},
},
+ directives: {
+ GlModal: GlModalDirective,
+ },
mixins: [getRefMixin],
props: {
currentPath: {
@@ -63,6 +71,21 @@ export default {
required: false,
default: false,
},
+ canPushCode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ originalBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
newBranchPath: {
type: String,
required: false,
@@ -93,7 +116,13 @@ export default {
required: false,
default: null,
},
+ uploadPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
+ uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
data() {
return {
projectShortPath: '',
@@ -126,7 +155,10 @@ export default {
);
},
canCreateMrFromFork() {
- return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn;
+ return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn;
+ },
+ showUploadModal() {
+ return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
dropdownItems() {
const items = [];
@@ -149,10 +181,9 @@ export default {
{
attrs: {
href: '#modal-upload-blob',
- 'data-target': '#modal-upload-blob',
- 'data-toggle': 'modal',
},
text: __('Upload file'),
+ modalId: UPLOAD_BLOB_MODAL_ID,
},
{
attrs: {
@@ -253,12 +284,26 @@ export default {
<gl-icon name="chevron-down" :size="16" class="float-left" />
</template>
<template v-for="(item, i) in dropdownItems">
- <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
+ <component
+ :is="getComponent(item.type)"
+ :key="i"
+ v-bind="item.attrs"
+ v-gl-modal="item.modalId || null"
+ >
{{ item.text }}
</component>
</template>
</gl-dropdown>
</li>
</ol>
+ <upload-blob-modal
+ v-if="showUploadModal"
+ :modal-id="$options.uploadBlobModalId"
+ :commit-message="__('Upload New File')"
+ :target-branch="selectedBranch"
+ :original-branch="originalBranch"
+ :can-push-code="canPushCode"
+ :path="uploadPath"
+ />
</nav>
</template>
diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue
index 8c029fc9973..c222a83300d 100644
--- a/app/assets/javascripts/repository/components/directory_download_links.vue
+++ b/app/assets/javascripts/repository/components/directory_download_links.vue
@@ -1,9 +1,9 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
export default {
components: {
- GlLink,
+ GlButton,
},
props: {
currentPath: {
@@ -32,15 +32,15 @@ export default {
<h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5>
<div class="dropdown-menu-content">
<div class="btn-group ml-0 w-100">
- <gl-link
+ <gl-button
v-for="(link, index) in normalizedLinks"
:key="index"
:href="link.path"
- :class="{ 'btn-primary': index === 0 }"
- class="btn btn-xs"
+ :variant="index === 0 ? 'confirm' : 'default'"
+ size="small"
>
{{ link.text }}
- </gl-link>
+ </gl-button>
</div>
</div>
</section>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 70918dd55e4..8ea5fce92fa 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -12,6 +12,7 @@ import { escapeRegExp } from 'lodash';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../../mixins/get_ref';
import commitQuery from '../../queries/commit.query.graphql';
@@ -41,7 +42,7 @@ export default {
},
},
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagMixin()],
props: {
id: {
type: String,
@@ -103,10 +104,21 @@ export default {
};
},
computed: {
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
routerLinkTo() {
- return this.isFolder
- ? { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` }
- : null;
+ const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` };
+ const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` };
+
+ if (this.refactorBlobViewerEnabled && this.isBlob) {
+ return blobRouteConfig;
+ }
+
+ return this.isFolder ? treeRouteConfig : null;
+ },
+ isBlob() {
+ return this.type === 'blob';
},
isFolder() {
return this.type === 'tree';
@@ -115,7 +127,7 @@ export default {
return this.type === 'commit';
},
linkComponent() {
- return this.isFolder ? 'router-link' : 'a';
+ return this.isFolder || (this.refactorBlobViewerEnabled && this.isBlob) ? 'router-link' : 'a';
},
fullPath() {
return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), '');
diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue
deleted file mode 100644
index c5ab150adaf..00000000000
--- a/app/assets/javascripts/repository/components/tree_action_link.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { GlLink } from '@gitlab/ui';
-
-export default {
- components: {
- GlLink,
- },
- props: {
- path: {
- type: String,
- required: true,
- },
- text: {
- type: String,
- required: true,
- },
- cssClass: {
- type: String,
- required: false,
- default: null,
- },
- },
-};
-</script>
-
-<template>
- <gl-link :href="path" :class="cssClass" class="btn gl-button">{{ text }}</gl-link>
-</template>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index ec7ba469ca0..d2ff01e7fc1 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -168,6 +168,7 @@ export default {
});
},
},
+ validFileMimetypes: [],
};
</script>
<template>
@@ -179,7 +180,12 @@ export default {
:action-cancel="cancelOptions"
@primary.prevent="uploadFile"
>
- <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile">
+ <upload-dropzone
+ class="gl-h-200! gl-mb-4"
+ single-file-selection
+ :valid-file-mimetypes="$options.validFileMimetypes"
+ @change="setFile"
+ >
<div
v-if="file"
class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index e6969b7c8b2..3a9a2adb417 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -1,14 +1,18 @@
import { GlButton } from '@gitlab/ui';
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { escapeFileUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { escapeFileUrl } from '../lib/utils/url_utility';
-import { __ } from '../locale';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
import LastCommit from './components/last_commit.vue';
import apolloProvider from './graphql';
+import commitsQuery from './queries/commits.query.graphql';
+import projectPathQuery from './queries/project_path.query.graphql';
+import projectShortPathQuery from './queries/project_short_path.query.graphql';
+import refsQuery from './queries/ref.query.graphql';
import createRouter from './router';
import { updateFormAction } from './utils/dom';
import { setTitle } from './utils/title';
@@ -19,13 +23,32 @@ export default function setupVueRepositoryList() {
const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
const router = createRouter(projectPath, escapedRef);
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: commitsQuery,
+ data: {
+ commits: [],
+ },
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: projectPathQuery,
data: {
projectPath,
+ },
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: projectShortPathQuery,
+ data: {
projectShortPath,
+ },
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: refsQuery,
+ data: {
ref,
escapedRef,
- commits: [],
},
});
@@ -55,6 +78,8 @@ export default function setupVueRepositoryList() {
const {
canCollaborate,
canEditTree,
+ canPushCode,
+ selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
@@ -65,8 +90,7 @@ export default function setupVueRepositoryList() {
newDirPath,
} = breadcrumbEl.dataset;
- router.afterEach(({ params: { path = '/' } }) => {
- updateFormAction('.js-upload-blob-form', uploadPath, path);
+ router.afterEach(({ params: { path } }) => {
updateFormAction('.js-create-dir-form', newDirPath, path);
});
@@ -81,12 +105,16 @@ export default function setupVueRepositoryList() {
currentPath: this.$route.params.path,
canCollaborate: parseBoolean(canCollaborate),
canEditTree: parseBoolean(canEditTree),
+ canPushCode: parseBoolean(canPushCode),
+ originalBranch: ref,
+ selectedBranch,
newBranchPath,
newTagPath,
newBlobPath,
forkNewBlobPath,
forkNewDirectoryPath,
forkUploadBlobPath,
+ uploadPath,
},
});
},
diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue
new file mode 100644
index 00000000000..27af398be09
--- /dev/null
+++ b/app/assets/javascripts/repository/pages/blob.vue
@@ -0,0 +1,22 @@
+<script>
+// This file is in progress and behind a feature flag, please see the following issue for more:
+// https://gitlab.com/gitlab-org/gitlab/-/issues/323200
+
+import BlobContentViewer from '../components/blob_content_viewer.vue';
+
+export default {
+ components: {
+ BlobContentViewer,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <blob-content-viewer :path="path" />
+</template>
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
new file mode 100644
index 00000000000..e0bbf12f3eb
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -0,0 +1,30 @@
+query getBlobInfo($projectPath: ID!, $filePath: String!) {
+ project(fullPath: $projectPath) {
+ id
+ repository {
+ blobs(path: $filePath) {
+ name
+ size
+ rawBlob
+ type
+ fileType
+ tooLarge
+ path
+ editBlobPath
+ ideEditPath
+ storedExternally
+ rawPath
+ externalStorageUrl
+ replacePath
+ deletePath
+ canLock
+ isLocked
+ lockLink
+ canModifyBlob
+ forkPath
+ simpleViewer
+ richViewer
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index ad6e32d7055..c7f7451fb55 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -2,6 +2,7 @@ import { escapeRegExp } from 'lodash';
import Vue from 'vue';
import VueRouter from 'vue-router';
import { joinPaths } from '../lib/utils/url_utility';
+import BlobPage from './pages/blob.vue';
import IndexPage from './pages/index.vue';
import TreePage from './pages/tree.vue';
@@ -15,6 +16,13 @@ export default function createRouter(base, baseRef) {
}),
};
+ const blobPathRoute = {
+ component: BlobPage,
+ props: (route) => ({
+ path: route.params.path,
+ }),
+ };
+
return new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
@@ -32,6 +40,18 @@ export default function createRouter(base, baseRef) {
...treePathRoute,
},
{
+ name: 'blobPathDecoded',
+ // Sometimes the ref needs decoding depending on how the backend sends it to us
+ path: `(/-)?/blob/${decodeURI(baseRef)}/:path*`,
+ ...blobPathRoute,
+ },
+ {
+ name: 'blobPath',
+ // Support without decoding as well just in case the ref doesn't need to be decoded
+ path: `(/-)?/blob/${escapeRegExp(baseRef)}/:path*`,
+ ...blobPathRoute,
+ },
+ {
path: '/',
name: 'projectRoot',
component: IndexPage,
diff --git a/app/assets/javascripts/runner/runner_details/constants.js b/app/assets/javascripts/runner/runner_details/constants.js
new file mode 100644
index 00000000000..bb57e85fa8a
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/constants.js
@@ -0,0 +1,3 @@
+import { s__ } from '~/locale';
+
+export const I18N_TITLE = s__('Runners|Runner #%{runner_id}');
diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/runner_details/index.js
new file mode 100644
index 00000000000..cbf70640ef7
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import RunnerDetailsApp from './runner_details_app.vue';
+
+export const initRunnerDetail = (selector = '#js-runner-detail') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId } = el.dataset;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(RunnerDetailsApp, {
+ props: {
+ runnerId,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
new file mode 100644
index 00000000000..1b1485bfe72
--- /dev/null
+++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
@@ -0,0 +1,20 @@
+<script>
+import { I18N_TITLE } from './constants';
+
+export default {
+ i18n: {
+ I18N_TITLE,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <h2 class="page-title">
+ {{ sprintf($options.i18n.I18N_TITLE, { runner_id: runnerId }) }}
+ </h2>
+</template>
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index 4640259314b..99cf16c8350 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -32,7 +32,9 @@ export default {
<status-filter />
<confidentiality-filter />
<div class="gl-display-flex gl-align-items-center gl-mt-3">
- <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button>
+ <gl-button category="primary" variant="confirm" size="small" type="submit">
+ {{ __('Apply') }}
+ </gl-button>
<gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
__('Reset filters')
}}</gl-link>
diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue
index e4eba655e39..2bf144705c4 100644
--- a/app/assets/javascripts/search/sort/components/app.vue
+++ b/app/assets/javascripts/search/sort/components/app.vue
@@ -96,6 +96,7 @@ export default {
v-gl-tooltip
:disabled="!selectedSortOption.sortable"
:title="sortDirectionData.tooltip"
+ :aria-label="sortDirectionData.tooltip"
:icon="sortDirectionData.icon"
@click="handleSortDirectionChange"
/>
diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue
index 987735ed811..2439ab55923 100644
--- a/app/assets/javascripts/search/topbar/components/app.vue
+++ b/app/assets/javascripts/search/topbar/components/app.vue
@@ -65,9 +65,9 @@ export default {
<label class="gl-display-block">{{ __('Project') }}</label>
<project-filter :initial-data="projectInitialData" />
</div>
- <gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{
- __('Search')
- }}</gl-button>
+ <gl-button class="btn-search gl-lg-ml-2" category="primary" variant="confirm" type="submit"
+ >{{ __('Search') }}
+ </gl-button>
</section>
</gl-form>
</template>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
index 5fb7217db74..d16850cd889 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -9,10 +9,13 @@ import {
GlSkeletonLoader,
GlTooltipDirective,
} from '@gitlab/ui';
-
+import { __ } from '~/locale';
import { ANY_OPTION } from '../constants';
export default {
+ i18n: {
+ clearLabel: __('Clear'),
+ },
name: 'SearchableDropdown',
components: {
GlDropdown,
@@ -96,7 +99,8 @@ export default {
v-gl-tooltip
name="clear"
category="tertiary"
- :title="__('Clear')"
+ :title="$options.i18n.clearLabel"
+ :aria-label="$options.i18n.clearLabel"
class="gl-p-0! gl-mr-2"
@keydown.enter.stop="resetDropdown"
@click.stop="resetDropdown"
diff --git a/app/assets/javascripts/search_settings/constants.js b/app/assets/javascripts/search_settings/constants.js
index 499e42854ed..9452d149122 100644
--- a/app/assets/javascripts/search_settings/constants.js
+++ b/app/assets/javascripts/search_settings/constants.js
@@ -5,7 +5,7 @@ export const EXCLUDED_NODES = ['OPTION'];
export const HIDE_CLASS = 'gl-display-none';
// used to highlight the text that matches the * search term
-export const HIGHLIGHT_CLASS = 'gl-bg-orange-50';
+export const HIGHLIGHT_CLASS = 'gl-bg-orange-100';
// How many seconds to wait until the user * stops typing
export const TYPING_DELAY = 400;
diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue
index a2528edd914..8a8827b41cd 100644
--- a/app/assets/javascripts/security_configuration/components/manage_sast.vue
+++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue
@@ -54,6 +54,6 @@ export default {
<template>
<gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{
- s__('SecurityConfiguration|Configure via Merge Request')
+ s__('SecurityConfiguration|Configure via merge request')
}}</gl-button>
</template>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index bed264341a5..bff90254c04 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,14 +1,23 @@
<script>
/* eslint-disable vue/no-v-html */
-import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui';
+import {
+ GlToast,
+ GlModal,
+ GlTooltipDirective,
+ GlIcon,
+ GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
import $ from 'jquery';
import Vue from 'vue';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import * as Emoji from '~/emoji';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { __, s__ } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { updateUserStatus } from '~/rest_api';
+import { timeRanges } from '~/vue_shared/constants';
import EmojiMenuInModal from './emoji_menu_in_modal';
import { isUserBusy } from './utils';
@@ -20,11 +29,21 @@ export const AVAILABILITY_STATUS = {
Vue.use(GlToast);
+const statusTimeRanges = [
+ {
+ label: __('Never'),
+ name: 'never',
+ },
+ ...timeRanges,
+];
+
export default {
components: {
GlIcon,
GlModal,
GlFormCheckbox,
+ GlDropdown,
+ GlDropdownItem,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -53,6 +72,11 @@ export default {
required: false,
default: false,
},
+ currentClearStatusAfter: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -65,6 +89,10 @@ export default {
modalId: 'set-user-status-modal',
noEmoji: true,
availability: isUserBusy(this.currentAvailability),
+ clearStatusAfter: statusTimeRanges[0].label,
+ clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
+ date: this.currentClearStatusAfter,
+ }),
};
},
computed: {
@@ -161,12 +189,16 @@ export default {
this.setStatus();
},
setStatus() {
- const { emoji, message, availability } = this;
+ const { emoji, message, availability, clearStatusAfter } = this;
updateUserStatus({
emoji,
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
+ clearStatusAfter:
+ clearStatusAfter === statusTimeRanges[0].label
+ ? null
+ : clearStatusAfter.replace(' ', '_'),
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
@@ -183,7 +215,11 @@ export default {
this.closeModal();
},
+ setClearStatusAfter(after) {
+ this.clearStatusAfter = after;
+ },
},
+ statusTimeRanges,
};
</script>
@@ -268,10 +304,31 @@ export default {
</div>
<div class="gl-display-flex">
<span class="gl-text-gray-600 gl-ml-5">
- {{ s__('SetStatusModal|"Busy" will be shown next to your name') }}
+ {{ s__('SetStatusModal|A busy indicator is shown next to your name and avatar.') }}
</span>
</div>
</div>
+ <div class="form-group">
+ <div class="gl-display-flex gl-align-items-baseline">
+ <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
+ <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
+ <gl-dropdown-item
+ v-for="after in $options.statusTimeRanges"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="setClearStatusAfter(after.label)"
+ >{{ after.label }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <div
+ v-if="currentClearStatusAfter.length"
+ class="gl-mt-3 gl-text-gray-400 gl-font-sm"
+ data-testid="clear-status-at-message"
+ >
+ {{ clearStatusAfterMessage }}
+ </div>
+ </div>
</div>
</div>
</gl-modal>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index d0a65b48522..98fc0b0a783 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -103,10 +103,10 @@ export default {
v-gl-tooltip="tooltipOption"
:href="assigneeUrl"
:title="tooltipTitle"
- class="d-inline-block"
+ class="gl-display-inline-block"
>
<!-- use d-flex so that slot can be appropriately styled -->
- <span class="d-flex">
+ <span class="gl-display-flex">
<assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
<slot></slot>
</span>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index ca86d6c6c3e..f98798582c1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,7 +1,7 @@
<script>
import actionCable from '~/actioncable_consumer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
+import { assigneesQueries } from '~/sidebar/constants';
export default {
subscription: null,
@@ -9,7 +9,8 @@ export default {
props: {
mediator: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
issuableIid: {
type: String,
@@ -19,10 +20,16 @@ export default {
type: String,
required: true,
},
+ issuableType: {
+ type: String,
+ required: true,
+ },
},
apollo: {
- project: {
- query,
+ workspace: {
+ query() {
+ return assigneesQueries[this.issuableType].query;
+ },
variables() {
return {
iid: this.issuableIid,
@@ -30,7 +37,9 @@ export default {
};
},
result(data) {
- this.handleFetchResult(data);
+ if (this.mediator) {
+ this.handleFetchResult(data);
+ }
},
},
},
@@ -43,7 +52,7 @@ export default {
methods: {
received(data) {
if (data.event === 'updated') {
- this.$apollo.queries.project.refetch();
+ this.$apollo.queries.workspace.refetch();
}
},
initActionCablePolling() {
@@ -57,7 +66,7 @@ export default {
);
},
handleFetchResult({ data }) {
- const { nodes } = data.project.issue.assignees;
+ const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({
...n,
@@ -69,7 +78,7 @@ export default {
},
},
render() {
- return this.$slots.default;
+ return null;
},
};
</script>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index b53b7039018..e93aced12f3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -18,6 +18,11 @@ export default {
required: false,
default: 'issue',
},
+ signedIn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
assigneesText() {
@@ -34,20 +39,28 @@ export default {
<div class="gl-display-flex gl-flex-direction-column issuable-assignees">
<div
v-if="emptyUsers"
- class="gl-display-flex gl-align-items-center gl-text-gray-500"
+ class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed"
data-testid="none"
>
- <span> {{ __('None') }} -</span>
- <gl-button
- data-testid="assign-yourself"
- category="tertiary"
- variant="link"
- class="gl-ml-2"
- @click="$emit('assign-self')"
- >
- <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
- </gl-button>
+ <span> {{ __('None') }}</span>
+ <template v-if="signedIn">
+ <span class="gl-ml-2">-</span>
+ <gl-button
+ data-testid="assign-yourself"
+ category="tertiary"
+ variant="link"
+ class="gl-ml-2"
+ @click="$emit('assign-self')"
+ >
+ <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
+ </gl-button>
+ </template>
</div>
- <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
+ <uncollapsed-assignee-list
+ v-else
+ :users="users"
+ :issuable-type="issuableType"
+ class="gl-mt-2 hide-collapsed"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index 6595debf9a5..e15ea595190 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -123,6 +123,7 @@ export default {
v-if="shouldEnableRealtime"
:issuable-iid="issuableIid"
:project-path="projectPath"
+ :issuable-type="issuableType"
:mediator="mediator"
/>
<assignee-title
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index cc2201ad359..78cac989850 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,26 +1,28 @@
<script>
-import {
- GlDropdownItem,
- GlDropdownDivider,
- GlAvatarLabeled,
- GlAvatarLink,
- GlSearchBoxByType,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
+import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import SidebarInviteMembers from './sidebar_invite_members.vue';
+import SidebarParticipant from './sidebar_participant.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
export default {
i18n: {
unassigned: __('Unassigned'),
@@ -28,17 +30,26 @@ export default {
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
- assigneesQueries,
components: {
SidebarEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
- GlAvatarLabeled,
- GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
+ SidebarInviteMembers,
+ SidebarParticipant,
+ SidebarAssigneesRealtime,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ directlyInviteMembers: {
+ default: false,
+ },
+ indirectlyInviteMembers: {
+ default: false,
+ },
},
props: {
iid: {
@@ -76,12 +87,13 @@ export default {
selected: [],
isSettingAssignees: false,
isSearching: false,
+ isDirty: false,
};
},
apollo: {
issuable: {
query() {
- return this.$options.assigneesQueries[this.issuableType].query;
+ return assigneesQueries[this.issuableType].query;
},
variables() {
return this.queryVariables;
@@ -109,15 +121,20 @@ export default {
},
update(data) {
const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
- const mergedSearchResults = this.participants.reduce((acc, current) => {
- if (
- !acc.some((user) => current.username === user.username) &&
- (current.name.includes(this.search) || current.username.includes(this.search))
- ) {
+ const filteredParticipants = this.participants.filter(
+ (user) =>
+ user.name.toLowerCase().includes(this.search.toLowerCase()) ||
+ user.username.toLowerCase().includes(this.search.toLowerCase()),
+ );
+ const mergedSearchResults = searchResults.reduce((acc, current) => {
+ // Some users are duplicated in the query result:
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/327822
+ if (!acc.some((user) => current.username === user.username)) {
acc.push(current);
}
return acc;
- }, searchResults);
+ }, filteredParticipants);
+
return mergedSearchResults;
},
debounce: ASSIGNEES_DEBOUNCE_DELAY,
@@ -134,6 +151,10 @@ export default {
},
},
computed: {
+ shouldEnableRealtime() {
+ // Note: Realtime is only available on issues right now, future support for MR wil be built later.
+ return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue;
+ },
queryVariables() {
return {
iid: this.iid,
@@ -155,6 +176,9 @@ export default {
},
assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
+ if (!items) {
+ return __('Assignee');
+ }
return n__('Assignee', '%d Assignees', items.length);
},
selectedFiltered() {
@@ -197,8 +221,15 @@ export default {
noUsersFound() {
return !this.isSearchEmpty && this.searchUsers.length === 0;
},
+ signedIn() {
+ return this.currentUser.username !== undefined;
+ },
showCurrentUser() {
- return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
+ return (
+ this.signedIn &&
+ !this.isCurrentUserInParticipants &&
+ (this.isSearchEmpty || this.isSearching)
+ );
},
},
watch: {
@@ -221,7 +252,7 @@ export default {
this.isSettingAssignees = true;
return this.$apollo
.mutate({
- mutation: this.$options.assigneesQueries[this.issuableType].mutation,
+ mutation: assigneesQueries[this.issuableType].mutation,
variables: {
...this.queryVariables,
assigneeUsernames,
@@ -239,20 +270,22 @@ export default {
});
},
selectAssignee(name) {
- if (name === undefined) {
- this.clearSelected();
- return;
- }
+ this.isDirty = true;
if (!this.multipleAssignees) {
- this.selected = [name];
+ this.selected = name ? [name] : [];
this.collapseWidget();
- } else {
- this.selected = this.selected.concat(name);
+ return;
+ }
+ if (name === undefined) {
+ this.clearSelected();
+ return;
}
+ this.selected = this.selected.concat(name);
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
+ this.isDirty = true;
if (!this.multipleAssignees) {
this.collapseWidget();
@@ -265,7 +298,9 @@ export default {
this.selected = [];
},
saveAssignees() {
+ this.isDirty = false;
this.updateAssignees(this.selectedUserNames);
+ this.$el.dispatchEvent(hideDropdownEvent);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
@@ -291,6 +326,9 @@ export default {
collapseWidget() {
this.$refs.toggle.collapse();
},
+ expandWidget() {
+ this.$refs.toggle.expand();
+ },
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
@@ -299,121 +337,113 @@ export default {
</script>
<template>
- <div
- v-if="isAssigneesLoading"
- class="gl-display-flex gl-align-items-center assignee"
- data-testid="loading-assignees"
- >
- {{ __('Assignee') }}
- <gl-loading-icon size="sm" class="gl-ml-2" />
- </div>
- <sidebar-editable-item
- v-else
- ref="toggle"
- :loading="isSettingAssignees"
- :title="assigneeText"
- @open="focusSearch"
- @close="saveAssignees"
- >
- <template #collapsed>
- <issuable-assignees
- :users="assignees"
- :issuable-type="issuableType"
- class="gl-mt-2"
- @assign-self="assignSelf"
- />
- </template>
+ <div data-testid="assignees-widget">
+ <sidebar-assignees-realtime
+ v-if="shouldEnableRealtime"
+ :project-path="fullPath"
+ :issuable-iid="iid"
+ :issuable-type="issuableType"
+ />
+ <sidebar-editable-item
+ ref="toggle"
+ :loading="isSettingAssignees"
+ :initial-loading="isAssigneesLoading"
+ :title="assigneeText"
+ :is-dirty="isDirty"
+ @open="focusSearch"
+ @close="saveAssignees"
+ >
+ <template #collapsed>
+ <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot>
+ <issuable-assignees
+ :users="assignees"
+ :issuable-type="issuableType"
+ :signed-in="signedIn"
+ @assign-self="assignSelf"
+ @expand-widget="expandWidget"
+ />
+ </template>
- <template #default>
- <multi-select-dropdown
- class="gl-w-full dropdown-menu-user"
- :text="$options.i18n.assignees"
- :header-text="$options.i18n.assignTo"
- @toggle="collapseWidget"
- >
- <template #search>
- <gl-search-box-by-type ref="search" v-model.trim="search" />
- </template>
- <template #items>
- <gl-loading-icon
- v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
- data-testid="loading-participants"
- size="lg"
- />
- <template v-else>
- <template v-if="isSearchEmpty || isSearching">
+ <template #default>
+ <multi-select-dropdown
+ class="gl-w-full dropdown-menu-user"
+ :text="$options.i18n.assignees"
+ :header-text="$options.i18n.assignTo"
+ @toggle="collapseWidget"
+ >
+ <template #search>
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ class="js-dropdown-input-field"
+ />
+ </template>
+ <template #items>
+ <gl-loading-icon
+ v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
+ data-testid="loading-participants"
+ size="lg"
+ />
+ <template v-else>
+ <template v-if="isSearchEmpty || isSearching">
+ <gl-dropdown-item
+ :is-checked="selectedIsEmpty"
+ :is-check-centered="true"
+ data-testid="unassign"
+ @click="selectAssignee()"
+ >
+ <span
+ :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
+ class="gl-font-weight-bold"
+ >{{ $options.i18n.unassigned }}</span
+ ></gl-dropdown-item
+ >
+ </template>
+ <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
- :is-checked="selectedIsEmpty"
+ v-for="item in selectedFiltered"
+ :key="item.id"
+ :is-checked="isChecked(item.username)"
:is-check-centered="true"
- data-testid="unassign"
- @click="selectAssignee()"
+ data-testid="selected-participant"
+ @click.stop="unselect(item.username)"
>
- <span
- :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'"
- class="gl-font-weight-bold"
- >{{ $options.i18n.unassigned }}</span
- ></gl-dropdown-item
+ <sidebar-participant :user="item" />
+ </gl-dropdown-item>
+ <template v-if="showCurrentUser">
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ data-testid="current-user"
+ @click.stop="selectAssignee(currentUser)"
+ >
+ <sidebar-participant :user="currentUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
+ <gl-dropdown-item
+ v-for="unselectedUser in unselectedFiltered"
+ :key="unselectedUser.id"
+ data-testid="unselected-participant"
+ @click="selectAssignee(unselectedUser)"
>
- </template>
- <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
- <gl-dropdown-item
- v-for="item in selectedFiltered"
- :key="item.id"
- :is-checked="isChecked(item.username)"
- :is-check-centered="true"
- data-testid="selected-participant"
- @click.stop="unselect(item.username)"
- >
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="item.name"
- :sub-label="item.username"
- :src="item.avatarUrl || item.avatar || item.avatar_url"
- class="gl-align-items-center"
- />
- </gl-avatar-link>
- </gl-dropdown-item>
- <template v-if="showCurrentUser">
- <gl-dropdown-divider />
+ <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
<gl-dropdown-item
- data-testid="current-user"
- @click.stop="selectAssignee(currentUser)"
+ v-if="noUsersFound && !isSearching"
+ data-testid="empty-results"
+ class="gl-pl-6!"
>
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="currentUser.name"
- :sub-label="currentUser.username"
- :src="currentUser.avatarUrl"
- class="gl-align-items-center gl-pl-6!"
- />
- </gl-avatar-link>
+ {{ __('No matching results') }}
</gl-dropdown-item>
</template>
- <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
- <gl-dropdown-item
- v-for="unselectedUser in unselectedFiltered"
- :key="unselectedUser.id"
- data-testid="unselected-participant"
- @click="selectAssignee(unselectedUser)"
- >
- <gl-avatar-link class="gl-pl-6!">
- <gl-avatar-labeled
- :size="32"
- :label="unselectedUser.name"
- :sub-label="unselectedUser.username"
- :src="unselectedUser.avatarUrl || unselectedUser.avatar"
- class="gl-align-items-center"
- />
- </gl-avatar-link>
- </gl-dropdown-item>
- <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results">
- {{ __('No matching results') }}
+ </template>
+ <template #footer>
+ <gl-dropdown-item>
+ <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" />
</gl-dropdown-item>
</template>
- </template>
- </multi-select-dropdown>
- </template>
- </sidebar-editable-item>
+ </multi-select-dropdown>
+ </template>
+ </sidebar-editable-item>
+ </div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
new file mode 100644
index 00000000000..9952c6db582
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -0,0 +1,51 @@
+<script>
+import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue';
+import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { __ } from '~/locale';
+
+export default {
+ displayText: __('Invite members'),
+ dataTrackLabel: 'edit_assignee',
+ components: {
+ InviteMemberTrigger,
+ InviteMemberModal,
+ InviteMembersTrigger,
+ },
+ inject: {
+ projectMembersPath: {
+ default: '',
+ },
+ directlyInviteMembers: {
+ default: false,
+ },
+ },
+ computed: {
+ trackEvent() {
+ return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b';
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <invite-members-trigger
+ v-if="directlyInviteMembers"
+ trigger-element="anchor"
+ :display-text="$options.displayText"
+ :event="trackEvent"
+ :label="$options.dataTrackLabel"
+ classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ />
+ <template v-else>
+ <invite-member-trigger
+ :display-text="$options.displayText"
+ :event="trackEvent"
+ :label="$options.dataTrackLabel"
+ class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ />
+ <invite-member-modal :members-path="projectMembersPath" />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
new file mode 100644
index 00000000000..e2a38a100b9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlAvatarLabeled,
+ GlAvatarLink,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ userLabel() {
+ if (!this.user.status) {
+ return this.user.name;
+ }
+ return sprintf(s__('UserAvailability|%{author} (Busy)'), {
+ author: this.user.name,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="userLabel"
+ :sub-label="user.username"
+ :src="user.avatarUrl || user.avatar || user.avatar_url"
+ class="gl-align-items-center"
+ />
+ </gl-avatar-link>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index d0da4a9c75a..b7080bb05b8 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -1,4 +1,5 @@
<script>
+import { IssuableType } from '~/issue_show/constants';
import { __, sprintf } from '~/locale';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -58,7 +59,10 @@ export default {
this.showLess = !this.showLess;
},
userAvailability(u) {
- return u?.availability || '';
+ if (this.issuableType === IssuableType.MergeRequest) {
+ return u?.availability || '';
+ }
+ return u?.status?.availability || '';
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index a21ac73f131..1fb4bd26533 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -18,8 +18,15 @@ export default {
GlSprintf,
GlButton,
},
- inject: ['fullPath', 'iid'],
props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
confidential: {
required: true,
type: Boolean,
@@ -121,7 +128,7 @@ export default {
</gl-button>
<gl-button
category="secondary"
- variant="warning"
+ variant="confirm"
:disabled="loading"
:loading="loading"
data-testid="confidential-toggle"
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index ec5f07f9785..372368707af 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -27,8 +27,20 @@ export default {
SidebarConfidentialityContent,
SidebarConfidentialityForm,
},
- inject: ['fullPath', 'iid'],
+ inject: {
+ isClassicSidebar: {
+ default: false,
+ },
+ },
props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
issuableType: {
required: true,
type: String,
@@ -126,6 +138,7 @@ export default {
v-if="!isLoading"
:confidential="confidential"
:issuable-type="issuableType"
+ :class="{ 'gl-mt-3': !isClassicSidebar }"
@expandSidebar="expandSidebar"
/>
</div>
@@ -133,6 +146,8 @@ export default {
<template #default>
<sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" />
<sidebar-confidentiality-form
+ :iid="iid"
+ :full-path="fullPath"
:confidential="confidential"
:issuable-type="issuableType"
@closeForm="closeForm"
diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
index 8c8241cf6a4..0d8cb8cb2b6 100644
--- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
+++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
@@ -1,43 +1,24 @@
<script>
-import { s__, __, sprintf } from '~/locale';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CopyableField from '../../vue_shared/components/sidebar/copyable_field.vue';
export default {
- i18n: {
- copyEmail: __('Copy email address'),
- },
components: {
- ClipboardButton,
+ CopyableField,
},
props: {
- copyText: {
+ issueEmailAddress: {
type: String,
required: true,
},
},
- computed: {
- emailText() {
- return sprintf(s__('RightSidebar|Issue email: %{copyText}'), { copyText: this.copyText });
- },
- },
};
</script>
<template>
- <div
+ <copyable-field
data-qa-selector="copy-forward-email"
- class="copy-email-address gl-display-flex gl-align-items-center gl-justify-content-space-between"
- >
- <span
- class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap hide-collapsed gl-w-85p"
- >{{ emailText }}</span
- >
- <clipboard-button
- class="copy-email-button gl-bg-none!"
- category="tertiary"
- :title="$options.i18n.copyEmail"
- :text="copyText"
- tooltip-placement="left"
- />
- </div>
+ :name="s__('RightSidebar|Issue email')"
+ :clipboard-tooltip-text="s__('RightSidebar|Copy email address')"
+ :value="issueEmailAddress"
+ />
</template>
diff --git a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue
new file mode 100644
index 00000000000..141c2b3aae9
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue
@@ -0,0 +1,203 @@
+<script>
+import { GlButton, GlIcon, GlDatepicker, GlTooltipDirective } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
+import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { dueDateQueries } from '~/sidebar/constants';
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
+export default {
+ tracking: {
+ event: 'click_edit_button',
+ label: 'right_sidebar',
+ property: 'dueDate',
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ GlIcon,
+ GlDatepicker,
+ SidebarEditableItem,
+ },
+ inject: ['fullPath', 'iid', 'canUpdate'],
+ props: {
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ dueDate: null,
+ loading: false,
+ };
+ },
+ apollo: {
+ dueDate: {
+ query() {
+ return dueDateQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.dueDate || null;
+ },
+ result({ data }) {
+ this.$emit('dueDateUpdated', data.workspace?.issuable?.dueDate);
+ },
+ error() {
+ createFlash({
+ message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), {
+ issuableType: this.issuableType,
+ }),
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.dueDate.loading || this.loading;
+ },
+ hasDueDate() {
+ return this.dueDate !== null;
+ },
+ parsedDueDate() {
+ if (!this.hasDueDate) {
+ return null;
+ }
+
+ return parsePikadayDate(this.dueDate);
+ },
+ formattedDueDate() {
+ if (!this.hasDueDate) {
+ return this.$options.i18n.noDueDate;
+ }
+
+ return dateInWords(this.parsedDueDate, true);
+ },
+ workspacePath() {
+ return this.issuableType === IssuableType.Issue
+ ? {
+ projectPath: this.fullPath,
+ }
+ : {
+ groupPath: this.fullPath,
+ };
+ },
+ },
+ methods: {
+ closeForm() {
+ this.$refs.editable.collapse();
+ this.$el.dispatchEvent(hideDropdownEvent);
+ this.$emit('closeForm');
+ },
+ openDatePicker() {
+ this.$refs.datePicker.calendar.show();
+ },
+ setDueDate(date) {
+ this.loading = true;
+ this.$refs.editable.collapse();
+ this.$apollo
+ .mutate({
+ mutation: dueDateQueries[this.issuableType].mutation,
+ variables: {
+ input: {
+ ...this.workspacePath,
+ iid: this.iid,
+ dueDate: date ? formatDate(date, 'yyyy-mm-dd') : null,
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ issuableSetDueDate: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ } else {
+ this.$emit('closeForm');
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), {
+ issuableType: this.issuableType,
+ }),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+ i18n: {
+ dueDate: __('Due date'),
+ noDueDate: __('None'),
+ removeDueDate: __('remove due date'),
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="$options.i18n.dueDate"
+ :tracking="$options.tracking"
+ :loading="isLoading"
+ class="block"
+ data-testid="due-date"
+ @open="openDatePicker"
+ >
+ <template #collapsed>
+ <div v-gl-tooltip :title="$options.i18n.dueDate" class="sidebar-collapsed-icon">
+ <gl-icon :size="16" name="calendar" />
+ <span class="collapse-truncated-title">{{ formattedDueDate }}</span>
+ </div>
+ <div class="gl-display-flex gl-align-items-center hide-collapsed">
+ <span
+ :class="hasDueDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"
+ data-testid="sidebar-duedate-value"
+ >
+ {{ formattedDueDate }}
+ </span>
+ <div v-if="hasDueDate && canUpdate" class="gl-display-flex">
+ <span class="gl-px-2">-</span>
+ <gl-button
+ variant="link"
+ class="gl-text-gray-500!"
+ data-testid="reset-button"
+ :disabled="isLoading"
+ @click="setDueDate(null)"
+ >
+ {{ $options.i18n.removeDueDate }}
+ </gl-button>
+ </div>
+ </div>
+ </template>
+ <template #default>
+ <gl-datepicker
+ ref="datePicker"
+ :value="parsedDueDate"
+ show-clear-button
+ @input="setDueDate"
+ @clear="setDueDate(null)"
+ />
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
index 567c921b74e..d07c6e0cbd2 100644
--- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
+++ b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue
@@ -1,17 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { referenceQueries } from '~/sidebar/constants';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue';
export default {
- i18n: {
- copyReference: __('Copy reference'),
- text: __('Reference'),
- },
components: {
- ClipboardButton,
- GlLoadingIcon,
+ CopyableField,
},
inject: ['fullPath', 'iid'],
props: {
@@ -56,29 +50,10 @@ export default {
</script>
<template>
- <div class="sub-block">
- <clipboard-button
- v-if="!isLoading"
- :title="$options.i18n.copyReference"
- :text="reference"
- category="tertiary"
- css-class="sidebar-collapsed-icon dont-change-state"
- tooltip-placement="left"
- />
- <div class="gl-display-flex gl-align-items-center gl-justify-between gl-mb-2 hide-collapsed">
- <span class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap">
- {{ $options.i18n.text }}: {{ reference }}
- <gl-loading-icon v-if="isLoading" inline :label="$options.i18n.text" />
- </span>
- <clipboard-button
- v-if="!isLoading"
- :title="$options.i18n.copyReference"
- :text="reference"
- size="small"
- category="tertiary"
- css-class="gl-mr-1"
- tooltip-placement="left"
- />
- </div>
- </div>
+ <copyable-field
+ class="sub-block"
+ :is-loading="isLoading"
+ :name="__('Reference')"
+ :value="reference"
+ />
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index dd1d54d67f2..c6fef86c6ff 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,12 +1,15 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { sprintf, s__ } from '~/locale';
+import { __, sprintf, s__ } from '~/locale';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
const SUCCESS_STATE = 'success';
export default {
+ i18n: {
+ reRequestReview: __('Re-request review'),
+ },
components: {
GlButton,
GlIcon,
@@ -109,7 +112,8 @@ export default {
<gl-button
v-else-if="user.can_update_merge_request && user.reviewed"
v-gl-tooltip.left
- :title="__('Re-request review')"
+ :title="$options.i18n.reRequestReview"
+ :aria-label="$options.i18n.reRequestReview"
:loading="loadingStates[user.id] === $options.LOADING_STATE"
class="float-right gl-text-gray-500!"
size="small"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 4ab4606ac1c..caf1c92c28a 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
export default {
components: { GlButton, GlLoadingIcon },
@@ -20,6 +21,16 @@ export default {
required: false,
default: false,
},
+ initialLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isDirty: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
tracking: {
type: Object,
required: false,
@@ -35,6 +46,11 @@ export default {
edit: false,
};
},
+ computed: {
+ editButtonText() {
+ return this.isDirty ? __('Apply') : __('Edit');
+ },
+ },
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
@@ -86,15 +102,15 @@ export default {
<template>
<div>
<div class="gl-display-flex gl-align-items-center" @click.self="collapse">
- <span class="hide-collapsed" data-testid="title">{{ title }}</span>
- <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" />
+ <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span>
+ <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" />
<gl-loading-icon
v-if="loading && isClassicSidebar"
inline
class="gl-mx-auto gl-my-0 hide-expanded"
/>
<gl-button
- v-if="canUpdate"
+ v-if="canUpdate && !initialLoading"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button"
@@ -105,14 +121,16 @@ export default {
@keyup.esc="toggle"
@click="toggle"
>
- {{ __('Edit') }}
+ {{ editButtonText }}
</gl-button>
</div>
- <div v-show="!edit" data-testid="collapsed-content">
- <slot name="collapsed">{{ __('None') }}</slot>
- </div>
- <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
- <slot :edit="edit"></slot>
- </div>
+ <template v-if="!initialLoading">
+ <div v-show="!edit" data-testid="collapsed-content">
+ <slot name="collapsed">{{ __('None') }}</slot>
+ </div>
+ <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
+ <slot :edit="edit"></slot>
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index a0e636488f4..80e07d556bf 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,10 +1,12 @@
import { IssuableType } from '~/issue_show/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
+import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql';
import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql';
+import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
@@ -42,3 +44,10 @@ export const referenceQueries = {
query: mergeRequestReferenceQuery,
},
};
+
+export const dueDateQueries = {
+ [IssuableType.Issue]: {
+ query: issueDueDateQuery,
+ mutation: updateIssueDueDateMutation,
+ },
+};
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 312c0c89f29..1304e84814b 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -10,7 +10,10 @@ import {
parseBoolean,
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
+import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
+import SidebarDueDateWidget from '~/sidebar/components/due_date/sidebar_due_date_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import Translate from '../vue_shared/translate';
@@ -32,15 +35,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
-/**
- * Extracts the list of assignees with availability information from a hidden input
- * field and converts to a key:value pair for use in the sidebar assignees component.
- * The assignee username is used as the key and their busy status is the value
- *
- * e.g { root: 'busy', admin: '' }
- *
- * @returns {Object}
- */
function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl)
@@ -54,7 +48,7 @@ function getSidebarAssigneeAvailabilityData() {
);
}
-function mountAssigneesComponent(mediator) {
+function mountAssigneesComponentDeprecated(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
if (!el) return;
@@ -86,6 +80,51 @@ function mountAssigneesComponent(mediator) {
});
}
+function mountAssigneesComponent() {
+ const el = document.getElementById('js-vue-sidebar-assignees');
+
+ if (!el) return;
+
+ const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarAssigneesWidget,
+ },
+ provide: {
+ canUpdate: editable,
+ projectMembersPath,
+ directlyInviteMembers: el.hasAttribute('data-directly-invite-members'),
+ indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'),
+ },
+ render: (createElement) =>
+ createElement('sidebar-assignees-widget', {
+ props: {
+ iid: String(iid),
+ fullPath,
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
+ multipleAssignees: !el.dataset.maxAssignees,
+ },
+ scopedSlots: {
+ collapsed: ({ users, onClick }) =>
+ createElement(CollapsedAssigneeList, {
+ props: {
+ users,
+ },
+ nativeOn: {
+ click: onClick,
+ },
+ }),
+ },
+ }),
+ });
+}
+
function mountReviewersComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-reviewers');
@@ -151,14 +190,14 @@ function mountConfidentialComponent() {
SidebarConfidentialityWidget,
},
provide: {
- iid: String(iid),
- fullPath,
canUpdate: initialData.is_editable,
},
render: (createElement) =>
createElement('sidebar-confidentiality-widget', {
props: {
+ iid: String(iid),
+ fullPath,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
@@ -168,6 +207,36 @@ function mountConfidentialComponent() {
});
}
+function mountDueDateComponent() {
+ const el = document.getElementById('js-due-date-entry-point');
+ if (!el) {
+ return;
+ }
+
+ const { fullPath, iid, editable } = getSidebarOptions();
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ apolloProvider,
+ components: {
+ SidebarDueDateWidget,
+ },
+ provide: {
+ iid: String(iid),
+ fullPath,
+ canUpdate: editable,
+ },
+
+ render: (createElement) =>
+ createElement('sidebar-due-date-widget', {
+ props: {
+ issuableType: IssuableType.Issue,
+ },
+ }),
+ });
+}
+
function mountReferenceComponent() {
const el = document.getElementById('js-reference-entry-point');
if (!el) {
@@ -337,14 +406,22 @@ function mountCopyEmailComponent() {
new Vue({
el,
render: (createElement) =>
- createElement(CopyEmailToClipboard, { props: { copyText: createNoteEmail } }),
+ createElement(CopyEmailToClipboard, { props: { issueEmailAddress: createNoteEmail } }),
});
}
+const isAssigneesWidgetShown =
+ (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
+
export function mountSidebar(mediator) {
- mountAssigneesComponent(mediator);
+ if (isAssigneesWidgetShown) {
+ mountAssigneesComponent();
+ } else {
+ mountAssigneesComponentDeprecated(mediator);
+ }
mountReviewersComponent(mediator);
mountConfidentialComponent(mediator);
+ mountDueDateComponent(mediator);
mountReferenceComponent(mediator);
mountLockComponent();
mountParticipantsComponent(mediator);
diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
new file mode 100644
index 00000000000..6d3f782bd0a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
@@ -0,0 +1,10 @@
+query issueDueDate($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ dueDate
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
new file mode 100644
index 00000000000..f2b806102f4
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
@@ -0,0 +1,8 @@
+mutation epicSetSubscription($input: EpicSetSubscriptionInput!) {
+ updateIssuableSubscription: epicSetSubscription(input: $input) {
+ epic {
+ subscribed
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
new file mode 100644
index 00000000000..317b48c142d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql
@@ -0,0 +1,8 @@
+mutation updateEpic($input: UpdateEpicInput!) {
+ updateIssuableTitle: updateEpic(input: $input) {
+ epic {
+ title
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql
new file mode 100644
index 00000000000..cf7eccd61c7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateIssueDueDate($input: UpdateIssueInput!) {
+ issuableSetDueDate: updateIssue(input: $input) {
+ issuable: issue {
+ id
+ dueDate
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index bee9d7b8c2a..c53d0575752 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -33,7 +33,6 @@ export default {
SnippetBlobActionsEdit,
TitleField,
FormFooterActions,
- CaptchaModal: () => import('~/captcha/captcha_modal.vue'),
GlButton,
GlLoadingIcon,
},
@@ -68,10 +67,6 @@ export default {
description: '',
visibilityLevel: this.selectedLevel,
},
- captchaResponse: '',
- needsCaptchaResponse: false,
- captchaSiteKey: '',
- spamLogId: '',
};
},
computed: {
@@ -103,8 +98,6 @@ export default {
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.actions,
- ...(this.spamLogId && { spamLogId: this.spamLogId }),
- ...(this.captchaResponse && { captchaResponse: this.captchaResponse }),
};
},
saveButtonLabel() {
@@ -171,20 +164,14 @@ export default {
},
handleFormSubmit() {
this.isUpdating = true;
+
this.$apollo
.mutate(this.newSnippet ? this.createMutation() : this.updateMutation())
.then(({ data }) => {
const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
- if (baseObj.needsCaptchaResponse) {
- // If we need a captcha response, start process for receiving captcha response.
- // We will resubmit after the response is obtained.
- this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId);
- return;
- }
-
const errors = baseObj?.errors;
- if (errors.length) {
+ if (errors?.length) {
this.flashAPIFailure(errors[0]);
} else {
redirectTo(baseObj.snippet.webUrl);
@@ -200,38 +187,6 @@ export default {
updateActions(actions) {
this.actions = actions;
},
- /**
- * Start process for getting captcha response from user
- *
- * @param captchaSiteKey Stored in data and used to display the captcha.
- * @param spamLogId Stored in data and included when the form is re-submitted.
- */
- requestCaptchaResponse(captchaSiteKey, spamLogId) {
- this.captchaSiteKey = captchaSiteKey;
- this.spamLogId = spamLogId;
- this.needsCaptchaResponse = true;
- },
- /**
- * Handle the captcha response from the user
- *
- * @param captchaResponse The captchaResponse value emitted from the modal.
- */
- receivedCaptchaResponse(captchaResponse) {
- this.needsCaptchaResponse = false;
- this.captchaResponse = captchaResponse;
-
- if (this.captchaResponse) {
- // If the user solved the captcha, resubmit the form.
- // NOTE: we do not need to clear out the captchaResponse and spamLogId
- // data values after submit, because this component always does a full page reload.
- // Otherwise, we would need to.
- this.handleFormSubmit();
- } else {
- // If the user didn't solve the captcha (e.g. they just closed the modal),
- // finish the update and allow them to continue editing or manually resubmit the form.
- this.isUpdating = false;
- }
- },
},
};
</script>
@@ -249,11 +204,6 @@ export default {
class="loading-animation prepend-top-20 gl-mb-6"
/>
<template v-else>
- <captcha-modal
- :captcha-site-key="captchaSiteKey"
- :needs-captcha-response="needsCaptchaResponse"
- @receivedCaptchaResponse="receivedCaptchaResponse"
- />
<title-field
id="snippet-title"
v-model="snippet.title"
diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue
index f6c9c569b5f..ad1b08a5a07 100644
--- a/app/assets/javascripts/snippets/components/embed_dropdown.vue
+++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue
@@ -65,6 +65,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.MSG_COPY"
+ :aria-label="$options.MSG_COPY"
:data-clipboard-text="value"
icon="copy-to-clipboard"
data-qa-selector="copy_button"
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
index 64d5d7c30fa..f688868d1b9 100644
--- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
@@ -4,7 +4,5 @@ mutation CreateSnippet($input: CreateSnippetInput!) {
snippet {
webUrl
}
- needsCaptchaResponse
- captchaSiteKey
}
}
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
index 0a72f71b7c9..548725f7357 100644
--- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
+++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
@@ -4,8 +4,5 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) {
snippet {
webUrl
}
- needsCaptchaResponse
- captchaSiteKey
- spamLogId
}
}
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
index 23f800517c9..2ae2baddbcc 100644
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import appDataQuery from './queries/app_data.query.graphql';
import fileResolver from './resolvers/file';
import hasSubmittedChangesResolver from './resolvers/has_submitted_changes';
import submitContentChangesResolver from './resolvers/submit_content_changes';
@@ -28,7 +29,8 @@ const createApolloProvider = (appData) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
const mounts = appData.mounts.map((mount) => ({ __typename: 'Mount', ...mount }));
- defaultClient.cache.writeData({
+ defaultClient.cache.writeQuery({
+ query: appDataQuery,
data: {
appData: {
__typename: 'AppData',
diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue
new file mode 100644
index 00000000000..036ce2cca78
--- /dev/null
+++ b/app/assets/javascripts/tags/components/sort_dropdown.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
+import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ searchPlaceholder: s__('TagsPage|Filter by tag name'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByClick,
+ },
+ inject: ['sortOptions', 'filterTagsPath'],
+ data() {
+ return {
+ selectedKey: 'updated_desc',
+ searchTerm: '',
+ };
+ },
+ computed: {
+ selectedSortMethod() {
+ return this.sortOptions[this.selectedKey];
+ },
+ },
+ created() {
+ const sortValue = getParameterValues('sort');
+ const searchValue = getParameterValues('search');
+
+ if (sortValue.length > 0) {
+ [this.selectedKey] = sortValue;
+ }
+
+ if (searchValue.length > 0) {
+ [this.searchTerm] = searchValue;
+ }
+ },
+ methods: {
+ isSortMethodSelected(sortKey) {
+ return sortKey === this.selectedKey;
+ },
+ visitUrlFromOption(sortKey) {
+ this.selectedKey = sortKey;
+ const urlParams = {};
+
+ urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null;
+ urlParams.sort = sortKey;
+
+ const newUrl = mergeUrlParams(urlParams, this.filterTagsPath);
+ visitUrl(newUrl);
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-pr-3">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ :placeholder="$options.i18n.searchPlaceholder"
+ class="gl-pr-3"
+ data-testid="tag-search"
+ @submit="visitUrlFromOption(selectedKey)"
+ />
+ <gl-dropdown :text="selectedSortMethod" right data-testid="tags-dropdown">
+ <gl-dropdown-item
+ v-for="(value, key) in sortOptions"
+ :key="key"
+ :is-checked="isSortMethodSelected(key)"
+ is-check-item
+ @click="visitUrlFromOption(key)"
+ >
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tags/index.js b/app/assets/javascripts/tags/index.js
new file mode 100644
index 00000000000..68510f3fe3a
--- /dev/null
+++ b/app/assets/javascripts/tags/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import SortDropdown from './components/sort_dropdown.vue';
+
+const mountDropdownApp = (el) => {
+ const { sortOptions, filterTagsPath } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'SortTagsDropdownApp',
+ components: {
+ SortDropdown,
+ },
+ provide: {
+ sortOptions: JSON.parse(sortOptions),
+ filterTagsPath,
+ },
+ render: (createElement) => createElement(SortDropdown),
+ });
+};
+
+export default () => {
+ const el = document.getElementById('js-tags-sort-dropdown');
+ return el ? mountDropdownApp(el) : null;
+};
diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js
index f60c0759c72..49a43b120e0 100644
--- a/app/assets/javascripts/tooltips/index.js
+++ b/app/assets/javascripts/tooltips/index.js
@@ -44,10 +44,7 @@ const addTooltips = (elements, config) => {
const handleTooltipEvent = (rootTarget, e, selector, config = {}) => {
for (let { target } = e; target && target !== rootTarget; target = target.parentNode) {
if (isTooltip(target, selector)) {
- addTooltips([target], {
- show: true,
- ...config,
- });
+ addTooltips([target], config);
break;
}
}
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 01de034417e..cdfecceb78a 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -27,22 +27,33 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
pageUnloadTimer: 10,
};
+const addExperimentContext = (opts) => {
+ const { experiment, ...options } = opts;
+ if (experiment) {
+ const data = getExperimentData(experiment);
+ if (data) {
+ const context = { schema: TRACKING_CONTEXT_SCHEMA, data };
+ return { ...options, context };
+ }
+ }
+ return options;
+};
+
const createEventPayload = (el, { suffix = '' } = {}) => {
- const action = el.dataset.trackEvent + (suffix || '');
+ const action = (el.dataset.trackAction || el.dataset.trackEvent) + (suffix || '');
let value = el.dataset.trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false;
- let context = el.dataset.trackContext;
- if (el.dataset.trackExperiment) {
- const data = getExperimentData(el.dataset.trackExperiment);
- if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data };
- }
+ const context = addExperimentContext({
+ experiment: el.dataset.trackExperiment,
+ context: el.dataset.trackContext,
+ });
const data = {
label: el.dataset.trackLabel,
property: el.dataset.trackProperty,
value,
- context,
+ ...context,
};
return {
@@ -52,7 +63,7 @@ const createEventPayload = (el, { suffix = '' } = {}) => {
};
const eventHandler = (e, func, opts = {}) => {
- const el = e.target.closest('[data-track-event]');
+ const el = e.target.closest('[data-track-event], [data-track-action]');
if (!el) return;
@@ -130,7 +141,9 @@ export default class Tracking {
static trackLoadEvents(category = document.body.dataset.page, parent = document) {
if (!this.enabled()) return [];
- const loadEvents = parent.querySelectorAll('[data-track-event="render"]');
+ const loadEvents = parent.querySelectorAll(
+ '[data-track-action="render"], [data-track-event="render"]',
+ );
loadEvents.forEach((element) => {
const { action, data } = createEventPayload(element);
@@ -148,7 +161,8 @@ export default class Tracking {
return localCategory || opts.category;
},
trackingOptions() {
- return { ...opts, ...this.tracking };
+ const options = addExperimentContext(opts);
+ return { ...options, ...this.tracking };
},
},
methods: {
diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue
index e33a4b3ffb4..4cf3f3010b9 100644
--- a/app/assets/javascripts/user_lists/components/user_list.vue
+++ b/app/assets/javascripts/user_lists/components/user_list.vue
@@ -126,6 +126,7 @@ export default {
category="secondary"
variant="danger"
icon="remove"
+ :aria-label="__('Remove user')"
data-testid="delete-user-id"
@click="removeUserId(id)"
/>
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index e1a4a74b982..7c17ce85cc6 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -11,7 +11,6 @@ import {
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
-import ModalStore from '../boards/stores/modal_store';
import axios from '../lib/utils/axios_utils';
import { parseBoolean, spriteIcon } from '../lib/utils/common_utils';
import { loadCSSFile } from '../lib/utils/css_utils';
@@ -258,7 +257,11 @@ function UsersSelect(currentUser, els, options = {}) {
deprecatedJQueryDropdown.options.processData(term, users, callback);
});
},
- processData(term, data, callback) {
+ processData(term, dataArg, callback) {
+ // Sometimes the `dataArg` can contain special dropdown items like
+ // dividers which we don't want to consider here.
+ const data = dataArg.filter((x) => !x.type);
+
let users = data;
// Only show assigned user list when there is no search term
@@ -504,9 +507,7 @@ function UsersSelect(currentUser, els, options = {}) {
}
return;
}
- if ($el.closest('.add-issues-modal').length) {
- ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
- } else if (handleClick) {
+ if (handleClick) {
e.preventDefault();
handleClick(user, isMarking);
} else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
index cc3efae565a..b25c0cc0d96 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue
@@ -68,6 +68,7 @@ export default {
category="primary"
size="small"
:title="buttonTitle"
+ :aria-label="buttonTitle"
:loading="isLoading"
:disabled="isActionInProgress"
:class="`inline gl-ml-2 ${containerClasses}`"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 3419abd4738..1248a891ed9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -14,6 +14,7 @@ import { s__, n__ } from '~/locale';
import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import { MT_MERGE_STRATEGY } from '../constants';
@@ -28,6 +29,7 @@ export default {
GlTooltip,
PipelineArtifacts,
PipelineMiniGraph,
+ TimeAgoTooltip,
TooltipOnTruncate,
LinkedPipelinesMiniList: () =>
import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
@@ -114,6 +116,9 @@ export default {
showSourceBranch() {
return Boolean(this.pipeline.ref.branch);
},
+ finishedAt() {
+ return this.pipeline?.details?.finished_at;
+ },
coverageDeltaClass() {
const delta = this.pipelineCoverageDelta;
if (delta && parseFloat(delta) > 0) {
@@ -127,10 +132,20 @@ export default {
pipelineCoverageJobNumberText() {
return n__('from %d job', 'from %d jobs', this.buildsWithCoverage.length);
},
+ pipelineCoverageTooltipDeltaDescription() {
+ const delta = parseFloat(this.pipelineCoverageDelta) || 0;
+ if (delta > 0) {
+ return s__('Pipeline|This change will increase the overall test coverage if merged.');
+ }
+ if (delta < 0) {
+ return s__('Pipeline|This change will decrease the overall test coverage if merged.');
+ }
+ return s__('Pipeline|This change will not change the overall test coverage if merged.');
+ },
pipelineCoverageTooltipDescription() {
return n__(
- 'Coverage value for this pipeline was calculated by the coverage value of %d job.',
- 'Coverage value for this pipeline was calculated by averaging the resulting coverage values of %d jobs.',
+ 'Test coverage value for this pipeline was calculated by the coverage value of %d job.',
+ 'Test coverage value for this pipeline was calculated by averaging the resulting coverage values of %d jobs.',
this.buildsWithCoverage.length,
);
},
@@ -216,15 +231,24 @@ export default {
class="label-branch label-truncate gl-font-weight-normal"
/>
</template>
+ <template v-if="finishedAt">
+ <time-ago-tooltip
+ :time="finishedAt"
+ tooltip-placement="bottom"
+ data-testid="finished-at"
+ />
+ </template>
</div>
<div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage">
- {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
+ {{ s__('Pipeline|Test coverage') }} {{ pipeline.coverage }}%
<span
v-if="pipelineCoverageDelta"
+ ref="pipelineCoverageDelta"
:class="coverageDeltaClass"
data-testid="pipeline-coverage-delta"
- >({{ pipelineCoverageDelta }}%)</span
>
+ ({{ pipelineCoverageDelta }}%)
+ </span>
{{ pipelineCoverageJobNumberText }}
<span ref="pipelineCoverageQuestion">
<gl-icon name="question" :size="12" />
@@ -242,6 +266,12 @@ export default {
{{ build.name }} ({{ build.coverage }}%)
</div>
</gl-tooltip>
+ <gl-tooltip
+ :target="() => $refs.pipelineCoverageDelta"
+ data-testid="pipeline-coverage-delta-tooltip"
+ >
+ {{ pipelineCoverageTooltipDeltaDescription }}
+ </gl-tooltip>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 84a21a25552..6d68c15cf2d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -71,11 +71,11 @@ export default {
return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).targetBranch;
},
shouldRemoveSourceBranch() {
- if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch;
- }
+ if (!this.glFeatures.mergeRequestWidgetGraphql) return this.mr.shouldRemoveSourceBranch;
+
+ if (!this.state.shouldRemoveSourceBranch) return false;
- return this.mr.shouldRemoveSourceBranch;
+ return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch;
},
autoMergeStrategy() {
return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 23f415c3116..ee90d734ecb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,6 +1,5 @@
<script>
-import { GlButton, GlModalDirective, GlSkeletonLoader, GlPopover, GlLink } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
@@ -13,8 +12,6 @@ export default {
GlSkeletonLoader,
StatusIcon,
GlButton,
- GlPopover,
- GlLink,
},
directives: {
GlModalDirective,
@@ -93,24 +90,12 @@ export default {
return this.mr.sourceBranchProtected;
},
- popoverTitle() {
- return s__(
- 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
- );
- },
showResolveButton() {
- return this.mr.conflictResolutionPath && this.canPushToSourceBranch;
- },
- showPopover() {
- return this.showResolveButton && this.sourceBranchProtected;
+ return (
+ this.mr.conflictResolutionPath && this.canPushToSourceBranch && !this.sourceBranchProtected
+ );
},
},
- i18n: {
- title: s__(
- 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
- ),
- linkText: s__('mrWidget|Learn more about resolving conflicts'),
- },
};
</script>
<template>
@@ -141,33 +126,13 @@ export default {
}}
</span>
</span>
- <span v-if="showResolveButton" ref="popover">
- <gl-button
- :href="mr.conflictResolutionPath"
- :disabled="sourceBranchProtected"
- data-testid="resolve-conflicts-button"
- >
- {{ s__('mrWidget|Resolve conflicts') }}
- </gl-button>
- <gl-popover
- v-if="showPopover"
- :target="() => $refs.popover"
- placement="top"
- triggers="hover focus"
- >
- <template #title>
- <div class="gl-font-weight-normal gl-font-base">
- {{ $options.i18n.title }}
- </div>
- </template>
-
- <div class="gl-text-center">
- <gl-link :href="mr.conflictsDocsPath" target="_blank" rel="noopener noreferrer">
- {{ $options.i18n.linkText }}
- </gl-link>
- </div>
- </gl-popover>
- </span>
+ <gl-button
+ v-if="showResolveButton"
+ :href="mr.conflictResolutionPath"
+ data-testid="resolve-conflicts-button"
+ >
+ {{ s__('mrWidget|Resolve conflicts') }}
+ </gl-button>
<gl-button
v-if="canMerge"
v-gl-modal-directive="'modal-merge-info'"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index 043d14e32a2..9da3bea9362 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -130,6 +130,7 @@ export default {
size="small"
category="secondary"
variant="warning"
+ data-qa-selector="revert_button"
@click="openRevertModal"
>
{{ revertLabel }}
@@ -151,6 +152,7 @@ export default {
v-gl-tooltip.hover
:title="cherryPickTitle"
size="small"
+ data-qa-selector="cherry_pick_button"
@click="openCherryPickModal"
>
{{ cherryPickLabel }}
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 62c5cd90035..751f8082e1a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -15,10 +15,12 @@ import { isEmpty } from 'lodash';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
+import createFlash from '~/flash';
+import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
import { __ } from '~/locale';
+import SmartInterval from '~/smart_interval';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { deprecatedCreateFlash as Flash } from '../../../flash';
import MergeRequest from '../../../merge_request';
import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants';
import eventHub from '../../event_hub';
@@ -52,20 +54,27 @@ export default {
},
manual: true,
result({ data }) {
+ if (Object.keys(this.state).length === 0) {
+ this.removeSourceBranch =
+ data.project.mergeRequest.shouldRemoveSourceBranch ||
+ data.project.mergeRequest.forceRemoveSourceBranch ||
+ false;
+ this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage;
+ this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge;
+ this.isSquashReadOnly = data.project.squashReadOnly;
+ this.squashCommitMessage = data.project.mergeRequest.defaultSquashCommitMessage;
+ }
+
this.state = {
...data.project.mergeRequest,
mergeRequestsFfOnlyEnabled: data.project.mergeRequestsFfOnlyEnabled,
onlyAllowMergeIfPipelineSucceeds: data.project.onlyAllowMergeIfPipelineSucceeds,
};
- this.removeSourceBranch =
- data.project.mergeRequest.shouldRemoveSourceBranch ||
- data.project.mergeRequest.forceRemoveSourceBranch ||
- false;
- this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage;
- this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge;
- this.isSquashReadOnly = data.project.squashReadOnly;
- this.squashCommitMessage = data.project.mergeRequest.defaultSquashCommitMessage;
this.loading = false;
+
+ if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) {
+ this.initPolling();
+ }
},
},
},
@@ -124,7 +133,7 @@ export default {
},
pipeline() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
- return this.state.pipelines?.nodes?.[0];
+ return this.state.headPipeline;
}
return this.mr.pipeline;
@@ -291,8 +300,23 @@ export default {
if (this.glFeatures.mergeRequestWidgetGraphql) {
eventHub.$off('ApprovalUpdated', this.updateGraphqlState);
}
+
+ if (this.pollingInterval) {
+ this.pollingInterval.destroy();
+ }
},
methods: {
+ initPolling() {
+ const startingPollInterval = secondsToMilliseconds(5);
+
+ this.pollingInterval = new SmartInterval({
+ callback: () => this.$apollo.queries.state.refetch(),
+ startingInterval: startingPollInterval,
+ maxInterval: startingPollInterval + secondsToMilliseconds(4 * 60),
+ hiddenInterval: secondsToMilliseconds(6 * 60),
+ incrementByFactorOf: 2,
+ });
+ },
updateGraphqlState() {
return this.$apollo.queries.state.refetch();
},
@@ -351,7 +375,9 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line
+ createFlash({
+ message: __('Something went wrong. Please try again.'),
+ });
});
},
handleMergeImmediatelyButtonClick() {
@@ -402,7 +428,9 @@ export default {
}
})
.catch(() => {
- new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line
+ createFlash({
+ message: __('Something went wrong while merging this merge request. Please try again.'),
+ });
stopPolling();
});
},
@@ -432,7 +460,9 @@ export default {
}
})
.catch(() => {
- new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line
+ createFlash({
+ message: __('Something went wrong while deleting the source branch. Please try again.'),
+ });
});
},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index 6388b817e46..41b5983ae0c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -44,7 +44,8 @@ export default {
:checked="value"
:disabled="isDisabled"
name="squash"
- class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
+ class="js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
+ data-qa-selector="squash_checkbox"
:title="tooltipTitle"
@change="(checked) => $emit('input', checked)"
>
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 89b095fbfc1..264ea36137f 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
@@ -480,6 +480,7 @@ export default {
v-if="mr.testResultsPath"
class="js-reports-container"
:endpoint="mr.testResultsPath"
+ :head-blob-path="mr.headBlobPath"
:pipeline-path="mr.pipeline.path"
/>
@@ -513,7 +514,7 @@ export default {
>
{{
s__(
- 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result',
+ 'mrWidget|If the last pipeline ran in the fork project, it may be inaccurate. Before merge, we advise running a pipeline in this project.',
)
}}
</mr-widget-alert-message>
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 13ea07884b1..871aa880b36 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -11,11 +11,10 @@ query getState($projectPath: ID!, $iid: String!) {
mergeError
mergeStatus
mergeableDiscussionsState
- pipelines(first: 1) {
- nodes {
- status
- warnings
- }
+ headPipeline {
+ id
+ status
+ warnings
}
shouldBeRebased
sourceBranchExists
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index 8ee45b05431..367b9ad1cdf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -30,13 +30,11 @@ fragment ReadyToMerge on Project {
message
}
}
- pipelines(first: 1) {
- nodes {
- id
- status
- path
- active
- }
+ headPipeline {
+ id
+ status
+ path
+ active
}
}
}
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 7ccbd771379..f57b638dd81 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
@@ -60,6 +60,7 @@ export default class MergeRequestStore {
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type;
+ this.mergeRequestWidgetPath = data.merge_request_widget_path;
if (data.issues_links) {
const links = data.issues_links;
@@ -163,7 +164,7 @@ export default class MergeRequestStore {
setGraphqlData(project) {
const { mergeRequest } = project;
- const pipeline = mergeRequest.pipelines?.nodes?.[0];
+ const pipeline = mergeRequest.headPipeline;
this.projectArchived = project.archived;
this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index f7b49a85b83..3905ce2596c 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -21,7 +21,7 @@ import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import { SEVERITY_LEVELS } from '../constants';
+import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql';
import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql';
import alertQuery from '../graphql/queries/alert_details.query.graphql';
@@ -92,6 +92,9 @@ export default {
projectIssuesPath: {
default: '',
},
+ statuses: {
+ default: PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
trackAlertsDetailsViewsOptions: {
default: null,
},
@@ -367,7 +370,7 @@ export default {
>
{{ alert.runbook }}
</alert-summary-row>
- <alert-details-table :alert="alert" :loading="loading" />
+ <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" />
</gl-tab>
<gl-tab
v-if="!isThreatMonitoringPage"
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
index a01bd462196..554c7a573fe 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
@@ -19,10 +19,6 @@ export default {
projectId: {
default: '',
},
- // TODO remove this limitation in https://gitlab.com/gitlab-org/gitlab/-/issues/296717
- isThreatMonitoringPage: {
- default: false,
- },
},
props: {
alert: {
@@ -66,7 +62,6 @@ export default {
@alert-error="$emit('alert-error', $event)"
/>
<sidebar-status
- v-if="!isThreatMonitoringPage"
:project-path="projectPath"
:alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
index 8d5eb24ed1d..672761af1cf 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
@@ -3,6 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import { PAGE_CONFIG } from '../constants';
export default {
i18n: {
@@ -11,11 +12,6 @@ export default {
),
UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'),
},
- statuses: {
- TRIGGERED: s__('AlertManagement|Triggered'),
- ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
- RESOLVED: s__('AlertManagement|Resolved'),
- },
components: {
GlDropdown,
GlDropdownItem,
@@ -42,6 +38,11 @@ export default {
type: Boolean,
required: true,
},
+ statuses: {
+ type: Object,
+ required: false,
+ default: () => PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
},
computed: {
dropdownClass() {
@@ -57,13 +58,13 @@ export default {
mutation: updateAlertStatusMutation,
variables: {
iid: this.alert.iid,
- status: status.toUpperCase(),
+ status,
projectPath: this.projectPath,
},
})
.then((resp) => {
if (this.trackAlertStatusUpdateOptions) {
- this.trackStatusUpdate(status);
+ this.trackStatusUpdate(this.statuses[status]);
}
const errors = resp.data?.updateAlertStatus?.errors || [];
@@ -99,7 +100,7 @@ export default {
<gl-dropdown
ref="dropdown"
right
- :text="$options.statuses[alert.status]"
+ :text="statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
@keydown.esc.native="$emit('hide-dropdown')"
@@ -110,12 +111,12 @@ export default {
</p>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item
- v-for="(label, field) in $options.statuses"
+ v-for="(label, field) in statuses"
:key="field"
data-testid="statusDropdownItem"
- :active="label.toUpperCase() === alert.status"
+ :active="field === alert.status"
:active-class="'is-active'"
- @click="updateAlertStatus(label)"
+ @click="updateAlertStatus(field)"
>
{{ label }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
index 0a2bad5510b..3822b9153a4 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -1,14 +1,9 @@
<script>
import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { PAGE_CONFIG } from '../../constants';
import AlertStatus from '../alert_status.vue';
export default {
- statuses: {
- TRIGGERED: s__('AlertManagement|Triggered'),
- ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
- RESOLVED: s__('AlertManagement|Resolved'),
- },
components: {
GlIcon,
GlLoadingIcon,
@@ -16,6 +11,11 @@ export default {
GlSprintf,
AlertStatus,
},
+ inject: {
+ statuses: {
+ default: PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
+ },
props: {
projectPath: {
type: String,
@@ -94,6 +94,7 @@ export default {
:project-path="projectPath"
:is-dropdown-showing="isDropdownShowing"
:is-sidebar="true"
+ :statuses="statuses"
@alert-error="$emit('alert-error', $event)"
@hide-dropdown="hideDropdown"
@handle-updating="handleUpdating"
@@ -103,14 +104,11 @@ export default {
<p
v-else-if="!isDropdownShowing"
class="value gl-m-0"
- :class="{ 'no-value': !$options.statuses[alert.status] }"
+ :class="{ 'no-value': !statuses[alert.status] }"
>
- <span
- v-if="$options.statuses[alert.status]"
- class="gl-text-gray-500"
- data-testid="status"
- >{{ $options.statuses[alert.status] }}</span
- >
+ <span v-if="statuses[alert.status]" class="gl-text-gray-500" data-testid="status">
+ {{ statuses[alert.status] }}
+ </span>
<span v-else>
{{ s__('AlertManagement|None') }}
</span>
diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
index 2ab5160534c..6cc70739eaa 100644
--- a/app/assets/javascripts/vue_shared/alert_details/constants.js
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -13,6 +13,11 @@ export const SEVERITY_LEVELS = {
export const PAGE_CONFIG = {
OPERATIONS: {
TITLE: 'OPERATIONS',
+ STATUSES: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
// Tracks snowplow event when user views alert details
TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: {
category: 'Alert Management',
@@ -27,5 +32,11 @@ export const PAGE_CONFIG = {
},
THREAT_MONITORING: {
TITLE: 'THREAT_MONITORING',
+ STATUSES: {
+ TRIGGERED: s__('ThreatMonitoring|Unreviewed'),
+ ACKNOWLEDGED: s__('ThreatMonitoring|In review'),
+ RESOLVED: s__('ThreatMonitoring|Resolved'),
+ IGNORED: s__('ThreatMonitoring|Dismissed'),
+ },
},
};
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 50f2e63702b..fda405c0fa5 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -42,7 +42,8 @@ export default (selector) => {
}),
});
- apolloProvider.clients.defaultClient.cache.writeData({
+ apolloProvider.clients.defaultClient.cache.writeQuery({
+ query: sidebarStatusQuery,
data: {
sidebarStatus: false,
},
@@ -54,6 +55,7 @@ export default (selector) => {
page,
projectIssuesPath,
projectId,
+ statuses: PAGE_CONFIG[page].STATUSES,
};
if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
index 3d49a1cb1c5..a74e9d97143 100644
--- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue
+++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue
@@ -7,6 +7,7 @@ import {
splitCamelCase,
} from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
+import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants';
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
@@ -42,6 +43,11 @@ export default {
type: Boolean,
required: true,
},
+ statuses: {
+ type: Object,
+ required: false,
+ default: () => PAGE_CONFIG.OPERATIONS.STATUSES,
+ },
},
fields: [
{
@@ -71,6 +77,8 @@ export default {
let value;
if (fieldName === 'environment') {
value = fieldValue?.name;
+ } else if (fieldName === 'status') {
+ value = this.statuses[fieldValue] || fieldValue;
} else {
value = fieldValue;
}
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 82b3545117f..08d3e163257 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -44,6 +44,16 @@ export default {
required: false,
default: () => [],
},
+ selectedClass: {
+ type: String,
+ required: false,
+ default: 'selected',
+ },
+ },
+ data() {
+ return {
+ isMenuOpen: false,
+ };
},
computed: {
groupedDefaultAwards() {
@@ -68,7 +78,7 @@ export default {
methods: {
getAwardClassBindings(awardList) {
return {
- selected: this.hasReactionByCurrentUser(awardList),
+ [this.selectedClass]: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID,
};
},
@@ -147,6 +157,11 @@ export default {
const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
this.$emit('award', parsedName);
+
+ if (document.activeElement) document.activeElement.blur();
+ },
+ setIsMenuOpen(menuOpen) {
+ this.isMenuOpen = menuOpen;
},
},
};
@@ -172,8 +187,10 @@ export default {
<div v-if="canAwardEmoji" class="award-menu-holder">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
- toggle-class="add-reaction-button gl-relative!"
+ :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]"
@click="handleAward"
+ @shown="setIsMenuOpen(true)"
+ @hidden="setIsMenuOpen(false)"
>
<template #button-content>
<span class="reaction-control-icon reaction-control-icon-neutral">
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 db61d0f6b05..9c2ed5abf04 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js
@@ -11,6 +11,16 @@ export default {
type: String,
required: true,
},
+ isRawContent: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ fileName: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
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 5bb31f55e6c..f477610ff1d 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -1,14 +1,17 @@
<script>
/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
export default {
components: {
GlIcon,
+ EditorLite: () =>
+ import(/* webpackChunkName: 'EditorLite' */ '~/vue_shared/components/editor_lite.vue'),
},
- mixins: [ViewerMixin],
+ mixins: [ViewerMixin, glFeatureFlagsMixin()],
inject: ['blobHash'],
data() {
return {
@@ -19,6 +22,9 @@ export default {
lineNumbers() {
return this.content.split('\n').length;
},
+ refactorBlobViewerEnabled() {
+ return this.glFeatures.refactorBlobViewer;
+ },
},
mounted() {
const { hash } = window.location;
@@ -45,27 +51,31 @@ export default {
};
</script>
<template>
- <div
- class="file-content code js-syntax-highlight"
- data-qa-selector="file_content"
- :class="$options.userColorScheme"
- >
- <div class="line-numbers">
- <a
- v-for="line in lineNumbers"
- :id="`L${line}`"
- :key="line"
- class="diff-line-num js-line-number"
- :href="`#LC${line}`"
- :data-line-number="line"
- @click="scrollToLine(`#LC${line}`)"
- >
- <gl-icon :size="12" name="link" />
- {{ line }}
- </a>
- </div>
- <div class="blob-content">
- <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ <div>
+ <editor-lite
+ 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">
+ <a
+ v-for="line in lineNumbers"
+ :id="`L${line}`"
+ :key="line"
+ class="diff-line-num js-line-number"
+ :href="`#LC${line}`"
+ :data-line-number="line"
+ @click="scrollToLine(`#LC${line}`)"
+ >
+ <gl-icon :size="12" name="link" />
+ {{ line }}
+ </a>
+ </div>
+ <div class="blob-content">
+ <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index cd5f63afc79..f14e1992901 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -56,6 +56,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
data-qa-selector="copy_ssh_url_button"
icon="copy-to-clipboard"
@@ -75,6 +76,7 @@ export default {
<gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
+ :aria-label="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
data-qa-selector="copy_http_url_button"
icon="copy-to-clipboard"
diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
new file mode 100644
index 00000000000..1ff0938d086
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlModal, GlSprintf, GlButton } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlButton,
+ },
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ labelName: '',
+ subjectName: '',
+ destroyPath: '',
+ modalId: uniqueId('modal-delete-label-'),
+ };
+ },
+ mounted() {
+ document.querySelectorAll(this.selector).forEach((button) => {
+ button.addEventListener('click', (e) => {
+ e.preventDefault();
+
+ const { labelName, subjectName, destroyPath } = button.dataset;
+ this.labelName = labelName;
+ this.subjectName = subjectName;
+ this.destroyPath = destroyPath;
+ this.openModal();
+ });
+ });
+ },
+ methods: {
+ openModal() {
+ this.$refs.modal.show();
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" :modal-id="modalId">
+ <template #modal-title>
+ <gl-sprintf :message="__('Delete label: %{labelName}')">
+ <template #labelName>
+ {{ labelName }}
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-sprintf
+ :message="
+ __(
+ `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`,
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <template #modal-footer>
+ <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ category="primary"
+ variant="danger"
+ :href="destroyPath"
+ data-method="delete"
+ data-testid="delete-button"
+ >{{ __('Delete label') }}</gl-button
+ >
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
deleted file mode 100644
index 3f55f43edbb..00000000000
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<script>
-/* eslint-disable vue/require-default-prop */
-import { __ } from '~/locale';
-
-export default {
- name: 'DeprecatedModal', // use GlModal instead
-
- props: {
- id: {
- type: String,
- required: false,
- },
- title: {
- type: String,
- required: false,
- },
- text: {
- type: String,
- required: false,
- },
- hideFooter: {
- type: Boolean,
- required: false,
- default: false,
- },
- kind: {
- type: String,
- required: false,
- default: 'primary',
- },
- modalDialogClass: {
- type: String,
- required: false,
- default: '',
- },
- closeKind: {
- type: String,
- required: false,
- default: 'default',
- },
- closeButtonLabel: {
- type: String,
- required: false,
- default: __('Cancel'),
- },
- primaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- secondaryButtonLabel: {
- type: String,
- required: false,
- default: '',
- },
- submitDisabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-
- computed: {
- btnKindClass() {
- return {
- [`btn-${this.kind}`]: true,
- };
- },
- btnCancelKindClass() {
- return {
- [`btn-${this.closeKind}`]: true,
- };
- },
- },
-
- methods: {
- emitCancel(event) {
- this.$emit('cancel', event);
- },
- emitSubmit(event) {
- this.$emit('submit', event);
- },
- },
-};
-</script>
-
-<template>
- <div class="modal-open">
- <div :id="id" :class="id ? '' : 'd-block'" class="modal" role="dialog" tabindex="-1">
- <div :class="modalDialogClass" class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <slot name="header">
- <h4 class="modal-title float-left">{{ title }}</h4>
- <button
- type="button"
- class="close float-right"
- data-dismiss="modal"
- :aria-label="__('Close')"
- @click="emitCancel($event)"
- >
- <span aria-hidden="true">&times;</span>
- </button>
- </slot>
- </div>
- <div class="modal-body">
- <slot :text="text" name="body">
- <p>{{ text }}</p>
- </slot>
- </div>
- <div v-if="!hideFooter" class="modal-footer">
- <button
- :class="btnCancelKindClass"
- type="button"
- class="btn"
- data-dismiss="modal"
- @click="emitCancel($event)"
- >
- {{ closeButtonLabel }}
- </button>
-
- <slot v-if="secondaryButtonLabel" name="secondary-button">
- <button v-if="secondaryButtonLabel" type="button" class="btn" data-dismiss="modal">
- {{ secondaryButtonLabel }}
- </button>
- </slot>
-
- <button
- v-if="primaryButtonLabel"
- :disabled="submitDisabled"
- :class="btnKindClass"
- type="button"
- class="btn js-primary-button"
- data-dismiss="modal"
- data-qa-selector="save_changes_button"
- @click="emitSubmit($event)"
- >
- {{ primaryButtonLabel }}
- </button>
- </div>
- </div>
- </div>
- </div>
- <div v-if="!id" class="modal-backdrop fade show"></div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index 4ec54b33bce..fbadb202d51 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -3,6 +3,7 @@ import { GlIcon } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
+import { keysFor, MR_GO_TO_FILE } from '~/behaviors/shortcuts/keybindings';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import Item from './item.vue';
@@ -128,7 +129,7 @@ export default {
this.focusedIndex = 0;
}
- Mousetrap.bind(['t', 'mod+p'], (e) => {
+ Mousetrap.bind(keysFor(MR_GO_TO_FILE), (e) => {
if (e.preventDefault) {
e.preventDefault();
}
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index f7cfb59be01..e622b505570 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -128,6 +128,7 @@ const fileExtensionIcons = {
c: 'c',
m: 'c',
h: 'h',
+ 'c++': 'cpp',
cc: 'cpp',
cpp: 'cpp',
mm: 'cpp',
@@ -402,14 +403,15 @@ const fileNameIcons = {
'gradle.properties': 'gradle',
gradlew: 'gradle',
'gradle-wrapper.properties': 'gradle',
- license: 'certificate',
- 'license.md': 'certificate',
- 'license.md.rendered': 'certificate',
- 'license.txt': 'certificate',
- licence: 'certificate',
- 'licence.md': 'certificate',
- 'licence.md.rendered': 'certificate',
- 'licence.txt': 'certificate',
+ COPYING: 'certificate',
+ 'COPYING.LESSER': 'certificate',
+ LICENSE: 'certificate',
+ LICENCE: 'certificate',
+ 'LICENSE.md': 'certificate',
+ 'LICENCE.md': 'certificate',
+ 'LICENSE.txt': 'certificate',
+ 'LICENCE.txt': 'certificate',
+ '.gitlab-license': 'certificate',
dockerfile: 'docker',
'docker-compose.yml': 'docker',
'.mailmap': 'email',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 97a8f681faf..107ced550c1 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -58,7 +58,7 @@ export default {
type: String,
required: false,
default: '',
- validator: (value) => value === '' || /(_desc)|(_asc)/g.test(value),
+ validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value),
},
showCheckbox: {
type: Boolean,
@@ -363,6 +363,7 @@ export default {
<gl-button
v-gl-tooltip
:title="sortDirectionTooltip"
+ :aria-label="sortDirectionTooltip"
:icon="sortDirectionIcon"
class="flex-shrink-1"
@click="handleSortDirectionClick"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
index d53c829a48e..aeb698a3adb 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue
@@ -45,6 +45,9 @@ export default {
activeAuthor() {
return this.authors.find((author) => author.username.toLowerCase() === this.currentValue);
},
+ activeAuthorAvatar() {
+ return this.avatarUrl(this.activeAuthor);
+ },
},
watch: {
active: {
@@ -74,6 +77,9 @@ export default {
this.loading = false;
});
},
+ avatarUrl(author) {
+ return author.avatarUrl || author.avatar_url;
+ },
searchAuthors: debounce(function debouncedSearch({ data }) {
this.fetchAuthorBySearchTerm(data);
}, DEBOUNCE_DELAY),
@@ -92,7 +98,7 @@ export default {
<gl-avatar
v-if="activeAuthor"
:size="16"
- :src="activeAuthor.avatar_url"
+ :src="activeAuthorAvatar"
shape="circle"
class="gl-mr-2"
/>
@@ -115,7 +121,7 @@ export default {
:value="author.username"
>
<div class="d-flex">
- <gl-avatar :size="32" :src="author.avatar_url" />
+ <gl-avatar :size="32" :src="avatarUrl(author)" />
<div>
<div>{{ author.name }}</div>
<div>@{{ author.username }}</div>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
new file mode 100644
index 00000000000..98190d716c9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -0,0 +1,105 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import { __ } from '~/locale';
+
+import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
+import { stripQuotes } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ emojis: this.config.initialEmojis || [],
+ defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeEmoji() {
+ return this.emojis.find(
+ (emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue),
+ );
+ },
+ },
+ methods: {
+ fetchEmojiBySearchTerm(searchTerm) {
+ this.loading = true;
+ this.config
+ .fetchEmojis(searchTerm)
+ .then((res) => {
+ this.emojis = Array.isArray(res) ? res : res.data;
+ })
+ .catch(() => createFlash(__('There was a problem fetching emojis.')))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchEmojis: debounce(function debouncedSearch({ data }) {
+ this.fetchEmojiBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchEmojis"
+ >
+ <template #view="{ inputValue }">
+ <gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" />
+ <span v-else>{{ inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="emoji in defaultEmojis"
+ :key="emoji.value"
+ :value="emoji.value"
+ >
+ {{ emoji.value }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultEmojis.length" />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="emoji in emojis"
+ :key="emoji.name"
+ :value="emoji.name"
+ >
+ <div class="gl-display-flex">
+ <gl-emoji :data-name="emoji.name" />
+ <span class="gl-ml-3">{{ emoji.name }}</span>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
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
new file mode 100644
index 00000000000..101c7150c55
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import createFlash from '~/flash';
+import { isNumeric } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import { DEBOUNCE_DELAY } from '../constants';
+import { stripQuotes } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ epics: this.config.initialEpics || [],
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ /*
+ * When the URL contains the epic_iid, we'd get: '123'
+ */
+ if (isNumeric(this.value.data)) {
+ return parseInt(this.value.data, 10);
+ }
+
+ /*
+ * When the token is added in current session it'd be: 'Foo::&123'
+ */
+ const id = this.value.data.split('::&')[1];
+
+ if (id) {
+ return parseInt(id, 10);
+ }
+
+ return this.value.data;
+ },
+ activeEpic() {
+ const currentValueIsString = typeof this.currentValue === 'string';
+ return this.epics.find(
+ (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
+ );
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.epics.length) {
+ this.searchEpics({ data: this.currentValue });
+ }
+ },
+ },
+ },
+ methods: {
+ fetchEpicsBySearchTerm(searchTerm = '') {
+ this.loading = true;
+ this.config
+ .fetchEpics(searchTerm)
+ .then(({ data }) => {
+ this.epics = data;
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ fetchSingleEpic(iid) {
+ this.loading = true;
+ this.config
+ .fetchSingleEpic(iid)
+ .then(({ data }) => {
+ this.epics = [data];
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchEpics: debounce(function debouncedSearch({ data }) {
+ if (isNumeric(data)) {
+ return this.fetchSingleEpic(data);
+ }
+ return this.fetchEpicsBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+
+ getEpicValue(epic) {
+ return `${epic.title}::&${epic.iid}`;
+ },
+ },
+ stripQuotes,
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchEpics"
+ >
+ <template #view="{ inputValue }">
+ <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
+ </template>
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="epic in epics"
+ :key="epic.id"
+ :value="getEpicValue(epic)"
+ >
+ <div>{{ epic.title }}</div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 9c2a644b7a9..76b005772ec 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -46,7 +46,7 @@ export default {
},
activeLabel() {
return this.labels.find(
- (label) => label.title.toLowerCase() === stripQuotes(this.currentValue),
+ (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue),
);
},
containerStyle() {
@@ -69,6 +69,21 @@ export default {
},
},
methods: {
+ /**
+ * There's an inconsistency between private and public API
+ * for labels where label name is included in a different
+ * property;
+ *
+ * Private API => `label.title`
+ * Public API => `label.name`
+ *
+ * This method allows compatibility as there may be instances
+ * where `config.fetchLabels` provided externally may still be
+ * using either of the two APIs.
+ */
+ getLabelName(label) {
+ return label.name || label.title;
+ },
fetchLabelBySearchTerm(searchTerm) {
this.loading = true;
this.config
@@ -85,7 +100,7 @@ export default {
});
},
searchLabels: debounce(function debouncedSearch({ data }) {
- this.fetchLabelBySearchTerm(data);
+ if (!this.loading) this.fetchLabelBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
@@ -100,7 +115,7 @@ export default {
>
<template #view-token="{ inputValue, cssClasses, listeners }">
<gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners"
- >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token
+ >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token
>
</template>
<template #suggestions>
@@ -114,13 +129,17 @@ export default {
<gl-dropdown-divider v-if="defaultLabels.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
- <gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title">
- <div class="gl-display-flex">
+ <gl-filtered-search-suggestion
+ v-for="label in labels"
+ :key="label.id"
+ :value="getLabelName(label)"
+ >
+ <div class="gl-display-flex gl-align-items-center">
<span
:style="{ backgroundColor: label.color }"
class="gl-display-inline-block mr-2 p-2"
></span>
- <div>{{ label.title }}</div>
+ <div>{{ getLabelName(label) }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue
deleted file mode 100644
index b649dac029a..00000000000
--- a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue
+++ /dev/null
@@ -1,49 +0,0 @@
-<script>
-import { GlToggle } from '@gitlab/ui';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-export default {
- name: 'GlToggleVuex',
- components: {
- GlToggle,
- },
- props: {
- stateProperty: {
- type: String,
- required: true,
- },
- storeModule: {
- type: String,
- required: false,
- default: null,
- },
- setAction: {
- type: String,
- required: false,
- default() {
- return `set${capitalizeFirstCharacter(this.stateProperty)}`;
- },
- },
- },
- computed: {
- value: {
- get() {
- const { state } = this.$store;
- const { stateProperty, storeModule } = this;
- return storeModule ? state[storeModule][stateProperty] : state[stateProperty];
- },
- set(value) {
- const { stateProperty, storeModule, setAction } = this;
- const action = storeModule ? `${storeModule}/${setAction}` : setAction;
- this.$store.dispatch(action, { key: stateProperty, value });
- },
- },
- },
-};
-</script>
-
-<template>
- <gl-toggle v-model="value">
- <slot v-bind="{ value }"></slot>
- </gl-toggle>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index b4cac13168a..f169921d8a6 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -121,13 +121,7 @@ export default {
:title="user.email"
class="js-user-link commit-committer-link"
>
- <user-avatar-image
- :img-src="avatarUrl"
- :img-alt="userAvatarAltText"
- :tooltip-text="user.name"
- :img-size="24"
- />
-
+ <user-avatar-image :img-src="avatarUrl" :img-alt="userAvatarAltText" :size="24" />
{{ user.name }}
</gl-link>
<gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]">
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 051c65bae70..f36b9107a6e 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlPopover } from '@gitlab/ui';
+import { GlButton, GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
/**
* Render a button with a question mark icon
@@ -11,6 +11,9 @@ export default {
GlButton,
GlPopover,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
options: {
type: Object,
@@ -22,15 +25,13 @@ export default {
</script>
<template>
<span>
- <gl-button ref="popoverTrigger" variant="link" icon="question" tabindex="0" />
- <gl-popover triggers="hover focus" :target="() => $refs.popoverTrigger.$el" v-bind="options">
- <template #title>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <span v-html="options.title"></span>
+ <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" />
+ <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options">
+ <template v-if="options.title" #title>
+ <span v-safe-html="options.title"></span>
</template>
<template #default>
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div v-html="options.content"></div>
+ <div v-safe-html="options.content"></div>
</template>
</gl-popover>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js
new file mode 100644
index 00000000000..b115b1fb34b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js
@@ -0,0 +1,35 @@
+/**
+ * Return the union of the given components' props options. Required props take
+ * precendence over non-required props of the same name.
+ *
+ * This makes two assumptions:
+ * - All given components define their props in verbose object format.
+ * - The components all agree on the `type` of a common prop.
+ *
+ * @param {object[]} components The components to derive the union from.
+ * @returns {object} The union of the props of the given components.
+ */
+export const propsUnion = (components) =>
+ components.reduce((acc, component) => {
+ Object.entries(component.props ?? {}).forEach(([propName, propOptions]) => {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof propOptions !== 'object' || !('type' in propOptions)) {
+ throw new Error(
+ `Cannot create props union: expected verbose prop options for prop "${propName}"`,
+ );
+ }
+
+ if (propName in acc && acc[propName]?.type !== propOptions?.type) {
+ throw new Error(
+ `Cannot create props union: incompatible prop types for prop "${propName}"`,
+ );
+ }
+ }
+
+ if (!(propName in acc) || propOptions.required) {
+ acc[propName] = propOptions;
+ }
+ });
+
+ return acc;
+ }, {});
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 10887aee689..90ac20fe748 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -34,6 +34,7 @@ export default {
boundary="window"
right
menu-class="gl-w-full!"
+ data-qa-selector="apply_suggestion_button"
@shown="$refs.commitMessage.$el.focus()"
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
@@ -44,12 +45,14 @@ export default {
v-model="message"
:placeholder="defaultCommitMessage"
submit-on-enter
+ data-qa-selector="commit_message_textbox"
@submit="onApply"
/>
<gl-button
class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right"
category="primary"
variant="success"
+ data-qa-selector="commit_with_custom_message_button"
@click="onApply"
>
{{ __('Apply') }}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 25d01dc550f..80b7a9b7d05 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -62,6 +62,11 @@ export default {
required: false,
default: true,
},
+ uploadsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -72,6 +77,11 @@ export default {
required: false,
default: null,
},
+ lines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
note: {
type: Object,
required: false,
@@ -110,6 +120,20 @@ export default {
return this.referencedUsers.length >= referencedUsersThreshold;
},
lineContent() {
+ if (this.lines.length) {
+ return this.lines
+ .map((line) => {
+ const { rich_text: richText, text } = line;
+
+ if (text) {
+ return text;
+ }
+
+ return unescape(stripHtml(richText).replace(/\n/g, ''));
+ })
+ .join('\\n');
+ }
+
if (this.line) {
const { rich_text: richText, text } = this.line;
@@ -144,6 +168,9 @@ export default {
false,
);
},
+ suggestionsStartIndex() {
+ return Math.max(this.lines.length - 1, 0);
+ },
},
watch: {
isSubmitting(isSubmitting) {
@@ -229,12 +256,14 @@ export default {
ref="gl-form"
:class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }"
class="js-vue-markdown-field md-area position-relative gfm-form"
+ :data-uploads-path="uploadsPath"
>
<markdown-header
:preview-markdown="previewMarkdown"
:line-content="lineContent"
:can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover"
+ :suggestion-start-index="suggestionsStartIndex"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 5bc1786d692..01cf0beea3a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -1,6 +1,7 @@
<script>
import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import $ from 'jquery';
+import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm';
@@ -36,6 +37,11 @@ export default {
required: false,
default: false,
},
+ suggestionStartIndex: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -53,7 +59,9 @@ export default {
].join('\n');
},
mdSuggestion() {
- return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
+ return [['```', `suggestion:-${this.suggestionStartIndex}+0`].join(''), `{text}`, '```'].join(
+ '\n',
+ );
},
isMac() {
// Accessing properties using ?. to allow tests to use
@@ -116,6 +124,11 @@ export default {
.catch(() => {});
},
},
+ shortcuts: {
+ bold: keysFor(BOLD_TEXT),
+ italic: keysFor(ITALIC_TEXT),
+ link: keysFor(LINK_TEXT),
+ },
};
</script>
@@ -143,7 +156,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey })
"
- shortcuts="mod+b"
+ :shortcuts="$options.shortcuts.bold"
icon="bold"
/>
<toolbar-button
@@ -151,7 +164,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey })
"
- shortcuts="mod+i"
+ :shortcuts="$options.shortcuts.italic"
icon="italic"
/>
<toolbar-button
@@ -208,7 +221,7 @@ export default {
:button-title="
sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey })
"
- shortcuts="mod+k"
+ :shortcuts="$options.shortcuts.link"
icon="link"
/>
</div>
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 7c28e74e256..83b8a6ae562 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,13 +1,11 @@
<script>
import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ApplySuggestion from './apply_suggestion.vue';
export default {
components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion },
directives: { 'gl-tooltip': GlTooltipDirective },
- mixins: [glFeatureFlagsMixin()],
props: {
batchSuggestionsCount: {
type: Number,
@@ -59,9 +57,6 @@ export default {
};
},
computed: {
- canBeBatched() {
- return Boolean(this.glFeatures.batchSuggestions);
- },
isApplying() {
return this.isApplyingSingle || this.isApplyingBatch;
},
@@ -118,7 +113,7 @@ export default {
<gl-loading-icon class="d-flex-center mr-2" />
<span>{{ applyingSuggestionsMessage }}</span>
</div>
- <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center">
+ <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"
@@ -142,7 +137,7 @@ export default {
</div>
<div v-else class="d-flex align-items-center">
<gl-button
- v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton"
+ v-if="suggestionsCount > 1 && !isDisableButton"
class="btn-inverted js-add-to-batch-btn btn-grouped"
data-qa-selector="add_suggestion_batch_button"
:disabled="isDisableButton"
@@ -152,6 +147,7 @@ export default {
</gl-button>
<apply-suggestion
v-if="isLoggedIn"
+ v-gl-tooltip.viewport="tooltipMessage"
:disabled="isDisableButton"
:default-commit-message="defaultCommitMessage"
class="gl-ml-3"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 387b100a04f..7393a8791b7 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,13 +1,18 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
+import { isExperimentVariant } from '~/experimentation/utils';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
+ inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
+ InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@@ -29,6 +34,9 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
+ inviteCommentEnabled() {
+ return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
+ },
},
};
</script>
@@ -37,9 +45,9 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank">{{
- __('Markdown is supported')
- }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank">
+ {{ __('Markdown is supported') }}
+ </gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
@@ -59,6 +67,16 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
+ <invite-members-trigger
+ v-if="inviteCommentEnabled"
+ classes="gl-mr-3 gl-vertical-align-text-bottom"
+ :display-text="s__('InviteMember|Invite Member')"
+ icon="assignee"
+ variant="link"
+ :track-experiment="$options.inviteMembersInComment"
+ :trigger-source="$options.inviteMembersInComment"
+ data-track-event="comment_invite_click"
+ />
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
index 7b36d57dfbf..38afd56bae6 100644
--- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
+++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue
@@ -101,6 +101,7 @@ export default {
:data-clipboard-target="target"
:data-clipboard-text="text"
:title="title"
+ :aria-label="title"
:category="category"
icon="copy-to-clipboard"
/>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 50972a8c32c..149909d263e 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -28,6 +28,7 @@ import {
import $ from 'jquery';
import { mapGetters, mapActions, mapState } from 'vuex';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
+import { __ } from '~/locale';
import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -37,6 +38,9 @@ import TimelineEntryItem from './timeline_entry_item.vue';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
export default {
+ i18n: {
+ deleteButtonLabel: __('Remove description history'),
+ },
name: 'SystemNote',
components: {
GlIcon,
@@ -139,7 +143,8 @@ export default {
<gl-button
v-if="displayDeleteButton"
v-gl-tooltip
- :title="__('Remove description history')"
+ :title="$options.i18n.deleteButtonLabel"
+ :aria-label="$options.i18n.deleteButtonLabel"
variant="default"
category="tertiary"
icon="remove"
diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
new file mode 100644
index 00000000000..ff2847624c5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ schedules: {
+ type: Array,
+ required: true,
+ },
+ userName: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.isCurrentUser
+ ? s__('OnCallSchedules|You are currently a part of:')
+ : sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), {
+ name: this.userName,
+ });
+ },
+ footer() {
+ return this.isCurrentUser
+ ? s__(
+ 'OnCallSchedules|Removing yourself may put your on-call team at risk of missing a notification.',
+ )
+ : s__(
+ 'OnCallSchedules|Removing this user may put their on-call team at risk of missing a notification.',
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <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>
+ </template>
+ <template #project>
+ <gl-link :href="schedule.projectUrl" target="_blank">{{
+ schedule.projectName
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+
+ <p data-testid="footer">{{ footer }}</p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
deleted file mode 100644
index e193883b6e9..00000000000
--- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
-export const callbackName = 'recaptchaDialogCallback';
-
-export const eventHub = createEventHub();
-
-const throwDuplicateCallbackError = () => {
- throw new Error(`${callbackName} is already defined!`);
-};
-
-if (window[callbackName]) {
- throwDuplicateCallbackError();
-}
-
-const callback = () => eventHub.$emit('submit');
-
-Object.defineProperty(window, callbackName, {
- get: () => callback,
- set: throwDuplicateCallbackError,
-});
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
deleted file mode 100644
index fc1f3675a3d..00000000000
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ /dev/null
@@ -1,90 +0,0 @@
-<script>
-/* eslint-disable vue/no-v-html */
-import DeprecatedModal from './deprecated_modal.vue';
-import { eventHub } from './recaptcha_eventhub';
-
-export default {
- name: 'RecaptchaModal',
-
- components: {
- DeprecatedModal,
- },
-
- props: {
- html: {
- type: String,
- required: false,
- default: '',
- },
- },
-
- data() {
- return {
- script: {},
- scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js',
- };
- },
-
- watch: {
- html() {
- this.appendRecaptchaScript();
- },
- },
-
- mounted() {
- eventHub.$on('submit', this.submit);
-
- if (this.html) {
- this.appendRecaptchaScript();
- }
- },
-
- beforeDestroy() {
- eventHub.$off('submit', this.submit);
- },
-
- methods: {
- appendRecaptchaScript() {
- this.removeRecaptchaScript();
-
- const script = document.createElement('script');
- script.src = this.scriptSrc;
- script.classList.add('js-recaptcha-script');
- script.async = true;
- script.defer = true;
-
- this.script = script;
-
- document.body.appendChild(script);
- },
-
- removeRecaptchaScript() {
- if (this.script instanceof Element) this.script.remove();
- },
-
- close() {
- this.removeRecaptchaScript();
- this.$emit('close');
- },
-
- submit() {
- this.$el.querySelector('form').submit();
- },
- },
-};
-</script>
-
-<template>
- <deprecated-modal
- :hide-footer="true"
- :title="__('Please solve the reCAPTCHA')"
- kind="warning"
- class="recaptcha-modal js-recaptcha-modal"
- @cancel="close"
- >
- <div slot="body">
- <p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p>
- <div ref="recaptcha" v-html="html"></div>
- </div>
- </deprecated-modal>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index 62453a25f62..0825c3a76ea 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -1,5 +1,6 @@
<script>
import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
const ASCENDING_ORDER = 'asc';
const DESCENDING_ORDER = 'desc';
@@ -45,18 +46,60 @@ export default {
isSortAscending() {
return this.sorting.sort === ASCENDING_ORDER;
},
+ baselineQueryStringFilters() {
+ return this.tokens.reduce((acc, curr) => {
+ acc[curr.type] = '';
+ return acc;
+ }, {});
+ },
},
methods: {
+ generateQueryData({ sorting = {}, filter = [] } = {}) {
+ // Ensure that we clean up the query when we remove a token from the search
+ const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] };
+
+ filter.forEach((f) => {
+ if (f.type === FILTERED_SEARCH_TERM) {
+ result.search.push(f.value.data);
+ } else {
+ result[f.type] = f.value.data;
+ }
+ });
+ return result;
+ },
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
+ const newQueryString = this.generateQueryData({
+ sorting: { ...this.sorting, sort },
+ filter: this.filter,
+ });
this.$emit('sorting:changed', { sort });
+ this.$emit('query:changed', newQueryString);
},
onSortItemClick(item) {
+ const newQueryString = this.generateQueryData({
+ sorting: { ...this.sorting, orderBy: item },
+ filter: this.filter,
+ });
this.$emit('sorting:changed', { orderBy: item });
+ this.$emit('query:changed', newQueryString);
+ },
+ submitSearch() {
+ const newQueryString = this.generateQueryData({
+ sorting: this.sorting,
+ filter: this.filter,
+ });
+ this.$emit('filter:submit');
+ this.$emit('query:changed', newQueryString);
},
clearSearch() {
+ const newQueryString = this.generateQueryData({
+ sorting: this.sorting,
+ });
+
this.$emit('filter:changed', []);
this.$emit('filter:submit');
+ this.$emit('query:changed', newQueryString);
},
},
};
@@ -69,7 +112,7 @@ export default {
class="gl-mr-4 gl-flex-fill-1"
:placeholder="__('Filter results')"
:available-tokens="tokens"
- @submit="$emit('filter:submit')"
+ @submit="submitSearch"
@clear="clearSearch"
/>
<gl-sorting
diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
index 88d1b15aee3..dff3a6a8c3f 100644
--- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -1,8 +1,10 @@
<script>
import { GlFormCheckbox, GlModal } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { parseBoolean } from '~/lib/utils/common_utils';
import csrf from '~/lib/utils/csrf';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
export default {
actionCancel: {
@@ -12,6 +14,7 @@ export default {
components: {
GlFormCheckbox,
GlModal,
+ OncallSchedulesList,
},
data() {
return {
@@ -22,8 +25,20 @@ export default {
isAccessRequest() {
return parseBoolean(this.modalData.isAccessRequest);
},
+ isInvite() {
+ return parseBoolean(this.modalData.isInvite);
+ },
+ isGroupMember() {
+ return this.modalData.memberType === 'GroupMember';
+ },
actionText() {
- return this.isAccessRequest ? __('Deny access request') : __('Remove member');
+ if (this.isAccessRequest) {
+ return __('Deny access request');
+ } else if (this.isInvite) {
+ return s__('Member|Revoke invite');
+ }
+
+ return __('Remove member');
},
actionPrimary() {
return {
@@ -33,6 +48,21 @@ export default {
},
};
},
+ showUnassignIssuablesCheckbox() {
+ return !this.isAccessRequest && !this.isInvite;
+ },
+ isPartOfOncallSchedules() {
+ return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
+ },
+ oncallSchedules() {
+ let schedules = {};
+ try {
+ schedules = JSON.parse(this.modalData.oncallSchedules);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ return schedules;
+ },
},
mounted() {
document.addEventListener('click', this.handleClick);
@@ -68,9 +98,18 @@ export default {
<form ref="form" :action="modalData.memberPath" method="post">
<p data-testid="modal-message">{{ modalData.message }}</p>
+ <oncall-schedules-list
+ v-if="isPartOfOncallSchedules"
+ :schedules="oncallSchedules.schedules"
+ :user-name="oncallSchedules.name"
+ />
+
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables">
+ <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">
{{ __('Also unassign this user from related issues and merge requests') }}
</gl-form-checkbox>
</form>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
index 4271f6053ed..85a67c087bb 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -21,7 +21,11 @@ export default {
};
</script>
<template>
- <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button">
+ <button
+ v-gl-tooltip="{ title: tooltip }"
+ :aria-label="tooltip"
+ class="p-0 gl-display-flex toolbar-button"
+ >
<gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
</button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
index ff0626167a9..76f152e5453 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql
@@ -1,4 +1,4 @@
-query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
+query getRunnerPlatforms {
runnerPlatforms {
nodes {
name
@@ -11,10 +11,4 @@ query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) {
}
}
}
- project(fullPath: $projectPath) {
- id
- }
- group(fullPath: $groupPath) {
- id
- }
}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
index 643c1991807..c0248a35e3f 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql
@@ -1,15 +1,5 @@
-query runnerSetupInstructions(
- $platform: String!
- $architecture: String!
- $projectId: ID!
- $groupId: ID!
-) {
- runnerSetup(
- platform: $platform
- architecture: $architecture
- projectId: $projectId
- groupId: $groupId
- ) {
+query runnerSetupInstructions($platform: String!, $architecture: String!) {
+ runnerSetup(platform: $platform, architecture: $architecture) {
installInstructions
registerInstructions
}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
index 1d6db576942..d886a67fff7 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -1,155 +1,31 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlModal,
- GlModalDirective,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
-} from '@gitlab/ui';
-import { __, s__ } from '~/locale';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import {
- PLATFORMS_WITHOUT_ARCHITECTURES,
- INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
-} from './constants';
-import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql';
-import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql';
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import RunnerInstructionsModal from './runner_instructions_modal.vue';
export default {
components: {
- GlAlert,
GlButton,
- GlButtonGroup,
- GlDropdown,
- GlDropdownItem,
- GlModal,
- GlIcon,
- ModalCopyButton,
+ RunnerInstructionsModal,
},
directives: {
GlModalDirective,
},
- inject: {
- projectPath: {
- default: '',
- },
- groupPath: {
- default: '',
- },
- },
- apollo: {
- runnerPlatforms: {
- query: getRunnerPlatforms,
- variables() {
- return {
- projectPath: this.projectPath,
- groupPath: this.groupPath,
- };
- },
- error() {
- this.showAlert = true;
- },
- result({ data }) {
- this.project = data?.project;
- this.group = data?.group;
-
- this.selectPlatform(this.platforms[0].name);
- },
- },
+ modalId: 'runner-instructions-modal',
+ i18n: {
+ buttonText: s__('Runners|Show Runner installation instructions'),
},
data() {
return {
- showAlert: false,
- selectedPlatformArchitectures: [],
- selectedPlatform: {
- name: '',
- },
- selectedArchitecture: {},
- runnerPlatforms: {},
- instructions: {},
- project: {},
- group: {},
+ opened: false,
};
},
- computed: {
- isPlatformSelected() {
- return Object.keys(this.selectedPlatform).length > 0;
- },
- instructionsEmpty() {
- return Object.keys(this.instructions).length === 0;
- },
- groupId() {
- return this.group?.id ?? '';
- },
- projectId() {
- return this.project?.id ?? '';
- },
- platforms() {
- return this.runnerPlatforms?.nodes;
- },
- hasArchitecureList() {
- return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatform?.name);
- },
- instructionsWithoutArchitecture() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.instructions;
- },
- runnerInstallationLink() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.link;
- },
- },
methods: {
- selectPlatform(name) {
- this.selectedPlatform = this.platforms.find((platform) => platform.name === name);
- if (this.hasArchitecureList) {
- this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes;
- [this.selectedArchitecture] = this.selectedPlatformArchitectures;
- this.selectArchitecture(this.selectedArchitecture);
- }
- },
- selectArchitecture(architecture) {
- this.selectedArchitecture = architecture;
-
- this.$apollo.addSmartQuery('instructions', {
- variables() {
- return {
- platform: this.selectedPlatform.name,
- architecture: this.selectedArchitecture.name,
- projectId: this.projectId,
- groupId: this.groupId,
- };
- },
- query: getRunnerSetupInstructions,
- update(data) {
- return data?.runnerSetup;
- },
- error() {
- this.showAlert = true;
- },
- });
- },
- toggleAlert(state) {
- this.showAlert = state;
+ onClick() {
+ // lazily mount modal to prevent premature instructions requests
+ this.opened = true;
},
},
- modalId: 'installation-instructions-modal',
- i18n: {
- installARunner: s__('Runners|Install a Runner'),
- architecture: s__('Runners|Architecture'),
- downloadInstallBinary: s__('Runners|Download and Install Binary'),
- downloadLatestBinary: s__('Runners|Download Latest Binary'),
- registerRunner: s__('Runners|Register Runner'),
- method: __('Method'),
- fetchError: s__('Runners|An error has occurred fetching instructions'),
- instructions: s__('Runners|Show Runner installation instructions'),
- copyInstructions: s__('Runners|Copy instructions'),
- },
- closeButton: {
- text: __('Close'),
- attributes: [{ variant: 'default' }],
- },
};
</script>
<template>
@@ -158,104 +34,10 @@ export default {
v-gl-modal-directive="$options.modalId"
class="gl-mt-4"
data-testid="show-modal-button"
+ @click="onClick"
>
- {{ $options.i18n.instructions }}
+ {{ $options.i18n.buttonText }}
</gl-button>
- <gl-modal
- :modal-id="$options.modalId"
- :title="$options.i18n.installARunner"
- :action-secondary="$options.closeButton"
- >
- <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
- {{ $options.i18n.fetchError }}
- </gl-alert>
- <h5>{{ __('Environment') }}</h5>
- <gl-button-group class="gl-mb-5">
- <gl-button
- v-for="platform in platforms"
- :key="platform.name"
- data-testid="platform-button"
- @click="selectPlatform(platform.name)"
- >
- {{ platform.humanReadableName }}
- </gl-button>
- </gl-button-group>
- <template v-if="hasArchitecureList">
- <template v-if="isPlatformSelected">
- <h5>
- {{ $options.i18n.architecture }}
- </h5>
- <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name">
- <gl-dropdown-item
- v-for="architecture in selectedPlatformArchitectures"
- :key="architecture.name"
- data-testid="architecture-dropdown-item"
- @click="selectArchitecture(architecture)"
- >
- {{ architecture.name }}
- </gl-dropdown-item>
- </gl-dropdown>
- <div class="gl-display-flex gl-align-items-center gl-mb-5">
- <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
- <gl-button
- class="gl-ml-auto"
- :href="selectedArchitecture.downloadLocation"
- download
- data-testid="binary-download-button"
- >
- {{ $options.i18n.downloadLatestBinary }}
- </gl-button>
- </div>
- </template>
- <template v-if="!instructionsEmpty">
- <div class="gl-display-flex">
- <pre
- class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
- data-testid="binary-instructions"
- >
-
- {{ instructions.installInstructions }}
- </pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.installInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
-
- <hr />
- <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5>
- <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5>
- <div class="gl-display-flex">
- <pre
- class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
- data-testid="runner-instructions"
- >
- {{ instructions.registerInstructions }}
- </pre
- >
- <modal-copy-button
- :title="$options.i18n.copyInstructions"
- :text="instructions.registerInstructions"
- :modal-id="$options.modalId"
- css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
- category="tertiary"
- />
- </div>
- </template>
- </template>
- <template v-else>
- <div>
- <p>{{ instructionsWithoutArchitecture }}</p>
- <gl-button :href="runnerInstallationLink">
- <gl-icon name="external-link" />
- {{ s__('Runners|View installation instructions') }}
- </gl-button>
- </div>
- </template>
- </gl-modal>
+ <runner-instructions-modal v-if="opened" :modal-id="$options.modalId" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
new file mode 100644
index 00000000000..795b4f58ac5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -0,0 +1,249 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { __, s__ } from '~/locale';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import {
+ PLATFORMS_WITHOUT_ARCHITECTURES,
+ INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
+} from './constants';
+import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlIcon,
+ GlLoadingIcon,
+ GlSkeletonLoader,
+ ModalCopyButton,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ platforms: {
+ query: getRunnerPlatformsQuery,
+ update(data) {
+ return data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => {
+ return {
+ name,
+ humanReadableName,
+ architectures: architectures?.nodes || [],
+ };
+ });
+ },
+ result() {
+ // Select first platform by default
+ if (this.platforms?.[0]) {
+ this.selectPlatform(this.platforms[0]);
+ }
+ },
+ error() {
+ this.toggleAlert(true);
+ },
+ },
+ instructions: {
+ query: getRunnerSetupInstructionsQuery,
+ skip() {
+ return !this.selectedPlatform;
+ },
+ variables() {
+ return {
+ platform: this.selectedPlatformName,
+ architecture: this.selectedArchitectureName || '',
+ };
+ },
+ update(data) {
+ return data?.runnerSetup;
+ },
+ error() {
+ this.toggleAlert(true);
+ },
+ },
+ },
+ data() {
+ return {
+ platforms: [],
+ selectedPlatform: null,
+ selectedArchitecture: null,
+ showAlert: false,
+ instructions: {},
+ };
+ },
+ computed: {
+ platformsEmpty() {
+ return isEmpty(this.platforms);
+ },
+ instructionsEmpty() {
+ return isEmpty(this.instructions);
+ },
+ selectedPlatformName() {
+ return this.selectedPlatform?.name;
+ },
+ selectedArchitectureName() {
+ return this.selectedArchitecture?.name;
+ },
+ hasArchitecureList() {
+ return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatformName);
+ },
+ instructionsWithoutArchitecture() {
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.instructions;
+ },
+ runnerInstallationLink() {
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link;
+ },
+ },
+ methods: {
+ selectPlatform(platform) {
+ this.selectedPlatform = platform;
+
+ if (!platform.architectures?.some(({ name }) => name === this.selectedArchitectureName)) {
+ // Select first architecture when current value is not available
+ this.selectArchitecture(platform.architectures[0]);
+ }
+ },
+ selectArchitecture(architecture) {
+ this.selectedArchitecture = architecture;
+ },
+ toggleAlert(state) {
+ this.showAlert = state;
+ },
+ },
+ i18n: {
+ installARunner: s__('Runners|Install a runner'),
+ architecture: s__('Runners|Architecture'),
+ downloadInstallBinary: s__('Runners|Download and install binary'),
+ downloadLatestBinary: s__('Runners|Download latest binary'),
+ registerRunnerCommand: s__('Runners|Command to register runner'),
+ fetchError: s__('Runners|An error has occurred fetching instructions'),
+ copyInstructions: s__('Runners|Copy instructions'),
+ },
+ closeButton: {
+ text: __('Close'),
+ attributes: [{ variant: 'default' }],
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="modalId"
+ :title="$options.i18n.installARunner"
+ :action-secondary="$options.closeButton"
+ >
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
+ {{ $options.i18n.fetchError }}
+ </gl-alert>
+
+ <gl-skeleton-loader v-if="platformsEmpty && $apollo.loading" />
+
+ <template v-if="!platformsEmpty">
+ <h5>
+ {{ __('Environment') }}
+ </h5>
+ <gl-button-group class="gl-mb-3">
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ :selected="selectedPlatform && selectedPlatform.name === platform.name"
+ data-testid="platform-button"
+ @click="selectPlatform(platform)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ </template>
+ <template v-if="hasArchitecureList">
+ <template v-if="selectedPlatform">
+ <h5>
+ {{ $options.i18n.architecture }}
+ <gl-loading-icon v-if="$apollo.loading" inline />
+ </h5>
+
+ <gl-dropdown class="gl-mb-3" :text="selectedArchitectureName">
+ <gl-dropdown-item
+ v-for="architecture in selectedPlatform.architectures"
+ :key="architecture.name"
+ :is-check-item="true"
+ :is-checked="selectedArchitectureName === architecture.name"
+ data-testid="architecture-dropdown-item"
+ @click="selectArchitecture(architecture)"
+ >
+ {{ architecture.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <div class="gl-display-flex gl-align-items-center gl-mb-3">
+ <h5>{{ $options.i18n.downloadInstallBinary }}</h5>
+ <gl-button
+ class="gl-ml-auto"
+ :href="selectedArchitecture.downloadLocation"
+ download
+ icon="download"
+ data-testid="binary-download-button"
+ >
+ {{ $options.i18n.downloadLatestBinary }}
+ </gl-button>
+ </div>
+ </template>
+ <template v-if="!instructionsEmpty">
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="binary-instructions"
+ >{{ instructions.installInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.installInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+
+ <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5>
+ <div class="gl-display-flex">
+ <pre
+ class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line"
+ data-testid="register-command"
+ >{{ instructions.registerInstructions }}</pre
+ >
+ <modal-copy-button
+ :title="$options.i18n.copyInstructions"
+ :text="instructions.registerInstructions"
+ :modal-id="$options.modalId"
+ css-classes="gl-align-self-start gl-ml-2 gl-mt-2"
+ category="tertiary"
+ />
+ </div>
+ </template>
+ </template>
+ <template v-else>
+ <div>
+ <p>{{ instructionsWithoutArchitecture }}</p>
+ <gl-button :href="runnerInstallationLink">
+ <gl-icon name="external-link" />
+ {{ s__('Runners|View installation instructions') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
new file mode 100644
index 00000000000..bbc7e6e7a6e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue
@@ -0,0 +1,88 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+/**
+ * Renders an inline field, whose value can be copied to the clipboard,
+ * for use in the GitLab sidebar (issues, MRs, etc.).
+ */
+export default {
+ name: 'CopyableField',
+ components: {
+ GlLoadingIcon,
+ ClipboardButton,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clipboardTooltipText: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ clipboardProps() {
+ return {
+ category: 'tertiary',
+ tooltipBoundary: 'viewport',
+ tooltipPlacement: 'left',
+ text: this.value,
+ title:
+ this.clipboardTooltipText ||
+ sprintf(this.$options.i18n.clipboardTooltip, { name: this.name }),
+ };
+ },
+ loadingIconLabel() {
+ return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name });
+ },
+ templateText() {
+ return sprintf(this.$options.i18n.templateText, {
+ name: this.name,
+ value: this.value,
+ });
+ },
+ },
+ i18n: {
+ loadingIconLabel: __('Loading %{name}'),
+ clipboardTooltip: __('Copy %{name}'),
+ templateText: s__('Sidebar|%{name}: %{value}'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <clipboard-button
+ v-if="!isLoading"
+ css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent"
+ v-bind="clipboardProps"
+ />
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between hide-collapsed"
+ >
+ <span
+ class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"
+ :title="value"
+ >
+ {{ templateText }}
+ </span>
+
+ <gl-loading-icon v-if="isLoading" inline :label="loadingIconLabel" />
+ <clipboard-button v-else size="small" v-bind="clipboardProps" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
index 1d3bd312b09..320e2048f1c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -164,6 +164,7 @@ export default {
variant="link"
icon="close"
class="gl-mr-2 gl-w-auto! gl-p-2!"
+ :aria-label="__('Close')"
@click.prevent="handleDropdownCloseClick"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 426ae430ce7..f547433f322 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -172,9 +172,11 @@ export default {
after: this.handleVuexActionDispatch,
});
+ document.addEventListener('mousedown', this.handleDocumentMousedown);
document.addEventListener('click', this.handleDocumentClick);
},
beforeDestroy() {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown);
document.removeEventListener('click', this.handleDocumentClick);
},
methods: {
@@ -197,11 +199,36 @@ export default {
}
},
/**
+ * This method stores a mousedown event's target.
+ * Required by the click listener because the click
+ * event itself has no reference to this element.
+ */
+ handleDocumentMousedown({ target }) {
+ this.mousedownTarget = target;
+ },
+ /**
* This method listens for document-wide click event
* and toggle dropdown if user clicks anywhere outside
* the dropdown while dropdown is visible.
*/
handleDocumentClick({ target }) {
+ // We also perform the toggle exception check for the
+ // last mousedown event's target to avoid hiding the
+ // box when the mousedown happened inside the box and
+ // only the mouseup did not.
+ if (
+ this.showDropdownContents &&
+ !this.preventDropdownToggleOnClick(target) &&
+ !this.preventDropdownToggleOnClick(this.mousedownTarget)
+ ) {
+ this.toggleDropdownContents();
+ }
+ },
+ /**
+ * This method checks whether a given click target
+ * should prevent the dropdown from being toggled.
+ */
+ preventDropdownToggleOnClick(target) {
// This approach of element detection is needed
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
@@ -216,19 +243,20 @@ export default {
target?.parentElement?.classList.contains(className),
);
- const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
(className) => $(target).parents(className).length,
);
- if (
- this.showDropdownContents &&
- !hadExceptionParent &&
- !hasExceptionClass &&
- !this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
- !this.$refs.dropdownContents?.$el.contains(target)
- ) {
- this.toggleDropdownContents();
- }
+ const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
+
+ const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
+
+ return (
+ hasExceptionClass ||
+ hasExceptionParent ||
+ isInDropdownButtonCollapsed ||
+ isInDropdownContents
+ );
},
handleDropdownClose(labels) {
// Only emit label updates if there are any labels to update
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
index ef5f052527b..17904f20341 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue
@@ -30,5 +30,8 @@ export default {
<gl-dropdown-form>
<slot name="items"></slot>
</gl-dropdown-form>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 459ea27e9cd..3885127fa8e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
@@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
+ ...UserAvailability
}
}
assignees {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index 43bd9f17e9a..63482873b69 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
@@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
participants {
nodes {
...User
+ ...UserAvailability
}
}
assignees {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 8ee8de2cb5c..3f40c0368d7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issuableSetAssignees: issueSetAssignees(
@@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
assignees {
nodes {
...User
+ ...UserAvailability
}
}
participants {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
index a0f15a07692..77140ea36d8 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
@@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!,
assignees {
nodes {
...User
+ ...UserAvailability
}
}
participants {
nodes {
...User
+ ...UserAvailability
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue
index 2844d9e9e94..925c6008836 100644
--- a/app/assets/javascripts/vue_shared/components/url_sync.vue
+++ b/app/assets/javascripts/vue_shared/components/url_sync.vue
@@ -2,11 +2,18 @@
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
+/**
+ * Renderless component to update the query string,
+ * the update is done by updating the query property or
+ * by using updateQuery method in the scoped slot.
+ * note: do not use both prop and updateQuery method.
+ */
export default {
props: {
query: {
type: Object,
- required: true,
+ required: false,
+ default: null,
},
},
watch: {
@@ -14,12 +21,19 @@ export default {
immediate: true,
deep: true,
handler(newQuery) {
- historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
+ if (newQuery) {
+ this.updateQuery(newQuery);
+ }
},
},
},
+ methods: {
+ updateQuery(newQuery) {
+ historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true }));
+ },
+ },
render() {
- return this.$slots.default;
+ return this.$scopedSlots.default?.({ updateQuery: this.updateQuery });
},
};
</script>
diff --git a/app/assets/javascripts/admin/users/components/user_date.vue b/app/assets/javascripts/vue_shared/components/user_date.vue
index 38dddbf72c2..38dddbf72c2 100644
--- a/app/assets/javascripts/admin/users/components/user_date.vue
+++ b/app/assets/javascripts/vue_shared/components/user_date.vue
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 dbd8efec948..11f484b2cdf 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
@@ -1,11 +1,6 @@
<script>
/* eslint-disable vue/no-v-html */
-import {
- GlPopover,
- GlLink,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlIcon,
-} from '@gitlab/ui';
+import { GlPopover, GlLink, GlSkeletonLoader, GlIcon } from '@gitlab/ui';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '../../../emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
@@ -19,7 +14,7 @@ export default {
GlIcon,
GlLink,
GlPopover,
- GlSkeletonLoading,
+ GlSkeletonLoader,
UserAvatarImage,
UserNameWithStatus,
},
@@ -60,20 +55,18 @@ export default {
<template>
<!-- 200ms delay so not every mouseover triggers Popover -->
- <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top">
+ <gl-popover :target="target" :delay="200" boundary="viewport" placement="top">
<div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
<div class="gl-p-2 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" />
</div>
- <div class="gl-p-2 gl-w-full">
+ <div class="gl-p-2 gl-w-full gl-min-w-0">
<template v-if="userIsLoading">
- <!-- `gl-skeleton-loading` does not support equal length lines -->
- <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed -->
- <gl-skeleton-loading
- v-for="n in $options.maxSkeletonLines"
- :key="n"
- :lines="1"
- class="animation-container-small gl-mb-2"
+ <gl-skeleton-loader
+ :lines="$options.maxSkeletonLines"
+ preserve-aspect-ratio="none"
+ equal-width-lines
+ :height="52"
/>
</template>
<template v-else>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 5262a15136b..9a5ad195de9 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -8,6 +8,8 @@ const INTERVALS = {
export const FILE_SYMLINK_MODE = '120000';
+export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
+
export const timeRanges = [
{
label: __('30 minutes'),
diff --git a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js
deleted file mode 100644
index ff1f565e79a..00000000000
--- a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import recaptchaModal from '../components/recaptcha_modal.vue';
-
-export default {
- data() {
- return {
- showRecaptcha: false,
- recaptchaHTML: '',
- };
- },
-
- components: {
- recaptchaModal,
- },
-
- methods: {
- openRecaptcha() {
- this.showRecaptcha = true;
- },
-
- closeRecaptcha() {
- this.showRecaptcha = false;
- },
-
- checkForSpam(data) {
- if (!data.recaptcha_html) return data;
-
- this.recaptchaHTML = data.recaptcha_html;
-
- const spamError = new Error(data.error_message);
- spamError.name = 'SpamError';
- spamError.message = 'SpamError';
-
- throw spamError;
- },
- },
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
index 310d8d88904..4ce13827da2 100644
--- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
@@ -6,6 +6,7 @@ query securityReportDownloadPaths(
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
headPipeline {
+ id
jobs(securityReportTypes: $reportTypes) {
nodes {
name
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 b27dd33835f..1151cffa76f 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
@@ -184,6 +184,7 @@ export default {
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
+ track-action="users_expanding_secure_security_report"
>
<template v-for="slot in $options.summarySlots" #[slot]>
<span :key="slot">
@@ -212,6 +213,7 @@ export default {
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
+ track-action="users_expanding_secure_security_report"
>
<template #error>
{{ $options.i18n.scansHaveRun }}
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index f4ac4f81eac..4a387edbe3f 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -1,13 +1,5 @@
<script>
-import {
- GlDrawer,
- GlInfiniteScroll,
- GlResizeObserverDirective,
- GlTabs,
- GlTab,
- GlBadge,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
@@ -20,37 +12,24 @@ export default {
components: {
GlDrawer,
GlInfiniteScroll,
- GlTabs,
- GlTab,
SkeletonLoader,
Feature,
- GlBadge,
- GlLoadingIcon,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [trackingMixin],
props: {
- storageKey: {
+ versionDigest: {
type: String,
required: true,
},
- versions: {
- type: Array,
- required: true,
- },
- gitlabDotCom: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
},
mounted() {
- this.openDrawer(this.storageKey);
+ this.openDrawer(this.versionDigest);
this.fetchItems();
const body = document.querySelector('body');
@@ -70,16 +49,6 @@ export default {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height);
},
- featuresForVersion(version) {
- return this.features.filter((feature) => {
- return feature.release === parseFloat(version);
- });
- },
- fetchVersion(version) {
- if (this.featuresForVersion(version).length === 0) {
- this.fetchItems({ version });
- }
- },
},
};
</script>
@@ -99,7 +68,6 @@ export default {
</template>
<template v-if="features.length">
<gl-infinite-scroll
- v-if="gitlabDotCom"
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@@ -109,26 +77,6 @@ export default {
<feature v-for="feature in features" :key="feature.title" :feature="feature" />
</template>
</gl-infinite-scroll>
- <gl-tabs v-else :style="{ height: `${drawerBodyHeight}px` }" class="gl-p-0">
- <gl-tab
- v-for="(version, index) in versions"
- :key="version"
- @click="fetchVersion(version)"
- >
- <template #title>
- <span>{{ version }}</span>
- <gl-badge v-if="index === 0">{{ __('Your Version') }}</gl-badge>
- </template>
- <gl-loading-icon v-if="fetching" size="lg" class="text-center" />
- <template v-else>
- <feature
- v-for="feature in featuresForVersion(version)"
- :key="feature.title"
- :feature="feature"
- />
- </template>
- </gl-tab>
- </gl-tabs>
</template>
<div v-else class="gl-mt-5">
<skeleton-loader />
diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index 6da141cb19a..3ac3a3a3611 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import { mapState } from 'vuex';
import App from './components/app.vue';
import store from './store';
-import { getStorageKey, setNotification } from './utils/notification';
+import { getVersionDigest, setNotification } from './utils/notification';
let whatsNewApp;
@@ -27,9 +27,7 @@ export default (el) => {
render(createElement) {
return createElement('app', {
props: {
- storageKey: getStorageKey(el),
- versions: JSON.parse(el.getAttribute('data-versions')),
- gitlabDotCom: el.getAttribute('data-gitlab-dot-com'),
+ versionDigest: getVersionDigest(el),
},
});
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 4b3cfa55977..1dc92ea2606 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -1,19 +1,20 @@
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { STORAGE_KEY } from '../utils/notification';
import * as types from './mutation_types';
export default {
closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER);
},
- openDrawer({ commit }, storageKey) {
+ openDrawer({ commit }, versionDigest) {
commit(types.OPEN_DRAWER);
- if (storageKey) {
- localStorage.setItem(storageKey, JSON.stringify(false));
+ if (versionDigest) {
+ localStorage.setItem(STORAGE_KEY, versionDigest);
}
},
- fetchItems({ commit, state }, { page, version } = { page: null, version: null }) {
+ fetchItems({ commit, state }, { page } = { page: null }) {
if (state.fetching) {
return false;
}
@@ -24,7 +25,6 @@ export default {
.get('/-/whats_new', {
params: {
page,
- version,
},
})
.then(({ data, headers }) => {
diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js
index 52ca8058d1c..3d4326c4b3a 100644
--- a/app/assets/javascripts/whats_new/utils/notification.js
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -1,11 +1,18 @@
-export const getStorageKey = (appEl) => appEl.getAttribute('data-storage-key');
+export const STORAGE_KEY = 'display-whats-new-notification';
+
+export const getVersionDigest = (appEl) => appEl.getAttribute('data-version-digest');
export const setNotification = (appEl) => {
- const storageKey = getStorageKey(appEl);
+ const versionDigest = getVersionDigest(appEl);
const notificationEl = document.querySelector('.header-help');
let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
- if (JSON.parse(localStorage.getItem(storageKey)) === false) {
+ const legacyStorageKey = 'display-whats-new-notification-13.10';
+ const localStoragePairs = [
+ [legacyStorageKey, false],
+ [STORAGE_KEY, versionDigest],
+ ];
+ if (localStoragePairs.some((pair) => localStorage.getItem(pair[0]) === pair[1].toString())) {
notificationEl.classList.remove('with-notifications');
if (notificationCountEl) {
notificationCountEl.parentElement.removeChild(notificationCountEl);
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 4a15e0eb458..fa5ab590232 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -26,7 +26,6 @@
@import './pages/projects';
@import './pages/prometheus';
@import './pages/registry';
-@import './pages/runners';
@import './pages/search';
@import './pages/service_desk';
@import './pages/settings';
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
index e55141e15df..90aab7ce342 100644
--- a/app/assets/stylesheets/application_dark.scss
+++ b/app/assets/stylesheets/application_dark.scss
@@ -1,3 +1,60 @@
@import './themes/dark';
@import './application';
+
+@import './themes/theme_helper';
+
+body.gl-dark {
+ @include gitlab-theme(
+ $gray-900,
+ $gray-400,
+ $gray-500,
+ $gray-800,
+ $gray-900,
+ $white
+ );
+
+ .logo-text svg {
+ fill: var(--gl-text-color);
+ }
+
+ .navbar-gitlab {
+ background-color: var(--gray-50);
+ box-shadow: 0 1px 0 0 var(--gray-100);
+
+ .navbar-sub-nav,
+ .navbar-nav {
+ li {
+ > a:hover,
+ > a:focus,
+ > button:hover,
+ > button:focus {
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
+ }
+ }
+
+ li.active,
+ li.dropdown.show {
+ > a,
+ > button {
+ color: var(--gl-text-color);
+ background-color: var(--gray-200);
+ }
+ }
+ }
+
+ .search {
+ form {
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--border-color);
+
+ &:active,
+ &:hover {
+ background-color: var(--gray-100);
+ box-shadow: inset 0 0 0 1px var(--blue-200);
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/feature_highlight.scss b/app/assets/stylesheets/components/feature_highlight.scss
new file mode 100644
index 00000000000..08706951967
--- /dev/null
+++ b/app/assets/stylesheets/components/feature_highlight.scss
@@ -0,0 +1,9 @@
+.gl-badge.feature-highlight-badge {
+ background-color: $purple-light;
+ color: $purple;
+
+ &,
+ &.sm {
+ padding: 0.25rem;
+ }
+}
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
deleted file mode 100644
index b9bb3edaaab..00000000000
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ /dev/null
@@ -1,43 +0,0 @@
-// Custom Font Awesome styles that render emojis in asciidoc
-.md {
- .fa {
- display: inline-block;
- font-style: normal;
- font-size: 14px;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
-
- .fa-2x {
- font-size: 2em;
- }
-
- .fa-exclamation-triangle::before {
- content: '⚠';
- }
-
- .fa-exclamation-circle::before {
- content: '❗';
- }
-
- .fa-lightbulb-o::before {
- content: '💡';
- }
-
- .fa-thumb-tack::before {
- content: '📌';
- }
-
- .fa-fire::before {
- content: '🔥';
- }
-
- .fa-square-o::before {
- content: '\2610';
- }
-
- .fa-check-square-o::before {
- content: '\2611';
- }
-}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 662f7f52d61..412a1e8d6c9 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -122,12 +122,8 @@
}
}
-.award-control {
+.gl-button.btn.award-control {
margin: 4px 8px 4px 0;
- outline: 0;
- position: relative;
- display: block;
- float: left;
&.disabled {
cursor: default;
@@ -145,15 +141,6 @@
&:hover,
&:active,
&.is-active {
- background-color: $blue-50;
- border-color: $blue-200;
- box-shadow: none;
- outline: 0;
-
- .award-control-icon svg {
- fill: $blue-500;
- }
-
.award-control-icon-neutral {
opacity: 0;
}
@@ -164,6 +151,14 @@
}
}
+ &.active,
+ &.is-active,
+ &:active {
+ background-color: $blue-50;
+ border-color: $blue-200;
+ box-shadow: inset 0 0 2px $blue-200;
+ }
+
&.is-active {
.award-control-icon-positive {
opacity: 0;
@@ -192,10 +187,6 @@
&:focus {
outline: 0;
}
-
- .award-control-icon {
- margin: 0;
- }
}
&.is-loading {
@@ -213,9 +204,7 @@
gl-emoji,
.award-control-icon {
vertical-align: middle;
- margin-right: 0.15em;
- font-size: 1.5em;
- line-height: 1;
+ line-height: 0.5em;
}
.award-control-icon-loading {
@@ -224,11 +213,8 @@
.award-control-icon {
color: $border-gray-normal;
- margin-top: 1px;
- padding: 0 2px;
svg {
- margin-bottom: 1px;
height: $default-icon-size;
width: $default-icon-size;
border-radius: 50%;
@@ -239,10 +225,8 @@
.award-control-icon-positive,
.award-control-icon-super-positive {
@include transition(opacity, transform);
- position: absolute;
- left: 10px;
- bottom: 6px;
opacity: 0;
+ position: absolute;
path {
fill: $award-emoji-positive-add-lines;
@@ -261,7 +245,6 @@
// migrated to Vue.
.gl-button .award-emoji-block gl-emoji {
- top: -1px;
margin-top: -1px;
margin-bottom: -1px;
}
@@ -363,3 +346,7 @@
}
}
}
+
+.awards .is-active {
+ box-shadow: inset 0 0 0 1px $blue-200;
+}
diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss
index 95025459cc9..ef4355ad157 100644
--- a/app/assets/stylesheets/framework/ci_variable_list.scss
+++ b/app/assets/stylesheets/framework/ci_variable_list.scss
@@ -75,26 +75,3 @@
padding-top: 5px;
padding-bottom: 5px;
}
-
-.ci-variable-row-remove-button {
- @include transition(color);
- flex-shrink: 0;
- display: flex;
- justify-content: center;
- align-items: center;
- width: $ci-variable-remove-button-width;
- height: $input-height;
- padding: 0;
- background: transparent;
- color: $gl-text-color-secondary;
-
- &:hover,
- &:focus {
- outline: none;
- color: $gl-text-color;
- }
-
- &[disabled] {
- color: $gl-text-color-disabled;
- }
-}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 5d182373fb1..652ffd79ab3 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -121,10 +121,6 @@ hr {
@include str-truncated(30%);
}
- &-60 {
- @include str-truncated(60%);
- }
-
&-100 {
@include str-truncated(100%);
}
@@ -292,11 +288,6 @@ img.emoji {
}
}
-.search_box {
- @extend .card.card-body;
- text-align: center;
-}
-
.dropzone .dz-preview .dz-progress {
border-color: $border-color !important;
@@ -426,7 +417,6 @@ img.emoji {
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
.mw-70p { max-width: 70%; }
-.mw-90p { max-width: 90%; }
// By default flex items don't shrink below their minimum content size.
// To change this, these clases set a min-width or min-height
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index bd15022eadf..bc7a31c112f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -532,10 +532,6 @@ table.code {
&.parallel {
display: table-cell;
width: 46%;
-
- span {
- word-break: break-all;
- }
}
&.old {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index bdde6c7b313..ff42cd836da 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -133,13 +133,15 @@
}
}
-.dropdown-menu-toggle {
+// This is double classed to solve a specificity issue with the gitlab ui buttons
+.dropdown-menu-toggle.dropdown-menu-toggle {
@extend .dropdown-toggle;
+ justify-content: flex-start;
+ overflow: hidden;
padding-right: 25px;
position: relative;
- width: 160px;
text-overflow: ellipsis;
- overflow: hidden;
+ width: 160px;
.fa {
position: absolute;
@@ -376,11 +378,13 @@
}
> a,
- > button {
+ > button,
+ > .gl-button {
display: flex;
+ justify-content: flex-start;
margin: 0;
- text-overflow: inherit;
text-align: left;
+ text-overflow: inherit;
&.btn .fa:not(:last-child) {
margin-left: 5px;
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index eda3e33a6aa..78995c6e4f5 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -24,3 +24,53 @@
[id^='editor-lite-'] {
height: 500px;
}
+
+.monaco-editor.gl-editor-lite {
+ .margin-view-overlays {
+ .line-numbers {
+ @include gl-display-flex;
+ @include gl-justify-content-end;
+ @include gl-relative;
+
+ &::before {
+ @include gl-visibility-hidden;
+ @include gl-align-self-center;
+ @include gl-bg-gray-400;
+ @include gl-mr-2;
+ @include gl-w-4;
+ @include gl-h-4;
+ mask-image: asset_url('icons-stacked.svg#link');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center;
+ content: '';
+ }
+
+ &:hover {
+ @include gl-text-decoration-underline;
+ cursor: pointer !important;
+ }
+
+ &:hover::before {
+ @include gl-visibility-visible;
+ }
+
+ &:focus::before {
+ @include gl-visibility-visible;
+ outline: auto;
+ }
+
+ .link-anchor {
+ @include gl-display-block;
+ @include gl-absolute;
+ @include gl-w-full;
+ @include gl-h-full;
+ }
+ }
+ }
+}
+
+.active-line-text {
+ @include gl-bg-orange-600;
+ @include gl-opacity-3;
+}
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index c5c660c1014..1ddde3d2ed6 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -1,10 +1,10 @@
gl-emoji {
font-style: normal;
display: inline-flex;
- vertical-align: middle;
+ vertical-align: baseline;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
- font-size: 1.4em;
- line-height: 1em;
+ font-size: 1.2em;
+ line-height: 1;
}
.user-status-emoji {
@@ -26,6 +26,13 @@ gl-emoji {
height: 30px;
// Create a width that fits 9 emojis per row
width: 100 / 9 * 1%;
+ transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2) !important;
+ will-change: transform;
+
+ &:hover,
+ &:focus {
+ transform: scale(1.3);
+ }
}
.emoji-picker .gl-new-dropdown .dropdown-menu {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index a4af45a467c..f76101d92b1 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -204,10 +204,6 @@
margin-bottom: 10px;
}
- &:hover {
- @extend .form-control:hover;
- }
-
&.focus,
&.focus:hover {
border-color: $blue-300;
@@ -294,14 +290,14 @@
flex-direction: column;
}
-.filtered-search-history-dropdown-toggle-button {
- flex: 1;
- width: auto;
+.filtered-search-history-dropdown-toggle-button.gl-button {
border-radius: $border-radius-default 0 0 $border-radius-default;
- border: 0;
border-right: 1px solid $border-color;
+ box-shadow: none;
color: $gl-text-color-secondary;
+ flex: 1;
transition: color 0.1s linear;
+ width: auto;
&:hover,
&:focus {
@@ -342,12 +338,6 @@
}
}
-.filter-dropdown-container {
- .dropdown-toggle {
- line-height: 22px;
- }
-}
-
@include media-breakpoint-down(sm) {
.issues-details-filters,
.epics-details-filters {
@@ -402,7 +392,6 @@
> svg {
position: absolute;
- top: 11px;
right: 6px;
}
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index fdb56a128c7..432be7d0b3f 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -442,22 +442,22 @@
font-weight: $gl-font-weight-normal;
margin-left: -6px;
font-size: 11px;
- color: $white;
+ color: var(--gray-950, $white);
padding: 0 5px;
line-height: 12px;
border-radius: 7px;
box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
&.green-badge {
- background-color: $green-400;
+ background-color: var(--green-400, $green-400);
}
&.merge-requests-count {
- background-color: $orange-400;
+ background-color: var(--orange-400, $orange-400);
}
&.todos-count {
- background-color: $blue-400;
+ background-color: var(--blue-400, $blue-400);
}
}
@@ -511,7 +511,7 @@
.header-user {
&.show .dropdown-menu {
margin-top: 4px;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
left: auto;
max-height: $dropdown-max-height-lg;
@@ -580,7 +580,7 @@
.no-emoji-placeholder,
.clear-user-status {
svg {
- fill: $gl-text-color-secondary;
+ fill: var(--gray-500, $gray-500);
}
}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index ec433434573..48a18e0d145 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -94,14 +94,6 @@ body.modal-open {
padding-right: 0 !important;
}
-.modal-no-backdrop {
- @extend .modal-dialog;
-
- .modal-content {
- box-shadow: none;
- }
-}
-
.modal {
background-color: $black-transparent;
diff --git a/app/assets/stylesheets/framework/page_header.scss b/app/assets/stylesheets/framework/page_header.scss
index 660e3dcac8d..c8b4e306a2e 100644
--- a/app/assets/stylesheets/framework/page_header.scss
+++ b/app/assets/stylesheets/framework/page_header.scss
@@ -12,28 +12,11 @@
}
}
- .header-action-buttons {
- i {
- color: $gl-text-color-secondary;
- font-size: 13px;
- margin-right: 3px;
- }
-
- @include media-breakpoint-down(xs) {
- .btn {
- width: 100%;
- margin-top: 10px;
- }
-
- .dropdown {
- width: 100%;
- }
- }
- }
-
.avatar {
- @extend .avatar-inline;
- margin-left: 0;
+ float: none;
+ display: inline-block;
+ margin-left: 2px;
+ flex-shrink: 0;
@include media-breakpoint-up(sm) {
margin-left: 4px;
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 07c3eb19fd4..f57d906e73c 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -3,6 +3,7 @@
max-width: #{$max + '%'};
}
+.gl-responsive-table-row,
.gl-responsive-table-row-layout {
width: 100%;
@@ -17,7 +18,6 @@
}
.gl-responsive-table-row {
- @extend .gl-responsive-table-row-layout;
margin-top: 10px;
border: 1px solid $border-color;
color: $gray-500;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 2ad9a9d2dff..27b7cac2df5 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -108,7 +108,7 @@
> .dropdown,
> input,
> form {
- margin-right: $gl-padding-top;
+ margin-right: $gl-padding-8;
&:last-child {
margin-right: 0;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 241aaad015e..cb8a0c40f7f 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -58,19 +58,6 @@
height: $gl-padding;
}
}
-
- .copy-email-button { // TODO: replace with utility
- @include gl-w-full;
- @include gl-h-full;
- }
-
- .copy-email-address {
- height: 60px;
-
- &:hover {
- background: $gray-100;
- }
- }
}
.right-sidebar-expanded {
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
index 2aa0ab6c1eb..c8eadce5c51 100644
--- a/app/assets/stylesheets/framework/spinner.scss
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -20,7 +20,7 @@
}
}
-@mixin spinner($size: 16px, $border-width: 2px, $color: $orange-400) {
+@mixin spinner($size: 16px, $border-width: 2px, $color: $gray-700) {
border-radius: 50%;
position: relative;
margin: 0 auto;
@@ -46,7 +46,7 @@
}
&.spinner-dark {
- @include spinner-color($gray-500);
+ @include spinner-color($gray-700);
}
&.spinner-light {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 5624a6ea8a3..648ae29e212 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -1,6 +1,3 @@
-// Custom Fontawesome icons
-@import 'fontawesome_custom';
-
/**
* Apply Markup (Markdown/AsciiDoc) typography
*
@@ -435,7 +432,9 @@
}
}
- a.with-attachment-icon {
+ a.with-attachment-icon,
+ a[href*='/uploads/'],
+ a[href*='storage.googleapis.com/google-code-attachments/'] {
&::before {
margin-right: 4px;
@@ -449,8 +448,6 @@
a[href*='/uploads/'],
a[href*='storage.googleapis.com/google-code-attachments/'] {
- @extend .with-attachment-icon;
-
&.no-attachment-icon {
&::before {
display: none;
@@ -507,32 +504,56 @@
text-decoration: line-through;
}
- .admonitionblock td.icon {
- width: 1%;
+ // Custom Font Awesome styles that render emojis in asciidoc
+ .fa {
+ display: inline-block;
+ font-style: normal;
+ font-size: 14px;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ }
- [class^='fa icon-'] {
- @extend .fa-2x;
- }
+ .fa-2x,
+ .admonitionblock td.icon [class^='fa icon-'] {
+ font-size: 2em;
+ }
- .icon-note {
- @extend .fa-thumb-tack;
- }
+ .fa-exclamation-triangle::before,
+ .admonitionblock td.icon .icon-warning::before {
+ content: '⚠';
+ }
- .icon-tip {
- @extend .fa-lightbulb-o;
- }
+ .fa-exclamation-circle::before,
+ .admonitionblock td.icon .icon-important::before {
+ content: '❗';
+ }
- .icon-warning {
- @extend .fa-exclamation-triangle;
- }
+ .fa-lightbulb-o::before,
+ .admonitionblock td.icon .icon-tip::before {
+ content: '💡';
+ }
- .icon-caution {
- @extend .fa-fire;
- }
+ .fa-thumb-tack::before,
+ .admonitionblock td.icon .icon-note::before {
+ content: '📌';
+ }
- .icon-important {
- @extend .fa-exclamation-circle;
- }
+ .fa-fire::before,
+ .admonitionblock td.icon .icon-caution::before {
+ content: '🔥';
+ }
+
+ .fa-square-o::before {
+ content: '\2610';
+ }
+
+ .fa-check-square-o::before {
+ content: '\2611';
+ }
+
+ .admonitionblock td.icon {
+ width: 1%;
}
.metrics-embed {
@@ -640,12 +661,13 @@ code {
.commit-sha,
.ref-name,
.pipeline-number {
- @extend .monospace;
+ font-family: $monospace-font;
font-size: 95%;
}
-.git-revision-dropdown .dropdown-content ul li a {
- @extend .ref-name;
+.git-revision-dropdown .dropdown-content li:not(.dropdown-menu-empty-item) a {
+ font-family: $monospace-font;
+ font-size: 95%;
word-break: break-all;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1aa4177c902..b5139ba7638 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -352,8 +352,8 @@ $border-gray-normal-dashed: darken($gray-normal, $darken-border-dashed-factor);
*/
$border-color: $gray-100;
$shadow-color: $t-gray-a-08;
-$well-expand-item: #e8f2f7;
-$well-inner-border: #eef0f2;
+$well-expand-item: #e8f2f7 !default;
+$well-inner-border: #eef0f2 !default;
$well-light-border: #f1f1f1;
$well-light-text-color: #5b6169;
@@ -590,7 +590,7 @@ $gl-btn-xs-line-height: 13px;
/*
* Badges
*/
-$badge-bg: rgba(0, 0, 0, 0.07);
+$badge-bg: rgba($black, 0.07);
/*
* Pagination
@@ -842,10 +842,10 @@ $linked-project-column-margin: 60px;
/*
Performance Bar
*/
-$perf-bar-production: #222;
-$perf-bar-staging: #291430;
-$perf-bar-development: #4c1210;
-$perf-bar-bucket-bg: #111;
+$perf-bar-production: $gray-950;
+$perf-bar-staging: $indigo-950;
+$perf-bar-development: $red-950;
+$perf-bar-bucket-bg: $black;
$perf-bar-bucket-box-shadow-from: rgba($white, 0.2);
$perf-bar-bucket-box-shadow-to: rgba($black, 0.25);
$perf-bar-canary-text: $orange-400;
@@ -924,11 +924,12 @@ Issue Analytics
$issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15);
/*
-Merge Requests
+Merge requests
*/
$mr-tabs-height: 48px;
$mr-version-controls-height: 56px;
$mr-widget-margin-left: 40px;
+$mr-review-bar-height: calc(2rem + 13px);
/*
Compare Branches
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index 6c050f33b07..8270db9966e 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -1,5 +1,6 @@
@import '../framework/variables';
@import './conflict_colors';
+@import 'page_bundles/mixins_and_variables_and_functions';
@mixin diff-background($background, $idiff, $border) {
background: $background;
@@ -93,3 +94,30 @@
}
}
}
+
+@mixin line-number-link($color) {
+ &::before {
+ @include gl-visibility-hidden;
+ @include gl-display-inline-block;
+ @include gl-align-self-center;
+ @include gl-mt-2;
+ @include gl-mr-2;
+ @include gl-w-4;
+ @include gl-h-4;
+ @include gl-float-left;
+ background-color: $color;
+ mask-image: asset_url('icons-stacked.svg#link');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center;
+ content: '';
+ }
+
+ &:hover::before {
+ @include gl-visibility-visible;
+ }
+
+ &:focus::before {
+ @include gl-visibility-visible;
+ }
+}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 0dc01213606..d6523265a43 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -90,6 +90,10 @@ $dark-il: #de935f;
.code.dark {
// Line numbers
+ .file-line-num {
+ @include line-number-link($dark-line-num-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $dark-main-bg;
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 95c3e8e9103..027f2fa63d3 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -91,6 +91,10 @@ $monokai-gh: #75715e;
.code.monokai {
// Line numbers
+ .file-line-num {
+ @include line-number-link($monokai-line-num-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $monokai-bg;
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 4fc6e5dba39..5002726bbc5 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -11,6 +11,10 @@
.code.none {
// Line numbers
+ .file-line-num {
+ @include line-number-link($black-transparent);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $gray-light;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index f95f5393323..cd0cb65e4e2 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -94,6 +94,10 @@ $solarized-dark-il: #2aa198;
.code.solarized-dark {
// Line numbers
+ .file-line-num {
+ @include line-number-link($solarized-dark-line-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $solarized-dark-line-bg;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index dc4bc2f32c2..77e88053424 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -101,6 +101,10 @@ $solarized-light-il: #2aa198;
.code.solarized-light {
// Line numbers
+ .file-line-num {
+ @include line-number-link($solarized-light-line-color);
+ }
+
.line-numbers,
.diff-line-num {
background-color: $solarized-light-line-bg;
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 128fe0cc046..18b2f0a5d58 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -78,6 +78,10 @@ $white-gc-bg: #eaf2f5;
}
// Line numbers
+.file-line-num {
+ @include line-number-link($black-transparent);
+}
+
.line-numbers,
.diff-line-num {
background-color: $gray-light;
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index 0d40159f6de..272f94176d0 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -12,9 +12,9 @@
.select2-container,
.select2-container.select2-drop-above {
.select2-choice {
- background: $white;
- color: $gl-text-color;
- border-color: $border-color;
+ background: var(--white, $white);
+ color: var(--gl-text-color, $gl-text-color);
+ border-color: var(--border-color, $border-color);
height: 34px;
padding: $gl-vert-padding $gl-input-padding;
font-size: $gl-font-size;
@@ -27,6 +27,10 @@
/* stylelint-disable-next-line function-url-quotes */
background: url(asset_path('chevron-down.png')) no-repeat 2px 8px;
+ .gl-dark & {
+ filter: invert(0.9);
+ }
+
b {
display: none;
}
@@ -37,8 +41,8 @@
}
&:hover {
- border-color: $gray-darkest;
- color: $gl-text-color;
+ border-color: var(--gray-200, $gray-200);
+ color: var(--gl-text-color, $gl-text-color);
}
}
@@ -49,8 +53,8 @@
// .select2-focusser element instead.
&.select2-container-active:not(.select2-dropdown-open) {
.select2-choice {
- color: $input-focus-color;
- background-color: $input-focus-bg;
+ color: var(--gray-700, $gray-700);
+ background-color: var(--white, $white);
border-color: $input-focus-border-color;
outline: 0;
}
@@ -85,19 +89,19 @@
.select2-choices,
.select2-choice {
- border-color: $red-500;
+ border-color: var(--red-500, $red-500);
}
}
}
.select2-drop,
.select2-drop.select2-drop-above {
- background: $white;
+ background: var(--white, $white);
box-shadow: 0 2px 4px $dropdown-shadow-color;
border-radius: $gl-border-radius-base;
- border: 1px solid $border-color;
+ border: 1px solid var(--border-color, $border-color);
min-width: 175px;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
z-index: 999;
.modal-open & {
@@ -114,7 +118,7 @@
}
.select2-drop.select2-drop-above.select2-drop-active {
- border-top: 1px solid $border-color;
+ border-top: 1px solid var(--border-color, $border-color);
margin-top: -6px;
}
@@ -128,7 +132,7 @@
.select2-dropdown-open,
.select2-dropdown-open.select2-drop-above {
.select2-choice {
- border-color: $gray-darkest;
+ border-color: var(--border-color, $border-color);
outline: 0;
}
}
@@ -136,7 +140,7 @@
.select2-container-multi {
.select2-choices {
border-radius: $border-radius-default;
- border-color: $border-color;
+ border-color: var(--border-color, $border-color);
background: none;
.select2-search-field input {
@@ -149,10 +153,10 @@
.select2-search-choice {
margin: 5px 0 0 8px;
box-shadow: none;
- border-color: $border-color;
- color: $gl-text-color;
+ border-color: var(--border-color, $border-color);
+ color: var(--gl-text-color, $gl-text-color);
line-height: 15px;
- background-color: $gray-light;
+ background-color: var(--gray-50, $gray-50);
background-image: none;
padding: 3px 18px 3px 5px;
@@ -163,7 +167,7 @@
}
&.select2-search-choice-focus {
- border-color: $gl-text-color;
+ border-color: var(--gl-text-color, $gl-text-color);
}
}
}
@@ -188,22 +192,22 @@
input {
padding: $grid-size;
background: transparent image-url('select2.png');
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
background-clip: content-box;
background-origin: content-box;
background-repeat: no-repeat;
background-position: right 0 bottom 0 !important;
- border: 1px solid $border-color;
+ border: 1px solid var(--border-color, $border-color);
border-radius: $border-radius-default;
line-height: 16px;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
&:focus {
- border-color: $blue-300;
+ border-color: var(--blue-300, $blue-300);
}
&.select2-active {
- background-color: $white;
+ background-color: var(--white, $white);
background-image: image-url('select2-spinner.gif') !important;
background-origin: content-box;
background-repeat: no-repeat;
@@ -236,10 +240,10 @@
.select2-highlighted {
background: transparent;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
.select2-result-label {
- background: $gray-darker;
+ background: var(--gray-50, $gray-50);
}
}
@@ -249,14 +253,14 @@
li.select2-result-with-children > .select2-result-label {
font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
.select2-highlighted {
.group-result {
.group-path {
- color: $gray-700;
+ color: var(--gray-700, $gray-700);
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
index fb7c1602cba..ed2707ffbcd 100644
--- a/app/assets/stylesheets/page_bundles/alert_management_settings.scss
+++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss
@@ -6,7 +6,6 @@ $stroke-size: 1px;
@include gl-relative;
@include gl-w-full;
height: $stroke-size;
- @include gl-display-inline-block;
background-color: var(--gray-400, $gray-400);
min-width: $gl-spacing-scale-5;
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index fdf9e157e1e..a00a71b07e7 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -40,8 +40,8 @@
[data-page$='epic_boards:index'],
[data-page$='epic_boards:show'] {
- .filter-form {
- display: none;
+ .filtered-search-wrapper {
+ display: none !important;
}
}
@@ -365,7 +365,7 @@
margin: 5px;
}
-.right-sidebar.issue-boards-sidebar {
+.right-sidebar.boards-sidebar {
.gutter-toggle {
bottom: 15px;
width: 22px;
@@ -401,99 +401,6 @@
}
}
-.add-issues-modal {
- background-color: rgba($black, 0.3);
- z-index: 9999;
-}
-
-.add-issues-container {
- width: 90vw;
- height: 85vh;
- max-width: 1100px;
- min-height: 500px;
- padding: 25px 15px 0;
- background-color: var(--white, $white);
- box-shadow: 0 2px 12px rgba(var(--black, $black), 0.5);
-
- .empty-state {
- &.add-issues-empty-state-filter {
- flex-direction: column;
- justify-content: center;
- }
-
- .svg-content {
- margin-top: -40px;
- }
- }
-}
-
-.add-issues-header {
- margin: -25px -15px -5px;
- border-bottom: 1px solid $border-color;
- border-top-right-radius: $border-radius-default;
- border-top-left-radius: $border-radius-default;
-
- > h2 {
- font-size: 18px;
- }
-}
-
-.add-issues-list-column {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- width: 50%;
- }
-
- @include media-breakpoint-up(md) {
- width: (100% / 3);
- }
-}
-
-.add-issues-list {
- padding-top: 3px;
- margin-left: -$gl-vert-padding;
- margin-right: -$gl-vert-padding;
- overflow-y: scroll;
-
- .board-card-parent {
- padding: 0 5px 5px;
- }
-
- .board-card {
- border: 1px solid var(--gray-900, $gray-900);
- box-shadow: 0 1px 2px rgba(var(--black, $black), 0.4);
- cursor: pointer;
- }
-}
-
-.add-issues-footer {
- margin: auto -15px 0;
- padding-left: 15px;
- padding-right: 15px;
- border-bottom-right-radius: $border-radius-default;
- border-bottom-left-radius: $border-radius-default;
-}
-
-.add-issues-footer-to-list {
- padding-left: $gl-vert-padding;
- padding-right: $gl-vert-padding;
- line-height: $input-height;
-}
-
-.issue-card-selected {
- position: absolute;
- right: -3px;
- top: -3px;
- width: 17px;
- background-color: var(--blue-500, $blue-500);
- color: $white;
- border: 1px solid var(--blue-600, $blue-600);
- font-size: 9px;
- line-height: 15px;
- border-radius: 50%;
-}
-
.board-card-info {
color: var(--gray-500, $gray-500);
white-space: nowrap;
@@ -555,12 +462,28 @@
overflow-x: scroll;
}
- .issue-boards-sidebar {
+ .boards-sidebar {
height: 100%;
top: 0;
}
}
+.boards-sidebar {
+ .sidebar-collapsed-icon {
+ display: none;
+ }
+}
+
.board-header-collapsed-info-icon:hover {
color: var(--gray-900, $gray-900);
}
+
+.board-card-skeleton {
+ height: 110px;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+
+ .board-card-skeleton-inner {
+ width: 340px;
+ height: 100px;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index 34f88f9405a..b91850f1775 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -8,9 +8,9 @@
.archived-job {
top: $header-height;
border-radius: 2px 2px 0 0;
- color: $orange-600;
- background-color: $orange-50;
- border: 1px solid $border-gray-normal;
+ color: var(--orange-600, $orange-600);
+ background-color: var(--orange-50, $orange-50);
+ border: 1px solid var(--border-color, $border-color);
padding: 3px 12px;
margin: auto;
align-items: center;
@@ -86,33 +86,17 @@
padding: 10px 0 9px;
}
- .header-action-buttons {
- display: flex;
-
- @include media-breakpoint-down(xs) {
- .sidebar-toggle-btn {
- margin-top: 0;
- margin-left: 10px;
- max-height: 34px;
- }
- }
- }
-
.header-content {
a {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
&:hover {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
text-decoration: none;
}
}
}
- code {
- color: $code-color;
- }
-
.avatar {
float: none;
margin-right: 2px;
@@ -160,12 +144,12 @@
.trigger-build-variable {
font-weight: $gl-font-weight-normal;
- color: $code-color;
+ color: var(--gray-950, $gray-950);
}
.trigger-build-value {
padding: 2px 4px;
- color: $black;
+ color: var(--black, $black);
}
.trigger-variables-table-cell {
@@ -185,7 +169,7 @@
cursor: pointer;
&:hover {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
@@ -223,7 +207,7 @@
}
&.retried {
- background-color: $gray-lightest;
+ background-color: var(--gray-10, $gray-10);
}
&:hover {
@@ -231,10 +215,6 @@
}
}
}
-
- .link-commit {
- color: var(--blue-600, $blue-600);
- }
}
.build-sidebar {
diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss
index 232d363b7f1..6b976106cc9 100644
--- a/app/assets/stylesheets/page_bundles/ci_status.scss
+++ b/app/assets/stylesheets/page_bundles/ci_status.scss
@@ -80,17 +80,3 @@
}
}
}
-
-.d-block.d-sm-none-inline {
- .ci-status-link {
- position: relative;
- top: 2px;
- left: 5px;
- }
-}
-
-.ci-status-link {
- svg {
- overflow: visible;
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index eb2dd6e578e..db4be3f18e8 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -4,37 +4,22 @@
@import 'bootstrap-vue/src/index';
@import '@gitlab/ui/src/scss/utilities';
-@import '@gitlab/ui/src/components/base/alert/alert';
// We should only import styles that we actually use.
@import '@gitlab/ui/src/components/base/alert/alert';
@import '@gitlab/ui/src/components/base/avatar/avatar';
-@import '@gitlab/ui/src/components/base/badge/badge';
@import '@gitlab/ui/src/components/base/button/button';
@import '@gitlab/ui/src/components/base/icon/icon';
@import '@gitlab/ui/src/components/base/link/link';
@import '@gitlab/ui/src/components/base/loading_icon/loading_icon';
@import '@gitlab/ui/src/components/base/modal/modal';
@import '@gitlab/ui/src/components/base/pagination/pagination';
-@import '@gitlab/ui/src/components/base/tabs/tabs/tabs';
+@import '@gitlab/ui/src/components/base/table/table';
@import '@gitlab/ui/src/components/base/tooltip/tooltip';
+@import '@gitlab/ui/src/components/base/search_box_by_type/search_box_by_type';
-$atlaskit-border-color: #dfe1e6;
$header-height: 40px;
-.subscription-form {
- .field-group-input {
- display: flex;
- padding-top: $gl-padding-4;
-
- .ak-button {
- align-items: center;
- height: auto;
- margin-left: $btn-margin-5;
- }
- }
-}
-
.jira-connect-header {
min-height: $header-height;
position: fixed;
@@ -56,45 +41,7 @@ $header-height: 40px;
}
.jira-connect-app-body {
- max-width: 600px;
+ max-width: 768px;
margin-left: auto;
margin-right: auto;
}
-
-// for external_link buttons
-svg {
- fill: currentColor;
-
- &.s16 {
- height: 16px;
- width: 16px;
- }
-}
-
-.ak-field-group label {
- text-align: left;
-}
-
-.ak-button__appearance-primary {
- &:hover {
- color: $white;
- text-decoration: none;
- }
-
- svg {
- align-self: center;
- margin-left: 4px;
- }
-}
-
-.subscriptions {
- tbody {
- tr {
- border-bottom: 1px solid $atlaskit-border-color;
- }
-
- td {
- padding: $gl-padding-8;
- }
- }
-}
diff --git a/app/assets/stylesheets/page_bundles/learn_gitlab.scss b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
index 189aefb330b..10a4a210d41 100644
--- a/app/assets/stylesheets/page_bundles/learn_gitlab.scss
+++ b/app/assets/stylesheets/page_bundles/learn_gitlab.scss
@@ -1,3 +1,11 @@
.learn-gitlab-info-card-content {
height: 200px;
}
+
+.learn-gitlab-section-card {
+ height: 400px;
+}
+
+.learn-gitlab-section-card-header {
+ height: 165px;
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 3263a5067ea..9fdc30359f8 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -9,6 +9,18 @@
min-width: 0;
}
+.with-system-header {
+ --system-header-height: #{$system-header-height};
+}
+
+.with-performance-bar {
+ --performance-bar-height: #{$performance-bar-height};
+}
+
+.review-bar-visible {
+ --review-bar-height: #{$mr-review-bar-height};
+}
+
.diff-tree-list {
// This 11px value should match the additional value found in
// /assets/stylesheets/framework/diffs.scss
@@ -23,24 +35,13 @@
position: -webkit-sticky;
position: sticky;
- top: $top-pos;
- max-height: calc(100vh - #{$top-pos});
+ // Unitless zero values are not allowed in calculations https://stackoverflow.com/a/55391061
+ // stylelint-disable-next-line length-zero-no-unit
+ top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
+ // stylelint-disable-next-line length-zero-no-unit
+ max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
z-index: 202;
- .with-system-header & {
- top: $top-pos + $system-header-height;
- }
-
- .with-system-header.with-performance-bar & {
- top: $top-pos + $system-header-height + $performance-bar-height;
- }
-
- .with-performance-bar & {
- $performance-bar-top-pos: $performance-bar-height + $top-pos;
- top: $performance-bar-top-pos;
- max-height: calc(100vh - #{$performance-bar-top-pos});
- }
-
.drag-handle {
bottom: 16px;
transform: translateX(10px);
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index d9ab52774bd..2f3cf889549 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -139,12 +139,28 @@
width: 186px;
}
+.gl-pipeline-job-width\! {
+ width: 186px !important;
+}
+
.gl-linked-pipeline-padding {
padding-right: 120px;
}
.gl-build-content {
- @include build-content();
+ display: inline-block;
+ padding: 8px 10px 9px;
+ width: 100%;
+ border: 1px solid var(--border-color, $border-color);
+ border-radius: 30px;
+ background-color: var(--white, $white);
+
+ &:hover,
+ &:focus {
+ background-color: var(--gray-50, $gray-50);
+ border: 1px solid $dropdown-toggle-active-border-color;
+ color: var(--gl-text-color, $gl-text-color);
+ }
}
.gl-ci-action-icon-container {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 7111d3d4107..a114a1dc82d 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -1,4 +1,5 @@
-%commit-description-base {
+.commit-description,
+.commit-row-description {
padding: $gl-padding-8 0 $gl-padding-8 $gl-padding-8;
margin-top: $gl-padding-8;
border: 0;
@@ -10,10 +11,6 @@
color: $gl-text-color-secondary;
}
-.commit-description {
- @extend %commit-description-base;
-}
-
.commit-box {
border-top: 1px solid $border-color;
padding: $gl-padding 0;
@@ -249,7 +246,6 @@
}
.commit-row-description {
- @extend %commit-description-base;
display: none;
flex: 1;
}
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index ef737e11799..14cff5b038a 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -36,7 +36,7 @@
}
.file-title {
- @extend .monospace;
+ @include gl-font-monospace;
line-height: 35px;
padding-top: 7px;
padding-bottom: 7px;
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 5738cbb4b31..c8da025131d 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -50,7 +50,7 @@
.event-user-info {
margin-bottom: $gl-padding-4;
- .author_name {
+ .author-name {
a {
color: $gl-text-color;
font-weight: $gl-font-weight-bold;
@@ -118,26 +118,11 @@
}
}
- .event_icon {
- position: relative;
- float: right;
- border: 1px solid $gray-darker;
- padding: 5px;
- border-radius: 5px;
- background: $gray-light;
- margin-left: 10px;
- top: -6px;
-
- img {
- width: 20px;
- }
- }
-
&:last-child {
border: 0;
}
- .event_commits {
+ .event-commits {
li {
&.commit {
background: transparent;
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index ee1385d36df..2ec2da9241b 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -160,17 +160,6 @@
vertical-align: top;
}
-
- .notification-dropdown {
- .dropdown-menu {
- @extend .dropdown-menu-right;
- }
-
- .icon {
- fill: $gl-text-color-secondary;
- }
- }
-
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
@@ -378,10 +367,6 @@ table.pipeline-project-metrics tr td {
.folder-caret {
width: $gl-font-size-large;
-
- svg {
- margin-bottom: 2px;
- }
}
.item-type-icon {
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index 540060d60de..c05216ac6e6 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -26,13 +26,6 @@
text-align: right;
white-space: nowrap;
}
-
- .key {
- @extend .badge.badge-pill;
- background-color: $label-inverse-bg;
- font: 11px Consolas, 'Liberation Mono', Menlo, Courier, monospace;
- padding: 3px 5px;
- }
}
.documentation {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index a6ab5459a84..b9f5a427a24 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -225,8 +225,7 @@
}
}
- .cross-project-reference,
- .sidebar-mr-source-branch {
+ .cross-project-reference {
color: inherit;
span {
@@ -238,10 +237,6 @@
text-overflow: ellipsis;
}
- cite {
- font-style: normal;
- }
-
button {
float: right;
padding: 1px 5px;
@@ -292,7 +287,7 @@
padding-top: 10px;
}
- &:not(.issue-boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
+ &:not(.boards-sidebar):not([data-signed-in]):not([data-always-show-toggle]) {
.issuable-sidebar-header {
display: none;
}
@@ -638,7 +633,7 @@
}
.btn-link:hover {
- @extend a:hover;
+ color: $blue-800;
text-decoration: none;
}
@@ -797,6 +792,40 @@
}
}
+.add-issuable-form-input-token-list {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ list-style: none;
+ margin-bottom: 0;
+ padding-left: 0;
+}
+
+.add-issuable-form-token-list-item {
+ max-width: 100%;
+ margin-bottom: $gl-vert-padding;
+ margin-right: 5px;
+}
+
+.add-issuable-form-input-list-item {
+ flex: 1;
+ min-width: 200px;
+ margin-bottom: $gl-vert-padding;
+}
+
+.add-issuable-form-input {
+ width: 100%;
+ border: 0;
+
+ &:focus {
+ outline: none;
+ }
+}
+
+.add-issuable-form-actions {
+ margin-top: $gl-padding;
+}
+
.time-tracker {
.sidebar-collapsed-icon {
> .stopwatch-svg {
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 2a8a86615f6..97c8182bab8 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -173,11 +173,10 @@ ul.related-merge-requests > li {
margin-top: 4px;
// override dropdown item styles
- .btn.btn-success {
+ .btn.btn-confirm {
@include btn-default;
- @include btn-green;
+ @include btn-blue;
- border-style: solid;
border-width: 1px;
line-height: $line-height-base;
width: auto;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index b7d05fc411a..c7b4dd660d0 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -120,7 +120,6 @@
}
.labels-container {
- background-color: $gray-100;
border-radius: $border-radius-default;
padding: $gl-padding $gl-padding-8;
}
@@ -160,7 +159,7 @@
color: $blue-600;
}
- &.remove-row:hover {
+ &.hover-red:hover {
color: $red-500;
}
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 2d04354a99d..9d437531e6d 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -179,8 +179,9 @@
}
input[type='submit'] {
- @extend .btn-block;
margin-bottom: 0;
+ display: block;
+ width: 100%;
}
.devise-errors {
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 23e368a2e73..36d39c1a613 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -236,8 +236,8 @@ $tabs-holder-z-index: 250;
}
.label-branch {
- @extend .ref-name;
-
+ @include gl-font-monospace;
+ font-size: 95%;
color: $gl-text-color;
font-weight: normal;
overflow: hidden;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index cb5050fc578..59768f4cda8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -48,7 +48,6 @@
}
.note-image-attach {
- @extend .col-lg-4;
margin-left: 45px;
float: none;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 190bdcb1efd..801dd44be8e 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -771,6 +771,26 @@ $system-note-svg-size: 16px;
}
}
+.unified-diff-components-diff-note-button {
+ &::before {
+ background-color: $blue-500;
+ mask-image: asset_url('icons-stacked.svg#comment');
+ mask-repeat: no-repeat;
+ mask-size: cover;
+ mask-position: center;
+ content: '';
+ width: 12px;
+ height: 12px;
+ }
+
+ &:hover,
+ &.inverted {
+ &::before {
+ background-color: $white;
+ }
+ }
+}
+
.disabled-comment {
background-color: $gray-light;
border-radius: $border-radius-base;
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index 33ab42b5511..298de33888d 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -1,8 +1,4 @@
.notification-list-item {
- .dropdown-menu {
- @extend .dropdown-menu-right;
- }
-
@include media-breakpoint-down(sm) {
.notification-dropdown {
width: 100%;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index e51cc0b4479..16f96ebadc9 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -115,11 +115,6 @@
font-size: $gl-font-size;
line-height: $gl-font-size-large;
}
-
- .home-panel-topic-list,
- .home-panel-metadata {
- font-size: $gl-font-size-small;
- }
}
}
@@ -141,10 +136,6 @@
}
}
- .notification-dropdown .dropdown-menu {
- @extend .dropdown-menu-right;
- }
-
.download-button {
@include media-breakpoint-down(md) {
margin-left: 0;
@@ -843,7 +834,7 @@ pre.light-well {
}
.form-control {
- @extend .monospace;
+ @include gl-font-monospace;
background-color: $white;
border-color: $border-color;
font-size: 14px;
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
deleted file mode 100644
index 856e49bd144..00000000000
--- a/app/assets/stylesheets/pages/runners.scss
+++ /dev/null
@@ -1,56 +0,0 @@
-.runner-state {
- padding: 6px 12px;
- margin-right: 10px;
- color: $white;
-
- &.runner-state-shared {
- background: $green-400;
- }
-
- &.runner-state-specific {
- background: $blue-400;
- }
-}
-
-.runner-status {
- &.runner-status-online {
- background-color: $green-600;
- }
-
- &.runner-status-offline {
- background-color: $gray-darkest;
- }
-
- &.runner-status-paused {
- background-color: $red-500;
- }
-}
-
-.runner {
- .btn {
- padding: 1px 6px;
- }
-
- h4 {
- font-weight: $gl-font-weight-normal;
- }
-}
-
-.admin-runner-btn-group-cell {
- min-width: 150px;
-
- .btn-sm {
- padding: 4px 9px;
- }
-
- .btn-default {
- color: $gl-text-color-secondary;
- }
-}
-
-@include media-breakpoint-down(md) {
- .runners-content {
- width: 100%;
- overflow: auto;
- }
-}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index f31b6d96f03..aa9ebfe2968 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -278,10 +278,6 @@
.card-header {
display: flex;
align-items: center;
-
- > .btn-success {
- margin-left: auto;
- }
}
.custom-metric {
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index fa008a05e11..a371aa37e07 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -22,7 +22,7 @@
.control {
float: left;
- margin-left: 10px;
+ margin-left: 8px;
}
}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index c6c9f3b7365..bcc3c35e00e 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -14,7 +14,7 @@
color: $gray-300;
select {
- color: $gray-300;
+ color: $white;
width: 200px;
}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index 7b66b61ff36..1d0333d1e2f 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -60,3 +60,11 @@ pre {
a[href]::after {
content: none !important;
}
+
+.with-performance-bar .layout-page {
+ margin-top: 0;
+}
+
+.content-wrapper {
+ margin-top: 0;
+}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index d8f74a2913e..11b4bde74a6 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -214,6 +214,9 @@ $yiq-text-light: $gray-950;
$line-added-dark: $green-200;
$line-removed-dark: $red-200;
+$well-expand-item: $gray-200;
+$well-inner-border: $gray-200;
+
// Misc component overrides that should live elsewhere
.gl-label {
filter: brightness(0.9) contrast(1.1);
diff --git a/app/assets/stylesheets/themes/theme_light.scss b/app/assets/stylesheets/themes/theme_light.scss
index 228bff94f5d..b41377475c5 100644
--- a/app/assets/stylesheets/themes/theme_light.scss
+++ b/app/assets/stylesheets/themes/theme_light.scss
@@ -81,50 +81,4 @@ body {
color: $gray-900;
}
}
-
- &.gl-dark {
- .logo-text svg {
- fill: var(--gl-text-color);
- }
-
- .navbar-gitlab {
- background-color: var(--gray-50);
- box-shadow: 0 1px 0 0 var(--gray-100);
-
- .navbar-sub-nav,
- .navbar-nav {
- li {
- > a:hover,
- > a:focus,
- > button:hover,
- > button:focus {
- color: var(--gl-text-color);
- background-color: var(--gray-200);
- }
- }
-
- li.active,
- li.dropdown.show {
- > a,
- > button {
- color: var(--gl-text-color);
- background-color: var(--gray-200);
- }
- }
- }
-
- .search {
- form {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--border-color);
-
- &:active,
- &:hover {
- background-color: var(--gray-100);
- box-shadow: inset 0 0 0 1px var(--blue-200);
- }
- }
- }
- }
- }
}