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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/dev_ops_report_no_data.svg39
-rw-r--r--app/assets/images/learn_gitlab/get_started.svg1
-rw-r--r--app/assets/images/learn_gitlab/graduation_hat.svg1
-rw-r--r--app/assets/images/learn_gitlab/rectangle.svg1
-rw-r--r--app/assets/javascripts/actioncable_link.js40
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue12
-rw-r--r--app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue5
-rw-r--r--app/assets/javascripts/admin/users/components/actions/unblock.vue4
-rw-r--r--app/assets/javascripts/admin/users/components/usage_ping_disabled.vue48
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue5
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue66
-rw-r--r--app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql8
-rw-r--r--app/assets/javascripts/admin/users/index.js30
-rw-r--r--app/assets/javascripts/admin/users/tabs.js32
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_table.vue4
-rw-r--r--app/assets/javascripts/alert_management/list.js2
-rw-r--r--app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue25
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue2
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue84
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql2
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql (renamed from app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql)5
-rw-r--r--app/assets/javascripts/alerts_settings/utils/cache_updates.js33
-rw-r--r--app/assets/javascripts/alerts_settings/utils/mapping_transformations.js22
-rw-r--r--app/assets/javascripts/analytics/devops_report/components/devops_score.vue110
-rw-r--r--app/assets/javascripts/analytics/devops_report/devops_score.js22
-rw-r--r--app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js (renamed from app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js)2
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue11
-rw-r--r--app/assets/javascripts/awards_handler.js1
-rw-r--r--app/assets/javascripts/badges/components/badge_list.vue5
-rw-r--r--app/assets/javascripts/batch_comments/components/drafts_count.vue2
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue16
-rw-r--r--app/assets/javascripts/batch_comments/components/publish_button.vue2
-rw-r--r--app/assets/javascripts/behaviors/date_picker.js33
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js6
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js19
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcut.vue80
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue574
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue6
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue1
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js2
-rw-r--r--app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js29
-rw-r--r--app/assets/javascripts/blob/viewer/index.js18
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js9
-rw-r--r--app/assets/javascripts/boards/boards_util.js31
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue5
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue11
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue44
-rw-r--r--app/assets/javascripts/boards/components/board_filtered_search.vue154
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue8
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue46
-rw-r--r--app/assets/javascripts/boards/constants.js42
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js4
-rw-r--r--app/assets/javascripts/boards/graphql/group_projects.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/boards/index.js24
-rw-r--r--app/assets/javascripts/boards/stores/actions.js113
-rw-r--r--app/assets/javascripts/boards/stores/getters.js2
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js48
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue23
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/components/step.vue150
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/constants.js67
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/index.js14
-rw-r--r--app/assets/javascripts/code_quality_walkthrough/utils.js38
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js4
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue22
-rw-r--r--app/assets/javascripts/content_editor/components/divider.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue65
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue94
-rw-r--r--app/assets/javascripts/content_editor/constants.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/bold.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/code.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js53
-rw-r--r--app/assets/javascripts/content_editor/extensions/document.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/dropcursor.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/gapcursor.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/heading.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/history.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/horizontal_rule.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/italic.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/list_item.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/text.js5
-rw-r--r--app/assets/javascripts/content_editor/index.js2
-rw-r--r--app/assets/javascripts/content_editor/services/build_serializer_config.js22
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js25
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js76
-rw-r--r--app/assets/javascripts/content_editor/services/create_editor.js60
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js101
-rw-r--r--app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js61
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue134
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js1
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js35
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js112
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js18
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js51
-rw-r--r--app/assets/javascripts/cycle_analytics/store/index.js21
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js52
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js17
-rw-r--r--app/assets/javascripts/cycle_analytics/utils.js63
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutations.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/action_btn.vue29
-rw-r--r--app/assets/javascripts/deploy_keys/components/app.vue43
-rw-r--r--app/assets/javascripts/deploy_keys/components/confirm_modal.vue46
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue52
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue16
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/diffs/components/app.vue96
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/compare_dropdown_layout.vue65
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue27
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue13
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue51
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue1
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue4
-rw-r--r--app/assets/javascripts/diffs/constants.js17
-rw-r--r--app/assets/javascripts/diffs/store/actions.js53
-rw-r--r--app/assets/javascripts/diffs/store/getters.js3
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js20
-rw-r--r--app/assets/javascripts/diffs/utils/diff_file.js2
-rw-r--r--app/assets/javascripts/due_date_select.js33
-rw-r--r--app/assets/javascripts/editor/editor_lite.js2
-rw-r--r--app/assets/javascripts/editor/extensions/editor_lite_extension_base.js8
-rw-r--r--app/assets/javascripts/emoji/components/picker.vue1
-rw-r--r--app/assets/javascripts/ensure_data.js4
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue40
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js2
-rw-r--r--app/assets/javascripts/environments/stores/helpers.js2
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue32
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue2
-rw-r--r--app/assets/javascripts/experimentation/components/gitlab_experiment.vue (renamed from app/assets/javascripts/experimentation/components/experiment.vue)0
-rw-r--r--app/assets/javascripts/experimentation/utils.js11
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue5
-rw-r--r--app/assets/javascripts/feature_flags/index.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js2
-rw-r--r--app/assets/javascripts/flash.js12
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue29
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list.vue6
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue5
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue7
-rw-r--r--app/assets/javascripts/frequent_items/constants.js14
-rw-r--r--app/assets/javascripts/frequent_items/index.js46
-rw-r--r--app/assets/javascripts/frequent_items/store/index.js29
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js19
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql10
-rw-r--r--app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql9
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue26
-rw-r--r--app/assets/javascripts/header.js4
-rw-r--r--app/assets/javascripts/help/help.js11
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue1
-rw-r--r--app/assets/javascripts/ide/components/file_alert.vue26
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue37
-rw-r--r--app/assets/javascripts/ide/index.js5
-rw-r--r--app/assets/javascripts/ide/lib/alerts/environments.vue32
-rw-r--r--app/assets/javascripts/ide/lib/alerts/index.js20
-rw-r--r--app/assets/javascripts/ide/messages.js6
-rw-r--r--app/assets/javascripts/ide/services/gql.js1
-rw-r--r--app/assets/javascripts/ide/services/index.js16
-rw-r--r--app/assets/javascripts/ide/stores/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions/alert.js18
-rw-r--r--app/assets/javascripts/ide/stores/getters.js2
-rw-r--r--app/assets/javascripts/ide/stores/getters/alert.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations/alert.js21
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue80
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue54
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js244
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql15
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql3
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql9
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql6
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js111
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js14
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql65
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue34
-rw-r--r--app/assets/javascripts/incidents_settings/constants.js4
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js2
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_checkbox.vue1
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue4
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue6
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_modal.vue67
-rw-r--r--app/assets/javascripts/invite_member/components/invite_member_trigger.vue43
-rw-r--r--app/assets/javascripts/invite_member/constants.js2
-rw-r--r--app/assets/javascripts/invite_member/event_hub.js3
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_modal.js27
-rw-r--r--app/assets/javascripts/invite_member/init_invite_member_trigger.js18
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue6
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue103
-rw-r--r--app/assets/javascripts/issuable_form.js16
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue17
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_list_root.vue77
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_tabs.vue5
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue64
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue622
-rw-r--r--app/assets/javascripts/issues_list/constants.js366
-rw-r--r--app/assets/javascripts/issues_list/index.js42
-rw-r--r--app/assets/javascripts/issues_list/utils.js195
-rw-r--r--app/assets/javascripts/jira_connect/components/groups_list.vue32
-rw-r--r--app/assets/javascripts/jira_connect/constants.js3
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue26
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue27
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue7
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue14
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/duration_cell.vue49
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/job_cell.vue163
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue50
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql17
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js10
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue85
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue14
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue35
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue2
-rw-r--r--app/assets/javascripts/jobs/constants.js2
-rw-r--r--app/assets/javascripts/jobs/index.js2
-rw-r--r--app/assets/javascripts/learn_gitlab/track_learn_gitlab.js10
-rw-r--r--app/assets/javascripts/lib/graphql.js47
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js3
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/keys.js1
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js10
-rw-r--r--app/assets/javascripts/lib/utils/recurrence.js154
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js2
-rw-r--r--app/assets/javascripts/lib/utils/uuids.js (renamed from app/assets/javascripts/diffs/utils/uuids.js)0
-rw-r--r--app/assets/javascripts/lib/utils/vuex_module_mappers.js91
-rw-r--r--app/assets/javascripts/logs/components/log_advanced_filters.vue5
-rw-r--r--app/assets/javascripts/logs/stores/actions.js2
-rw-r--r--app/assets/javascripts/main.js2
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue16
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue124
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue32
-rw-r--r--app/assets/javascripts/members/index.js2
-rw-r--r--app/assets/javascripts/members/store/state.js2
-rw-r--r--app/assets/javascripts/members/utils.js18
-rw-r--r--app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue34
-rw-r--r--app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue2
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue111
-rw-r--r--app/assets/javascripts/merge_request/components/status_box.vue71
-rw-r--r--app/assets/javascripts/merge_request/eventhub.js3
-rw-r--r--app/assets/javascripts/merge_request_tabs.js2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue4
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js6
-rw-r--r--app/assets/javascripts/monitoring/utils.js1
-rw-r--r--app/assets/javascripts/nav/components/top_nav_app.vue59
-rw-r--r--app/assets/javascripts/nav/components/top_nav_container_view.vue74
-rw-r--r--app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue144
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_item.vue31
-rw-r--r--app/assets/javascripts/nav/index.js12
-rw-r--r--app/assets/javascripts/nav/mount.js23
-rw-r--r--app/assets/javascripts/nav/stores/index.js4
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue35
-rw-r--r--app/assets/javascripts/notes.js2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue26
-rw-r--r--app/assets/javascripts/notes/stores/actions.js13
-rw-r--r--app/assets/javascripts/notes/stores/getters.js5
-rw-r--r--app/assets/javascripts/packages/details/components/maven_installation.vue30
-rw-r--r--app/assets/javascripts/packages/details/components/npm_installation.vue27
-rw-r--r--app/assets/javascripts/packages/details/components/package_files.vue1
-rw-r--r--app/assets/javascripts/packages/details/constants.js3
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js11
-rw-r--r--app/assets/javascripts/packages/list/components/package_search.vue5
-rw-r--r--app/assets/javascripts/packages/list/constants.js2
-rw-r--r--app/assets/javascripts/packages/list/stores/actions.js6
-rw-r--r--app/assets/javascripts/packages/list/stores/mutations.js3
-rw-r--r--app/assets/javascripts/packages/shared/components/package_list_row.vue35
-rw-r--r--app/assets/javascripts/packages/shared/components/package_path.vue19
-rw-r--r--app/assets/javascripts/packages/shared/constants.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue118
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue38
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue114
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js12
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue (renamed from app/assets/javascripts/registry/settings/components/expiration_dropdown.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue (renamed from app/assets/javascripts/registry/settings/components/expiration_input.vue)5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue (renamed from app/assets/javascripts/registry/settings/components/expiration_run_text.vue)5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue (renamed from app/assets/javascripts/registry/settings/components/expiration_toggle.vue)5
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue (renamed from app/assets/javascripts/registry/settings/components/registry_settings_app.vue)4
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue (renamed from app/assets/javascripts/registry/settings/components/settings_form.vue)8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js (renamed from app/assets/javascripts/registry/settings/constants.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql (renamed from app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js (renamed from app/assets/javascripts/registry/settings/graphql/index.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql (renamed from app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql (renamed from app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js (renamed from app/assets/javascripts/registry/settings/graphql/utils/cache_update.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js (renamed from app/assets/javascripts/registry/settings/registry_settings_bundle.js)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/utils.js (renamed from app/assets/javascripts/registry/settings/utils.js)0
-rw-r--r--app/assets/javascripts/pages/admin/dev_ops_report/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/labels/index/index.js22
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue18
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/milestones/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/milestones/new/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/settings/packages_and_registries/show/index.js (renamed from app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js)0
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/show/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js2
-rw-r--r--app/assets/javascripts/pages/help/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js12
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/compare/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue126
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js42
-rw-r--r--app/assets/javascripts/pages/projects/issues/service_desk/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js4
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue17
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue10
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue12
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue10
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js (renamed from app/assets/javascripts/compare_autocomplete.js)8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js16
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql7
-rw-r--r--app/assets/javascripts/pages/projects/milestones/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/new/components/app.vue148
-rw-r--r--app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue (renamed from app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue)0
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js54
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/new/index.js18
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/snippets/show/index.js8
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue298
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js4
-rw-r--r--app/assets/javascripts/performance/constants.js12
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue67
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue35
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue75
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue24
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue105
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue17
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue43
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue154
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue12
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue44
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue155
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js6
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql1
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql13
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js17
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js27
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue146
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue10
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue26
-rw-r--r--app/assets/javascripts/pipeline_new/components/refs_dropdown.vue3
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js3
-rw-r--r--app/assets/javascripts/pipeline_new/utils/filter_variables.js13
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue63
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue159
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js7
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js5
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js1
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue89
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue61
-rw-r--r--app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js25
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue76
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue115
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue21
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue89
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/empty_state.vue60
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue10
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js8
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/pipelines/utils.js9
-rw-r--r--app/assets/javascripts/project_select.js1
-rw-r--r--app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue1
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue57
-rw-r--r--app/assets/javascripts/projects/compare/components/repo_dropdown.vue52
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_card.vue26
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown.vue15
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue2
-rw-r--r--app/assets/javascripts/projects/compare/index.js6
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue201
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue66
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/constants.js1
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg9
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg23
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg13
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg38
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/index.js20
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue11
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue4
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_header.vue60
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue155
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue30
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue4
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js2
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql27
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql29
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql6
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue105
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue12
-rw-r--r--app/assets/javascripts/releases/components/app_show.vue2
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination.vue35
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_graphql.vue35
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_rest.vue24
-rw-r--r--app/assets/javascripts/releases/components/tag_field_new.vue17
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql (renamed from app/assets/javascripts/releases/queries/release.fragment.graphql)0
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql23
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql10
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql5
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql5
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql5
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql (renamed from app/assets/javascripts/releases/queries/all_releases.query.graphql)2
-rw-r--r--app/assets/javascripts/releases/graphql/queries/one_release.query.graphql (renamed from app/assets/javascripts/releases/queries/one_release.query.graphql)2
-rw-r--r--app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql9
-rw-r--r--app/assets/javascripts/releases/mount_index.js5
-rw-r--r--app/assets/javascripts/releases/stores/getters.js11
-rw-r--r--app/assets/javascripts/releases/stores/index.js2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js252
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js36
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/actions.js66
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/mutations.js8
-rw-r--r--app/assets/javascripts/releases/stores/modules/index/state.js3
-rw-r--r--app/assets/javascripts/releases/util.js87
-rw-r--r--app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue5
-rw-r--r--app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue18
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/actions.js31
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/mutations.js3
-rw-r--r--app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js (renamed from app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js)16
-rw-r--r--app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js28
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue10
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue111
-rw-r--r--app/assets/javascripts/repository/components/blob_header_edit.vue25
-rw-r--r--app/assets/javascripts/repository/components/upload_blob_modal.vue2
-rw-r--r--app/assets/javascripts/repository/index.js5
-rw-r--r--app/assets/javascripts/repository/pages/blob.vue6
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql48
-rw-r--r--app/assets/javascripts/repository/router.js1
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue45
-rw-r--r--app/assets/javascripts/runner/constants.js11
-rw-r--r--app/assets/javascripts/runner/graphql/get_runner.query.graphql6
-rw-r--r--app/assets/javascripts/runner/runner_details/constants.js3
-rw-r--r--app/assets/javascripts/runner/runner_details/index.js16
-rw-r--r--app/assets/javascripts/runner/runner_details/runner_details_app.vue29
-rw-r--r--app/assets/javascripts/security_configuration/components/configuration_table.vue15
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js (renamed from app/assets/javascripts/security_configuration/components/scanners_constants.js)16
-rw-r--r--app/assets/javascripts/security_configuration/components/feature_card.vue150
-rw-r--r--app/assets/javascripts/security_configuration/components/manage_sast.vue59
-rw-r--r--app/assets/javascripts/security_configuration/components/upgrade.vue2
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue7
-rw-r--r--app/assets/javascripts/shared/milestones/form.js5
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue79
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue287
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue44
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue296
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue56
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue110
-rw-r--r--app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue203
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue68
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue12
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue42
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue202
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue112
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue24
-rw-r--r--app/assets/javascripts/sidebar/constants.js98
-rw-r--r--app/assets/javascripts/sidebar/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/sidebar/graphql.js16
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js47
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql13
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_participants.query.graphql18
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql13
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql11
-rw-r--r--app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql16
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql11
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql10
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql11
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql11
-rw-r--r--app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql9
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql11
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql11
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js1
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js2
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue1
-rw-r--r--app/assets/javascripts/static_site_editor/services/generate_branch_name.js4
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js26
-rw-r--r--app/assets/javascripts/task_list.js21
-rw-r--r--app/assets/javascripts/tracking.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js23
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue46
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue56
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue7
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue (renamed from app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue)2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue (renamed from app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue167
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/keep_alive_slots.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql18
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue302
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue21
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js66
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js20
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue (renamed from app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue71
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue135
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue83
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/provider.js9
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql (renamed from app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql)0
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql18
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/utils.js14
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue13
-rw-r--r--app/assets/javascripts/whats_new/components/feature.vue60
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js4
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss4
-rw-r--r--app/assets/stylesheets/components/feature_highlight.scss22
-rw-r--r--app/assets/stylesheets/components/whats_new.scss12
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/blank.scss136
-rw-r--r--app/assets/stylesheets/framework/buttons.scss2
-rw-r--r--app/assets/stylesheets/framework/common.scss9
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss24
-rw-r--r--app/assets/stylesheets/framework/diffs.scss6
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss102
-rw-r--r--app/assets/stylesheets/framework/editor-lite.scss2
-rw-r--r--app/assets/stylesheets/framework/header.scss60
-rw-r--r--app/assets/stylesheets/framework/issue_box.scss11
-rw-r--r--app/assets/stylesheets/framework/kbd.scss15
-rw-r--r--app/assets/stylesheets/framework/lists.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss5
-rw-r--r--app/assets/stylesheets/framework/spinner.scss49
-rw-r--r--app/assets/stylesheets/framework/timeline.scss6
-rw-r--r--app/assets/stylesheets/framework/typography.scss16
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/_pipeline_mixins.scss25
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/dev_ops_report.scss261
-rw-r--r--app/assets/stylesheets/page_bundles/jira_connect.scss6
-rw-r--r--app/assets/stylesheets/page_bundles/merge_conflicts.scss17
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/milestone.scss17
-rw-r--r--app/assets/stylesheets/page_bundles/new_namespace.scss28
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/todos.scss170
-rw-r--r--app/assets/stylesheets/pages/editor.scss27
-rw-r--r--app/assets/stylesheets/pages/help.scss44
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/note_form.scss4
-rw-r--r--app/assets/stylesheets/pages/notes.scss3
-rw-r--r--app/assets/stylesheets/pages/projects.scss10
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss4
-rw-r--r--app/assets/stylesheets/themes/_dark.scss6
-rw-r--r--app/assets/stylesheets/utilities.scss35
645 files changed, 13117 insertions, 6277 deletions
diff --git a/app/assets/images/dev_ops_report_no_data.svg b/app/assets/images/dev_ops_report_no_data.svg
new file mode 100644
index 00000000000..5de929859ae
--- /dev/null
+++ b/app/assets/images/dev_ops_report_no_data.svg
@@ -0,0 +1,39 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220">
+ <g fill="none" fill-rule="evenodd">
+ <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/>
+ <g transform="translate(214 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/>
+ <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/>
+ <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/>
+ <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(118 7)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/>
+ <g fill-rule="nonzero">
+ <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/>
+ <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/>
+ </g>
+ <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/>
+ </g>
+ <g transform="translate(26 36)">
+ <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/>
+ <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/>
+ <g transform="translate(21 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(69 82)">
+ <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/>
+ <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/>
+ </g>
+ <g transform="translate(38 42)">
+ <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/>
+ <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/>
+ </g>
+ <path fill="#EEE" d="M4 14h106v4H4z"/>
+ </g>
+ </g>
+</svg>
diff --git a/app/assets/images/learn_gitlab/get_started.svg b/app/assets/images/learn_gitlab/get_started.svg
new file mode 100644
index 00000000000..0e682842b1f
--- /dev/null
+++ b/app/assets/images/learn_gitlab/get_started.svg
@@ -0,0 +1 @@
+<svg width="468" height="96" xmlns="http://www.w3.org/2000/svg"><g transform="translate(4)" fill="none" fill-rule="evenodd"><path d="M19.817 6.79c.398-1.258 1.516-2.107 2.776-2.107h1.634c1.26 0 2.378.849 2.776 2.107l1.537 4.858c1.088.35 2.13.807 3.117 1.36l4.35-2.29c1.127-.593 2.487-.36 3.378.577l1.156 1.217c.89.938 1.111 2.37.548 3.557l-2.175 4.582c.525 1.038.96 2.136 1.291 3.281l4.613 1.62c1.195.419 2.001 1.597 2.001 2.923v1.721c0 1.326-.806 2.504-2 2.923l-4.614 1.62a18.947 18.947 0 01-1.291 3.281l2.175 4.582c.563 1.186.342 2.619-.548 3.557l-1.156 1.217c-.89.938-2.251 1.17-3.378.577l-4.35-2.29a16.947 16.947 0 01-3.117 1.36l-1.537 4.858c-.398 1.258-1.517 2.107-2.776 2.107h-1.634c-1.26 0-2.378-.849-2.776-2.107l-1.538-4.858a16.973 16.973 0 01-3.116-1.36l-4.35 2.29c-1.127.593-2.488.36-3.379-.577L6.28 46.159c-.89-.938-1.112-2.37-.548-3.557l2.175-4.582a18.93 18.93 0 01-1.292-3.281l-4.613-1.62C.806 32.7 0 31.522 0 30.196v-1.721c0-1.326.806-2.504 2-2.923l4.614-1.62a18.932 18.932 0 011.292-3.281L5.73 16.069c-.564-1.186-.343-2.62.548-3.557l1.155-1.217c.89-.938 2.252-1.17 3.378-.577l4.35 2.29a16.975 16.975 0 013.117-1.36l1.538-4.858zm3.593 34.872c6.464 0 11.705-5.52 11.705-12.327S29.874 17.01 23.41 17.01c-6.465 0-11.705 5.519-11.705 12.326 0 6.808 5.24 12.327 11.705 12.327z" stroke="#6E49CB" stroke-width="2" fill="#EFEDF8"/><path d="M23.41 37.039c4.04 0 7.315-3.45 7.315-7.704 0-4.255-3.275-7.704-7.315-7.704-4.04 0-7.316 3.45-7.316 7.704 0 4.255 3.276 7.704 7.316 7.704z" stroke="#6E49CB" stroke-linecap="round"/><path d="M218.894 5.854c1.177 0 2.186.207 2.858.497.337.145.536.288.633.388a.555.555 0 01.034.037v44.362c-.008.01-.019.023-.034.038-.097.099-.296.242-.633.388-.672.289-1.681.497-2.858.497-1.176 0-2.186-.208-2.857-.497-.338-.146-.537-.289-.634-.388-.015-.015-.026-.028-.034-.038V6.776a.554.554 0 01.034-.037c.097-.1.296-.243.634-.388.671-.29 1.681-.497 2.857-.497z" stroke="#6E49CB" stroke-width="2" fill="#EFEDF8" fill-rule="nonzero"/><path d="M223.59 6.665l30.511 9.4c2.93.902 2.983 2.492.135 3.545l-30.647 11.33V6.664z" fill="#6E49CB"/><path d="M434.38 42.624h-.119l-.11.046c-2.675 1.14-5.224 1.423-7.758.61l-.283-.09-.241.176a4.655 4.655 0 01-6.045-.465v0l-3.178-3.179a4.659 4.659 0 01-.159-6.422l.206-.227-.069-.298c-.496-2.14-.302-4.32.467-6.575l.183-.533-.526-.202-4.709-1.818a3.605 3.605 0 01-2.195-2.467 3.622 3.622 0 01.73-3.221s0 0 0 0l6.166-7.35v0a3.612 3.612 0 014.064-1.047h.001l6.025 2.326.361.14.27-.28c.577-.594 1.167-1.193 1.772-1.798 5.89-5.893 13.552-9.022 23.03-9.36.258-.01.515-.004.773.016v0a6.906 6.906 0 016.359 7.412c-.692 9.033-3.963 16.452-9.804 22.292v0c-.413.414-.826.826-1.242 1.237l-.272.269.137.357 2.433 6.302s0 0 0 0a3.6 3.6 0 01.127 2.2 3.603 3.603 0 01-1.175 1.865h0l-7.347 6.168.376.448-.376-.448a3.613 3.613 0 01-3.221.73 3.614 3.614 0 01-2.466-2.196v0l-1.638-4.244-.144-.374h-.403zm-17.378-19.038l.391.15.268-.32 7.795-9.291.526-.628-.764-.295-5.11-1.972v0a1.785 1.785 0 00-2.008.517v0l-6.166 7.35v0a1.789 1.789 0 00-.36 1.592 1.78 1.78 0 001.085 1.22v0l4.343 1.677zm19.584 18.482v.109l.04.102 1.676 4.345v0c.103.265.266.501.478.69l.389-.437-.389.437a1.78 1.78 0 002.332.034l7.348-6.167v0c.285-.24.488-.561.58-.922l-.566-.146.567.146c.093-.36.071-.74-.062-1.087h-.001l-1.972-5.111-.294-.764-.628.526-9.289 7.796-.21.175v.274zm17.83-39.285l-.22.542.22-.542a5.079 5.079 0 00-2.089-.369h0c-8.997.321-16.228 3.251-21.803 8.827-6.376 6.377-10.292 11.317-11.722 15.569-.723 2.148-.817 4.138-.263 6.047.553 1.901 1.732 3.67 3.47 5.41 1.676 1.675 3.328 2.828 5.103 3.357 1.788.532 3.639.412 5.67-.351 4.005-1.504 8.816-5.55 15.517-12.253 5.54-5.54 8.618-12.556 9.273-21.141a5.076 5.076 0 00-3.156-5.096z" stroke="#6E49CB" fill="#6E49CB" fill-rule="nonzero"/><path d="M440.596 20.006a2.998 2.998 0 004.26.023 2.99 2.99 0 00.877-2.135 3.003 3.003 0 00-.901-2.126 2.996 2.996 0 10-4.237 4.238zm-2.12 2.12a6.001 6.001 0 01-1.3-6.532 5.98 5.98 0 013.244-3.245 5.99 5.99 0 016.53 1.3 5.992 5.992 0 01-8.473 8.475l-.001.001z" fill="#C2B7E6" fill-rule="nonzero"/><path d="M412.525 39.607a1.498 1.498 0 010 2.118l-8.473 8.475a1.502 1.502 0 01-2.137.02 1.513 1.513 0 01-.328-.493 1.49 1.49 0 01.345-1.645l8.475-8.476a1.501 1.501 0 011.632-.325c.183.075.348.186.486.325zm7.415 7.416a1.498 1.498 0 010 2.118l-6.356 6.357a1.501 1.501 0 01-1.631.325 1.495 1.495 0 01-.811-1.958c.075-.181.185-.347.324-.486l6.356-6.356a1.5 1.5 0 012.118 0z" fill="#E0DBF2"/><path d="M416.232 43.314a1.498 1.498 0 010 2.12l-11.65 11.654a1.497 1.497 0 01-2.558-1.06c0-.398.158-.779.439-1.06l11.652-11.653a1.497 1.497 0 012.117 0z" fill="#C2B7E6"/><path d="M7.468 84.21h1.474c-.022-.644-.147-1.196-.377-1.655a3.111 3.111 0 00-.917-1.163 3.704 3.704 0 00-1.344-.672 6.253 6.253 0 00-1.671-.213c-.536 0-1.06.07-1.573.213a4.175 4.175 0 00-1.36.622c-.394.274-.71.629-.951 1.066-.24.426-.36.934-.36 1.524 0 .535.103.984.31 1.344.22.35.503.64.853.869.36.218.765.399 1.213.54.447.132.9.252 1.36.361.47.099.928.197 1.376.295.448.099.847.23 1.196.394.36.153.645.355.853.606.218.251.327.58.327.983 0 .427-.087.776-.262 1.05a1.98 1.98 0 01-.688.655 3.252 3.252 0 01-.967.328c-.35.065-.7.098-1.049.098-.437 0-.863-.054-1.278-.164a3.269 3.269 0 01-1.098-.508 2.762 2.762 0 01-.754-.868c-.185-.361-.278-.787-.278-1.279H.028c0 .71.126 1.328.377 1.852.262.514.612.94 1.049 1.279a4.79 4.79 0 001.54.737 6.948 6.948 0 003.474.05 4.641 4.641 0 001.475-.59 3.472 3.472 0 001.065-1.082c.284-.448.426-.984.426-1.607 0-.579-.11-1.06-.328-1.442a2.736 2.736 0 00-.852-.95 4.215 4.215 0 00-1.196-.59 14.217 14.217 0 00-1.377-.394c-.458-.11-.912-.208-1.36-.295a7.987 7.987 0 01-1.212-.36 2.535 2.535 0 01-.852-.542c-.208-.229-.312-.524-.312-.885 0-.382.071-.699.213-.95.153-.263.35-.47.59-.623a2.66 2.66 0 01.852-.328c.317-.065.64-.098.967-.098.808 0 1.47.19 1.983.573.524.372.83.978.918 1.82zm9.511 3.23h-4.867a2.82 2.82 0 01.213-.918c.12-.295.284-.552.492-.77.207-.22.453-.388.737-.509.295-.13.623-.196.983-.196.35 0 .667.065.95.196.296.12.547.29.755.508.218.208.387.46.508.754.13.295.207.607.229.935zm1.344 2.36h-1.377c-.12.557-.371.972-.754 1.245-.37.274-.852.41-1.442.41-.458 0-.857-.076-1.196-.23a2.394 2.394 0 01-.836-.606 2.483 2.483 0 01-.475-.885 3.438 3.438 0 01-.13-1.065h6.34a6.499 6.499 0 00-.147-1.623 4.48 4.48 0 00-.622-1.573 3.533 3.533 0 00-1.197-1.18c-.491-.317-1.114-.476-1.868-.476-.579 0-1.114.11-1.606.328-.48.219-.9.525-1.261.918-.35.393-.623.858-.82 1.393a5.1 5.1 0 00-.295 1.77c.022.645.115 1.24.279 1.787.175.546.431 1.016.77 1.41.339.393.754.699 1.246.917.502.219 1.092.328 1.77.328.96 0 1.758-.24 2.392-.721s1.043-1.197 1.229-2.147zm3.508-5.786v-2.54h-1.393v2.54h-1.442v1.23h1.442v5.392c0 .393.039.71.115.95.077.24.191.426.344.558.164.13.372.224.623.278.262.044.573.066.934.066h1.065v-1.23h-.639a7.31 7.31 0 01-.54-.016.742.742 0 01-.312-.115.446.446 0 01-.164-.229 1.883 1.883 0 01-.033-.393v-5.262h1.688v-1.229h-1.688zm14.81 8.474v-8.474h-1.393V88.8c0 .382-.055.738-.164 1.065a2.31 2.31 0 01-.459.836c-.207.24-.47.426-.786.558-.306.13-.672.196-1.098.196-.535 0-.956-.153-1.262-.459-.306-.306-.459-.72-.459-1.245v-5.737h-1.393v5.573c0 .459.044.88.131 1.262.099.371.263.694.492.967.23.273.53.486.901.639.372.142.836.213 1.393.213.623 0 1.164-.12 1.622-.36.46-.252.836-.64 1.131-1.164h.033v1.344h1.31zm2.141-8.474v11.703h1.393v-4.36h.033c.153.251.338.464.557.64.23.163.47.294.72.392.252.099.503.17.755.214.262.043.497.065.704.065.645 0 1.207-.115 1.688-.344.492-.23.896-.541 1.213-.934a3.92 3.92 0 00.72-1.41c.165-.535.246-1.104.246-1.704s-.081-1.17-.245-1.705a4.132 4.132 0 00-.738-1.41 3.35 3.35 0 00-1.212-.983c-.481-.24-1.05-.36-1.705-.36-.59 0-1.13.109-1.622.327a2.05 2.05 0 00-1.081 1.016h-.033v-1.147h-1.393zm6.555 4.163c0 .415-.044.82-.131 1.213a3.21 3.21 0 01-.426 1.049 2.22 2.22 0 01-.787.738c-.317.185-.716.278-1.196.278-.481 0-.89-.087-1.23-.262a2.55 2.55 0 01-.835-.721 3.082 3.082 0 01-.459-1.016 4.983 4.983 0 01-.147-1.213c0-.394.043-.781.13-1.164.1-.382.252-.721.46-1.016.207-.306.475-.552.803-.738.327-.186.726-.278 1.196-.278.448 0 .836.087 1.163.262.339.175.612.41.82.705a2.9 2.9 0 01.475 1.016c.109.371.164.754.164 1.147zm129.786-1.786v-4.295h3.048c.885 0 1.53.186 1.934.558.415.36.622.89.622 1.59 0 .699-.207 1.234-.622 1.606-.404.371-1.05.552-1.934.54h-3.048zm-1.557-5.606v11.703h1.557v-4.786h3.572c1.18.01 2.071-.29 2.671-.902.613-.612.918-1.464.918-2.556 0-1.093-.305-1.94-.918-2.541-.6-.612-1.49-.918-2.67-.918h-5.13zm10.478 0v11.703h1.393V80.785h-1.393zm11.057 11.67c-.24.142-.574.213-1 .213-.36 0-.65-.098-.868-.295-.207-.207-.312-.54-.312-1-.382.46-.83.793-1.343 1a4.456 4.456 0 01-1.639.295c-.383 0-.748-.044-1.098-.131a2.545 2.545 0 01-.885-.41 2.06 2.06 0 01-.606-.721c-.142-.306-.213-.672-.213-1.098 0-.48.082-.874.246-1.18.164-.306.377-.552.639-.738.273-.196.58-.344.917-.442.35-.099.705-.18 1.066-.246.382-.077.743-.131 1.081-.164.35-.044.656-.098.918-.164.262-.076.47-.18.622-.311.154-.142.23-.345.23-.607 0-.306-.06-.552-.18-.737a1.1 1.1 0 00-.443-.427 1.72 1.72 0 00-.606-.196 4.412 4.412 0 00-.656-.05c-.59 0-1.081.115-1.474.345-.394.218-.607.639-.64 1.262h-1.392c.022-.525.13-.967.327-1.328.197-.36.46-.65.787-.868a3.21 3.21 0 011.114-.492 6.032 6.032 0 011.36-.148c.383 0 .76.028 1.131.082.383.055.727.17 1.032.345.306.163.552.398.738.704.186.306.278.705.278 1.197v4.36c0 .327.017.568.05.72.043.154.174.23.393.23.12 0 .262-.027.426-.082v1.082zm-2.262-4.343c-.174.13-.403.23-.688.295-.284.054-.584.103-.901.147a14.79 14.79 0 00-.934.131c-.317.044-.6.12-.852.23a1.58 1.58 0 00-.623.475c-.153.197-.23.47-.23.82 0 .229.044.426.132.59.098.153.219.278.36.377.154.098.328.169.525.213.196.043.404.065.622.065.46 0 .852-.06 1.18-.18.328-.131.596-.29.803-.475.207-.197.36-.405.459-.623.098-.23.147-.443.147-.64v-1.425zm3.559-4.098v8.474h1.393v-4.786c0-.383.049-.732.147-1.05.109-.327.267-.611.475-.851.208-.24.464-.427.77-.558a2.898 2.898 0 011.115-.196c.535 0 .955.153 1.262.459.305.305.458.72.458 1.245v5.737h1.393v-5.573c0-.459-.049-.874-.147-1.246a2.302 2.302 0 00-.475-.983 2.294 2.294 0 00-.902-.64c-.372-.152-.835-.229-1.393-.229-1.257 0-2.174.514-2.753 1.541h-.032v-1.344h-1.311zm17.123 1.278l-.394-.491a7.294 7.294 0 01-.36-.492 4.216 4.216 0 01-.279-.524 1.485 1.485 0 01-.098-.525c0-.426.143-.737.426-.934.285-.208.59-.312.918-.312.415 0 .743.126.983.377.241.24.36.53.36.87 0 .25-.049.48-.147.687a2.28 2.28 0 01-.377.541 3.227 3.227 0 01-.508.443c-.185.131-.36.251-.524.36zm2.999 5.737l1.245 1.459h1.819l-2.278-2.639c.12-.262.225-.492.311-.688.088-.208.16-.416.213-.623.066-.208.115-.432.148-.672.044-.252.082-.552.115-.902h-1.328a6.424 6.424 0 01-.377 1.82l-2.113-2.574c.284-.164.557-.344.819-.54a4.65 4.65 0 00.705-.689c.208-.251.372-.524.491-.82.12-.305.18-.633.18-.983 0-.393-.082-.737-.245-1.032a2.093 2.093 0 00-.623-.754 2.587 2.587 0 00-.918-.46 3.74 3.74 0 00-1.049-.147c-.426 0-.802.066-1.13.197a2.327 2.327 0 00-1.344 1.311 2.498 2.498 0 00-.164.902c0 .295.033.562.099.803.077.23.175.453.294.672.132.207.274.415.427.622.153.208.31.427.475.656a11.6 11.6 0 00-1.065.64c-.328.218-.617.47-.869.753-.24.273-.436.585-.59.935-.141.35-.213.753-.213 1.212 0 .24.039.541.115.902.088.36.262.71.524 1.049.262.339.635.628 1.115.869.48.24 1.114.36 1.9.36.645 0 1.268-.137 1.869-.41a3.23 3.23 0 001.442-1.229zm-3.458-4.196l2.687 3.229c-.283.426-.639.765-1.065 1.016-.414.252-.89.377-1.425.377-.284 0-.562-.049-.836-.147a2.615 2.615 0 01-.705-.394 2.164 2.164 0 01-.508-.622 1.886 1.886 0 01-.18-.82c0-.35.055-.65.164-.901.11-.263.257-.498.442-.705.186-.208.4-.394.64-.558.251-.163.513-.322.786-.475zm17.767.607h-4.867a2.82 2.82 0 01.213-.918 2.46 2.46 0 01.491-.77c.208-.22.453-.388.738-.509.295-.13.623-.196.983-.196.35 0 .666.065.95.196.295.12.547.29.754.508.22.208.388.46.508.754.131.295.207.607.23.935zm1.343 2.36h-1.376c-.12.557-.371.972-.754 1.245-.37.274-.852.41-1.442.41-.459 0-.858-.076-1.196-.23a2.4 2.4 0 01-.836-.606 2.477 2.477 0 01-.475-.885 3.448 3.448 0 01-.131-1.065h6.342a6.466 6.466 0 00-.148-1.623 4.48 4.48 0 00-.623-1.573 3.536 3.536 0 00-1.196-1.18c-.492-.317-1.114-.476-1.868-.476-.58 0-1.114.11-1.606.328a3.863 3.863 0 00-1.262.918c-.35.393-.622.858-.819 1.393a5.1 5.1 0 00-.295 1.77c.022.645.115 1.24.279 1.787.174.546.432 1.016.77 1.41.338.393.754.699 1.245.917.502.219 1.092.328 1.77.328.96 0 1.76-.24 2.392-.721.634-.48 1.043-1.197 1.23-2.147zm3.854-1.77l-3.18 4.458h1.688l2.36-3.508 2.36 3.508h1.786l-3.277-4.573 2.916-3.901h-1.671l-2.114 2.967-2.032-2.967h-1.786l2.95 4.016zm12.086-.59h-4.867a2.79 2.79 0 01.213-.918c.12-.295.283-.552.492-.77.207-.22.453-.388.737-.509.295-.13.623-.196.983-.196.35 0 .666.065.95.196.296.12.546.29.755.508.217.208.387.46.508.754.13.295.207.607.23.935zm1.344 2.36h-1.377c-.12.557-.372.972-.754 1.245-.372.274-.852.41-1.442.41-.458 0-.858-.076-1.196-.23a2.379 2.379 0 01-.835-.606 2.477 2.477 0 01-.476-.885 3.413 3.413 0 01-.13-1.065h6.34a6.577 6.577 0 00-.147-1.623 4.503 4.503 0 00-.622-1.573 3.536 3.536 0 00-1.197-1.18c-.491-.317-1.114-.476-1.868-.476-.579 0-1.114.11-1.606.328a3.863 3.863 0 00-1.261.918c-.35.393-.623.858-.82 1.393a5.1 5.1 0 00-.295 1.77c.021.645.115 1.24.279 1.787a3.98 3.98 0 00.77 1.41c.338.393.754.699 1.246.917.502.219 1.092.328 1.77.328.96 0 1.757-.24 2.392-.721.633-.48 1.043-1.197 1.229-2.147zm7.326-3.065h1.442c-.054-.503-.185-.934-.393-1.295a2.832 2.832 0 00-.803-.918 3.084 3.084 0 00-1.115-.524 4.806 4.806 0 00-1.343-.18c-.666 0-1.25.12-1.754.36-.502.23-.922.552-1.262.967a4.095 4.095 0 00-.737 1.442 6.135 6.135 0 00-.246 1.77c0 .634.082 1.219.246 1.754.176.525.426.978.754 1.36.34.383.754.678 1.245.886.504.207 1.077.311 1.72.311 1.082 0 1.934-.284 2.557-.852.635-.568 1.028-1.377 1.18-2.426h-1.426c-.086.656-.327 1.164-.72 1.524-.382.36-.918.541-1.607.541-.436 0-.813-.087-1.13-.262a2.238 2.238 0 01-.77-.688 3.318 3.318 0 01-.443-1 5.037 5.037 0 01-.13-1.148c0-.426.044-.835.13-1.229.088-.404.23-.76.426-1.065.209-.306.481-.552.82-.738.339-.186.76-.278 1.261-.278.59 0 1.06.147 1.41.442.35.295.579.71.688 1.246zm10.064 5.753v-8.474h-1.393V88.8c0 .382-.055.738-.164 1.065a2.316 2.316 0 01-.459.836c-.208.24-.47.426-.786.558-.307.13-.672.196-1.098.196-.536 0-.957-.153-1.262-.459-.307-.306-.459-.72-.459-1.245v-5.737h-1.393v5.573c0 .459.043.88.131 1.262.099.371.262.694.492.967.23.273.529.486.901.639.371.142.836.213 1.393.213.623 0 1.163-.12 1.622-.36.46-.252.836-.64 1.13-1.164h.034v1.344h1.31zm4.025-8.474v-2.54h-1.393v2.54h-1.442v1.23h1.442v5.392c0 .393.039.71.115.95.076.24.19.426.344.558.164.13.37.224.623.278.262.044.573.066.934.066h1.065v-1.23h-.64c-.218 0-.398-.005-.54-.016a.74.74 0 01-.311-.115.448.448 0 01-.164-.229 1.86 1.86 0 01-.033-.393v-5.262h1.688v-1.229h-1.688zm9.118 3.426h-4.867a2.82 2.82 0 01.213-.918 2.46 2.46 0 01.492-.77c.207-.22.453-.388.737-.509.295-.13.623-.196.983-.196.35 0 .666.065.95.196.296.12.547.29.755.508.218.208.387.46.508.754.13.295.207.607.229.935zm1.344 2.36h-1.377c-.12.557-.37.972-.754 1.245-.37.274-.852.41-1.442.41-.458 0-.858-.076-1.196-.23a2.4 2.4 0 01-.836-.606 2.477 2.477 0 01-.475-.885 3.448 3.448 0 01-.13-1.065h6.34a6.466 6.466 0 00-.147-1.623 4.48 4.48 0 00-.622-1.573 3.536 3.536 0 00-1.197-1.18c-.491-.317-1.114-.476-1.868-.476-.58 0-1.114.11-1.606.328a3.863 3.863 0 00-1.261.918c-.35.393-.623.858-.82 1.393a5.1 5.1 0 00-.295 1.77 7.05 7.05 0 00.279 1.787c.174.546.432 1.016.77 1.41.338.393.754.699 1.245.917.503.219 1.093.328 1.77.328.961 0 1.76-.24 2.393-.721s1.043-1.197 1.229-2.147zm126.596 1.377v-9.08h2.622c.72 0 1.327.103 1.819.31.491.197.89.493 1.196.886.316.382.54.852.672 1.41.141.546.213 1.169.213 1.868 0 .721-.078 1.338-.23 1.852-.143.503-.328.923-.557 1.262-.23.339-.492.607-.786.803-.285.197-.574.35-.869.46a4.367 4.367 0 01-.836.196 8.132 8.132 0 01-.655.033h-2.59zm-1.557-10.392v11.703h4.015c.971 0 1.813-.137 2.523-.41.71-.273 1.295-.666 1.754-1.18.458-.525.797-1.164 1.016-1.918.217-.765.327-1.639.327-2.622 0-1.88-.487-3.278-1.458-4.196-.973-.918-2.36-1.377-4.162-1.377h-4.015zm17.19 6.655h-4.866a2.85 2.85 0 01.213-.918 2.46 2.46 0 01.492-.77c.208-.22.454-.388.737-.509.295-.13.623-.196.983-.196.35 0 .667.065.95.196.296.12.547.29.754.508.22.208.389.46.508.754.132.295.209.607.23.935zm1.345 2.36h-1.377c-.12.557-.371.972-.754 1.245-.37.274-.852.41-1.442.41-.459 0-.857-.076-1.196-.23a2.4 2.4 0 01-.836-.606 2.495 2.495 0 01-.475-.885 3.448 3.448 0 01-.131-1.065h6.342a6.466 6.466 0 00-.148-1.623 4.458 4.458 0 00-.623-1.573 3.526 3.526 0 00-1.196-1.18c-.491-.317-1.114-.476-1.868-.476a3.91 3.91 0 00-1.606.328c-.48.219-.901.525-1.262.918a4.322 4.322 0 00-.819 1.393 5.1 5.1 0 00-.295 1.77 7.05 7.05 0 00.279 1.787c.175.546.432 1.016.77 1.41.34.393.754.699 1.245.917.504.219 1.094.328 1.77.328.962 0 1.76-.24 2.393-.721.634-.48 1.044-1.197 1.229-2.147zm1.624-5.786v11.703h1.393v-4.36h.033a2.4 2.4 0 00.557.64c.23.163.47.294.721.392.25.099.502.17.754.214.262.043.496.065.704.065.644 0 1.207-.115 1.688-.344.492-.23.896-.541 1.213-.934.328-.405.568-.875.72-1.41.165-.535.247-1.104.247-1.704s-.082-1.17-.246-1.705a4.132 4.132 0 00-.737-1.41 3.353 3.353 0 00-1.213-.983c-.481-.24-1.049-.36-1.704-.36-.59 0-1.131.109-1.623.327a2.05 2.05 0 00-1.081 1.016h-.033v-1.147h-1.393zm6.555 4.163c0 .415-.045.82-.131 1.213-.088.393-.23.743-.426 1.049a2.219 2.219 0 01-.787.738c-.317.185-.716.278-1.196.278-.481 0-.89-.087-1.23-.262a2.534 2.534 0 01-.835-.721 3.065 3.065 0 01-.459-1.016 5.185 5.185 0 01-.016-2.377c.098-.382.25-.721.459-1.016.207-.306.475-.552.803-.738.328-.186.725-.278 1.196-.278.447 0 .836.087 1.163.262.339.175.611.41.82.705.218.295.377.633.475 1.016.109.371.164.754.164 1.147zm3.192-7.392v11.703h1.393V80.785h-1.393zm4.566 7.474a4.3 4.3 0 01.197-1.36c.141-.405.333-.743.573-1.016.24-.274.519-.481.836-.623a2.57 2.57 0 012.048 0c.328.142.612.35.852.623s.426.611.557 1.016c.142.393.213.847.213 1.36s-.071.973-.213 1.377a2.868 2.868 0 01-.557 1c-.24.262-.524.464-.852.606a2.57 2.57 0 01-2.048 0 2.518 2.518 0 01-.836-.606 3.075 3.075 0 01-.573-1 4.456 4.456 0 01-.197-1.377zm-1.475 0c0 .623.088 1.202.262 1.737.175.536.437 1.006.787 1.41.35.393.78.705 1.294.934.514.219 1.104.328 1.77.328.678 0 1.268-.11 1.77-.328.514-.23.944-.54 1.294-.934.35-.404.613-.874.787-1.41a5.579 5.579 0 00.262-1.737c0-.623-.088-1.202-.262-1.737a3.852 3.852 0 00-.787-1.41 3.659 3.659 0 00-1.294-.95c-.502-.23-1.092-.345-1.77-.345-.666 0-1.256.115-1.77.345-.513.229-.944.546-1.294.95-.35.394-.612.863-.787 1.41a5.579 5.579 0 00-.262 1.737zm13.195 5.36a8.003 8.003 0 01-.492 1.049 2.468 2.468 0 01-.524.688 1.554 1.554 0 01-.64.393c-.229.088-.495.132-.802.132-.164 0-.328-.011-.492-.033a2.326 2.326 0 01-.475-.115v-1.278c.12.054.258.098.41.13.164.044.3.066.41.066.284 0 .52-.07.704-.213.197-.13.344-.322.442-.573l.574-1.426-3.36-8.425h1.574l2.474 6.933h.033l2.376-6.933h1.475l-3.687 9.605z" fill="#303030" fill-rule="nonzero"/><rect fill="#C2B7E6" fill-rule="nonzero" x="62.036" y="26.927" width="126.412" height="4.683" rx="2"/><rect fill="#C2B7E6" fill-rule="nonzero" x="266.87" y="26.927" width="125.242" height="4.683" rx="2"/></g></svg>
diff --git a/app/assets/images/learn_gitlab/graduation_hat.svg b/app/assets/images/learn_gitlab/graduation_hat.svg
new file mode 100644
index 00000000000..998d8d9b935
--- /dev/null
+++ b/app/assets/images/learn_gitlab/graduation_hat.svg
@@ -0,0 +1 @@
+<svg width="16" height="17" xmlns="http://www.w3.org/2000/svg"><path fill="#fffff" d="M1.53 7.639l-.476.88.476-.88zm0-1.758L1.054 5l.476.88zm2.257 2.982h1v-.596l-.523-.283-.477.879zm8.424 0l-.476-.88-.524.284v.596h1zm2.257-1.224l.477.88-.477-.88zm0-1.758l-.476.879.476-.88zM8.476 2.632l-.477.88.477-.88zm-.953 0l.476.88-.476-.88zM2.007 6.76l-.953-1.758c-1.396.756-1.396 2.76 0 3.516l.953-1.758zm2.257 1.224L2.007 6.76l-.953 1.758L3.31 9.742l.953-1.758zm.523 1.995V8.863h-2v1.116h2zM8 12.5c-1.949 0-3.212-1.289-3.212-2.52h-2c0 2.656 2.51 4.52 5.212 4.52v-2zm3.212-2.52c0 1.231-1.262 2.52-3.212 2.52v2c2.704 0 5.212-1.864 5.212-4.52h-2zm0-1.117v1.116h2V8.863h-2zm2.78-2.103l-2.256 1.223.953 1.759 2.257-1.224-.953-1.758zm0 0l.954 1.758c1.396-.757 1.396-2.76 0-3.516l-.953 1.758zM8 3.51l5.993 3.249.953-1.758-5.993-3.249L8 3.511zm0 0l.953-1.758a2 2 0 00-1.906 0L8 3.511zM2.007 6.76l5.992-3.25-.953-1.758-5.992 3.249.953 1.758z"/><path fill="#fffff" d="M7.228 7.541c-.187-.112-.277-.427-.201-.704.076-.276.288-.41.475-.297L11 8.644v5.316c0 .298-.163.54-.365.54-.2 0-.364-.242-.364-.54V9.37L7.228 7.54z"/></svg> \ No newline at end of file
diff --git a/app/assets/images/learn_gitlab/rectangle.svg b/app/assets/images/learn_gitlab/rectangle.svg
new file mode 100644
index 00000000000..51667e77158
--- /dev/null
+++ b/app/assets/images/learn_gitlab/rectangle.svg
@@ -0,0 +1 @@
+<svg width="108" height="4" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="108" height="4" rx="2" fill="#C2B7E6"/></svg>
diff --git a/app/assets/javascripts/actioncable_link.js b/app/assets/javascripts/actioncable_link.js
new file mode 100644
index 00000000000..895a34ba157
--- /dev/null
+++ b/app/assets/javascripts/actioncable_link.js
@@ -0,0 +1,40 @@
+import { ApolloLink, Observable } from 'apollo-link';
+import { print } from 'graphql';
+import cable from '~/actioncable_consumer';
+import { uuids } from '~/lib/utils/uuids';
+
+export default class ActionCableLink extends ApolloLink {
+ // eslint-disable-next-line class-methods-use-this
+ request(operation) {
+ return new Observable((observer) => {
+ const subscription = cable.subscriptions.create(
+ {
+ channel: 'GraphqlChannel',
+ query: operation.query ? print(operation.query) : null,
+ variables: operation.variables,
+ operationName: operation.operationName,
+ nonce: uuids()[0],
+ },
+ {
+ received(data) {
+ if (data.errors) {
+ observer.error(data.errors);
+ } else if (data.result) {
+ observer.next(data.result);
+ }
+
+ if (!data.more) {
+ observer.complete();
+ }
+ },
+ },
+ );
+
+ return {
+ unsubscribe() {
+ subscription.unsubscribe();
+ },
+ };
+ });
+ }
+}
diff --git a/app/assets/javascripts/admin/users/components/actions/delete.vue b/app/assets/javascripts/admin/users/components/actions/delete.vue
index 725d3dbf388..6f4f272154a 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete.vue
@@ -14,12 +14,22 @@ export default {
type: Object,
required: true,
},
+ oncallSchedules: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
};
</script>
<template>
- <shared-delete-action modal-type="delete" :username="username" :paths="paths">
+ <shared-delete-action
+ modal-type="delete"
+ :username="username"
+ :paths="paths"
+ :oncall-schedules="oncallSchedules"
+ >
<slot></slot>
</shared-delete-action>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
index 0ae15bfbebb..82b09c04ab2 100644
--- a/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
+++ b/app/assets/javascripts/admin/users/components/actions/delete_with_contributions.vue
@@ -14,12 +14,22 @@ export default {
type: Object,
required: true,
},
+ oncallSchedules: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
};
</script>
<template>
- <shared-delete-action modal-type="delete-with-contributions" :username="username" :paths="paths">
+ <shared-delete-action
+ modal-type="delete-with-contributions"
+ :username="username"
+ :paths="paths"
+ :oncall-schedules="oncallSchedules"
+ >
<slot></slot>
</shared-delete-action>
</template>
diff --git a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
index 9107d9ccdd9..b3b68442e80 100644
--- a/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
+++ b/app/assets/javascripts/admin/users/components/actions/shared/shared_delete_action.vue
@@ -18,6 +18,10 @@ export default {
type: String,
required: true,
},
+ oncallSchedules: {
+ type: Array,
+ required: true,
+ },
},
computed: {
modalAttributes() {
@@ -26,6 +30,7 @@ export default {
'data-delete-user-url': this.paths.delete,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
+ 'data-oncall-schedules': JSON.stringify(this.oncallSchedules),
};
},
},
diff --git a/app/assets/javascripts/admin/users/components/actions/unblock.vue b/app/assets/javascripts/admin/users/components/actions/unblock.vue
index f2b501caf09..d4c0f900c94 100644
--- a/app/assets/javascripts/admin/users/components/actions/unblock.vue
+++ b/app/assets/javascripts/admin/users/components/actions/unblock.vue
@@ -23,9 +23,7 @@ export default {
'data-method': 'put',
'data-modal-attributes': JSON.stringify({
title: sprintf(s__('AdminUsers|Unblock user %{username}?'), { username: this.username }),
- message: s__(
- 'AdminUsers|You can always unblock their account, their data will remain intact.',
- ),
+ message: s__('AdminUsers|You can always block their account again if needed.'),
okVariant: 'confirm',
okTitle: s__('AdminUsers|Unblock'),
}),
diff --git a/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue b/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue
deleted file mode 100644
index 5da38495010..00000000000
--- a/app/assets/javascripts/admin/users/components/usage_ping_disabled.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
-
-export default {
- components: {
- GlEmptyState,
- GlSprintf,
- GlLink,
- },
- inject: {
- svgPath: {
- default: '',
- },
- docsLink: {
- default: '',
- },
- primaryButtonPath: {
- default: '',
- },
- },
-};
-</script>
-<template>
- <gl-empty-state
- class="js-empty-state"
- :title="__('Activate user activity analysis')"
- :svg-path="svgPath"
- :primary-button-text="__('Turn on usage ping')"
- :primary-button-link="primaryButtonPath"
- >
- <template #description>
- <gl-sprintf
- :message="
- __(
- 'Turn on %{strongStart}usage ping%{strongEnd} to activate analysis of user activity, known as %{docLinkStart}Cohorts%{docLinkEnd}.',
- )
- "
- >
- <template #docLink="{ content }">
- <gl-link :href="docsLink" target="_blank">{{ content }}</gl-link>
- </template>
- <template #strong="{ content }"
- ><strong>{{ content }}</strong></template
- >
- </gl-sprintf>
- </template>
- </gl-empty-state>
-</template>
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index e92c97b54a3..b782526e6be 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -70,14 +70,14 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-justify-content-end">
+ <div class="gl-display-flex gl-justify-content-end" :data-testid="`user-actions-${user.id}`">
<gl-button v-if="hasEditAction" data-testid="edit" :href="userPaths.edit">{{
$options.i18n.edit
}}</gl-button>
<gl-dropdown
v-if="hasDropdownActions"
- data-testid="actions"
+ data-testid="dropdown-toggle"
right
class="gl-ml-2"
icon="settings"
@@ -109,6 +109,7 @@ export default {
:key="action"
:paths="userPaths"
:username="user.name"
+ :oncall-schedules="user.oncallSchedules"
:data-testid="`delete-${action}`"
>
{{ $options.i18n[action] }}
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index 8b41a063abc..2fd96e38f8e 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -1,7 +1,10 @@
<script>
-import { GlTable } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
+import { s__, __ } from '~/locale';
import UserDate from '~/vue_shared/components/user_date.vue';
+import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql';
import UserActions from './user_actions.vue';
import UserAvatar from './user_avatar.vue';
@@ -11,6 +14,7 @@ const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
export default {
components: {
+ GlSkeletonLoader,
GlTable,
UserAvatar,
UserActions,
@@ -26,6 +30,45 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ groupCounts: [],
+ };
+ },
+ apollo: {
+ groupCounts: {
+ query: getUsersGroupCountsQuery,
+ variables() {
+ return {
+ usernames: this.users.map((user) => user.username),
+ };
+ },
+ update(data) {
+ const nodes = data?.users?.nodes || [];
+ const parsedIds = convertNodeIdsFromGraphQLIds(nodes);
+
+ return parsedIds.reduce((acc, { id, groupCount }) => {
+ acc[id] = groupCount || 0;
+ return acc;
+ }, {});
+ },
+ error(error) {
+ createFlash({
+ message: this.$options.i18n.groupCountFetchError,
+ captureError: true,
+ error,
+ });
+ },
+ skip() {
+ return !this.users.length;
+ },
+ },
+ },
+ i18n: {
+ groupCountFetchError: s__(
+ 'AdminUsers|Could not load user group counts. Please refresh the page to try again.',
+ ),
+ },
fields: [
{
key: 'name',
@@ -38,6 +81,11 @@ export default {
thClass: thWidthClass(10),
},
{
+ key: 'groupCount',
+ label: __('Groups'),
+ thClass: thWidthClass(10),
+ },
+ {
key: 'createdAt',
label: __('Created on'),
thClass: thWidthClass(15),
@@ -50,7 +98,7 @@ export default {
{
key: 'settings',
label: '',
- thClass: thWidthClass(20),
+ thClass: thWidthClass(10),
},
],
};
@@ -64,6 +112,7 @@ export default {
:empty-text="s__('AdminUsers|No users found')"
show-empty
stacked="md"
+ data-qa-selector="user_row_content"
>
<template #cell(name)="{ item: user }">
<user-avatar :user="user" :admin-user-path="paths.adminUser" />
@@ -77,6 +126,17 @@ export default {
<user-date :date="lastActivityOn" show-never />
</template>
+ <template #cell(groupCount)="{ item: { id } }">
+ <div :data-testid="`user-group-count-${id}`">
+ <gl-skeleton-loader v-if="$apollo.loading" :width="40" :lines="1" />
+ <span v-else>{{ groupCounts[id] }}</span>
+ </div>
+ </template>
+
+ <template #cell(projectsCount)="{ item: { id, projectsCount } }">
+ <div :data-testid="`user-project-count-${id}`">{{ projectsCount }}</div>
+ </template>
+
<template #cell(settings)="{ item: user }">
<user-actions :user="user" :paths="paths" />
</template>
diff --git a/app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql b/app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql
new file mode 100644
index 00000000000..0d8e199f16e
--- /dev/null
+++ b/app/assets/javascripts/admin/users/graphql/queries/get_users_group_counts.query.graphql
@@ -0,0 +1,8 @@
+query getUsersGroupCounts($usernames: [String!]) {
+ users(usernames: $usernames) {
+ nodes {
+ id
+ groupCount
+ }
+ }
+}
diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js
index 0365d054fc9..54c8edc080b 100644
--- a/app/assets/javascripts/admin/users/index.js
+++ b/app/assets/javascripts/admin/users/index.js
@@ -1,7 +1,14 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import AdminUsersApp from './components/app.vue';
-import UsagePingDisabled from './components/usage_ping_disabled.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+});
export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => {
if (!el) {
@@ -12,6 +19,7 @@ export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-a
return new Vue({
el,
+ apolloProvider,
render: (createElement) =>
createElement(AdminUsersApp, {
props: {
@@ -21,23 +29,3 @@ export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-a
}),
});
};
-
-export const initCohortsEmptyState = (el = document.querySelector('#js-cohorts-empty-state')) => {
- if (!el) {
- return false;
- }
-
- const { emptyStateSvgPath, enableUsagePingLink, docsLink } = el.dataset;
-
- return new Vue({
- el,
- provide: {
- svgPath: emptyStateSvgPath,
- primaryButtonPath: enableUsagePingLink,
- docsLink,
- },
- render(h) {
- return h(UsagePingDisabled);
- },
- });
-};
diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js
deleted file mode 100644
index cbaab7df4e9..00000000000
--- a/app/assets/javascripts/admin/users/tabs.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Api from '~/api';
-import { historyPushState } from '~/lib/utils/common_utils';
-import { mergeUrlParams } from '~/lib/utils/url_utility';
-
-const COHORTS_PANE = 'cohorts';
-const COHORTS_PANE_TAB_CLICK_EVENT = 'i_analytics_cohorts';
-
-const tabClickHandler = (e) => {
- const { hash } = e.currentTarget;
-
- let tab = null;
-
- if (hash === `#${COHORTS_PANE}`) {
- tab = COHORTS_PANE;
- Api.trackRedisHllUserEvent(COHORTS_PANE_TAB_CLICK_EVENT);
- }
-
- const newUrl = mergeUrlParams({ tab }, window.location.href);
- historyPushState(newUrl);
-};
-
-const initTabs = () => {
- const tabLinks = document.querySelectorAll('.js-users-tab-item a');
-
- if (tabLinks.length) {
- tabLinks.forEach((tabLink) => {
- tabLink.addEventListener('click', (e) => tabClickHandler(e));
- });
- }
-};
-
-export default initTabs;
diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue
index 79a6bac3ba7..8ea977698e1 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_table.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue
@@ -17,6 +17,7 @@ import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
+import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue';
import {
tdClass,
thClass,
@@ -96,6 +97,7 @@ export default {
severityLabels: SEVERITY_LEVELS,
statusTabs: ALERTS_STATUS_TABS,
components: {
+ AlertsDeprecationWarning,
GlAlert,
GlLoadingIcon,
GlTable,
@@ -273,6 +275,8 @@ export default {
</gl-sprintf>
</gl-alert>
+ <alerts-deprecation-warning />
+
<paginated-table-with-search-and-tabs
:show-error-msg="showErrorMsg"
:i18n="$options.i18n"
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index b23f8a8eba4..e9d19f18ab5 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -23,6 +23,7 @@ export default () => {
assigneeUsernameQuery,
alertManagementEnabled,
userCanEnableAlertManagement,
+ hasManagedPrometheus,
} = domEl.dataset;
const apolloProvider = new VueApollo({
@@ -64,6 +65,7 @@ export default () => {
alertManagementEnabled: parseBoolean(alertManagementEnabled),
trackAlertStatusUpdateOptions: PAGE_CONFIG.OPERATIONS.TRACK_ALERT_STATUS_UPDATE_OPTIONS,
userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement),
+ hasManagedPrometheus: parseBoolean(hasManagedPrometheus),
},
apolloProvider,
render(createElement) {
diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
index 5171588eb64..2733a59f62d 100644
--- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
+++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue
@@ -12,7 +12,11 @@ import Vue from 'vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
import { mappingFields } from '../constants';
-import { getMappingData, transformForSave } from '../utils/mapping_transformations';
+import {
+ getMappingData,
+ transformForSave,
+ setFieldsLabels,
+} from '../utils/mapping_transformations';
export const i18n = {
columns: {
@@ -72,11 +76,14 @@ export default {
},
computed: {
mappingData() {
- return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping);
+ return getMappingData(this.gitlabFields, this.formattedParsedPayload, this.savedMapping);
},
hasFallbackColumn() {
return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks));
},
+ formattedParsedPayload() {
+ return setFieldsLabels(this.parsedPayload);
+ },
},
methods: {
setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) {
@@ -92,14 +99,16 @@ export default {
},
filterFields(searchTerm = '', fields) {
const search = searchTerm.toLowerCase();
- return fields.filter((field) => field.label.toLowerCase().includes(search));
+ return fields.filter((field) =>
+ field.displayLabel.replace('...', '').toLowerCase().includes(search),
+ );
},
isSelected(fieldValue, mapping) {
return isEqual(fieldValue, mapping);
},
selectedValue(mapping) {
return (
- this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label ||
+ this.formattedParsedPayload.find((item) => isEqual(item.path, mapping))?.displayLabel ||
this.$options.i18n.makeSelection
);
},
@@ -167,11 +176,13 @@ export default {
<gl-dropdown-item
v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
:key="`${mappingField.path}__mapping`"
+ v-gl-tooltip
:is-checked="isSelected(gitlabField.mapping, mappingField.path)"
is-check-item
+ :title="mappingField.tooltip"
@click="setMapping(gitlabField.name, mappingField.path)"
>
- {{ mappingField.label }}
+ {{ mappingField.displayLabel }}
</gl-dropdown-item>
<gl-dropdown-item v-if="noResults(gitlabField.searchTerm, gitlabField.mappingFields)">
{{ $options.i18n.noResults }}
@@ -197,13 +208,15 @@ export default {
gitlabField.mappingFields,
)"
:key="`${mappingField.path}__fallback`"
+ v-gl-tooltip
:is-checked="isSelected(gitlabField.fallback, mappingField.path)"
is-check-item
+ :title="mappingField.tooltip"
@click="
setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback)
"
>
- {{ mappingField.label }}
+ {{ mappingField.displayLabel }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="noResults(gitlabField.fallbackSearchTerm, gitlabField.mappingFields)"
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 ef29fc5e8b4..d9e5878b9e3 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -116,7 +116,7 @@ export default {
methods: {
tbodyTrClass(item) {
return {
- [bodyTrClass]: this.integrations.length,
+ [bodyTrClass]: this.integrations?.length,
'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id,
};
},
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 f51c8d7e9f7..3917e4c5fdd 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -14,13 +14,12 @@ import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_cu
import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
-import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql';
+import getHttpIntegrationQuery from '../graphql/queries/get_http_integration.query.graphql';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import service from '../services';
import {
updateStoreAfterIntegrationDelete,
updateStoreAfterIntegrationAdd,
- updateStoreAfterHttpIntegrationAdd,
} from '../utils/cache_updates';
import {
DELETE_INTEGRATION_ERROR,
@@ -68,33 +67,8 @@ export default {
};
},
update(data) {
- const { alertManagementIntegrations: { nodes: list = [] } = {} } = data.project || {};
-
- return {
- list,
- };
- },
- error(err) {
- createFlash({ message: err });
- },
- },
- // TODO: we'll need to update the logic to request specific http integration by its id on edit
- // when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674
- // currently the request for ALL http integrations is made and on specific integration edit we search it in the list
- httpIntegrations: {
- fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
- query: getHttpIntegrationsQuery,
- variables() {
- return {
- projectPath: this.projectPath,
- };
- },
- update(data) {
- const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {};
-
- return {
- list,
- };
+ const { alertManagementIntegrations: { nodes = [] } = {} } = data.project || {};
+ return nodes;
},
error(err) {
createFlash({ message: err });
@@ -107,9 +81,9 @@ export default {
data() {
return {
isUpdating: false,
- integrations: {},
- httpIntegrations: {},
+ integrations: [],
currentIntegration: null,
+ currentHttpIntegration: null,
newIntegration: null,
formVisible: false,
showSuccessfulCreateAlert: false,
@@ -121,7 +95,7 @@ export default {
return this.$apollo.queries.integrations.loading;
},
canAddIntegration() {
- return this.multiIntegrations || this.integrations?.list?.length < 2;
+ return this.multiIntegrations || this.integrations.length < 2;
},
},
methods: {
@@ -142,11 +116,6 @@ export default {
},
update(store, { data }) {
updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath });
- if (isHttp) {
- updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, {
- projectPath,
- });
- }
},
})
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
@@ -253,15 +222,38 @@ export default {
});
},
editIntegration({ id, type }) {
- let currentIntegration = this.integrations.list.find((integration) => integration.id === id);
- if (this.isHttp(type)) {
- const httpIntegrationMappingData = this.httpIntegrations.list.find(
- (integration) => integration.id === id,
- );
- currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
- }
+ const currentIntegration = this.integrations.find((integration) => integration.id === id);
- this.viewIntegration(currentIntegration, tabIndices.viewCredentials);
+ if (this.multiIntegrations && this.isHttp(type)) {
+ this.$apollo.addSmartQuery('currentHttpIntegration', {
+ query: getHttpIntegrationQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ id,
+ };
+ },
+ update(data) {
+ const {
+ project: {
+ alertManagementHttpIntegrations: { nodes = [{}] },
+ },
+ } = data;
+ return nodes[0];
+ },
+ result() {
+ this.viewIntegration(
+ { ...currentIntegration, ...this.currentHttpIntegration },
+ tabIndices.viewCredentials,
+ );
+ },
+ error() {
+ createFlash({ message: DEFAULT_ERROR });
+ },
+ });
+ } else {
+ this.viewIntegration(currentIntegration, tabIndices.viewCredentials);
+ }
},
viewIntegration(integration, tabIndex) {
this.$apollo
@@ -368,7 +360,7 @@ export default {
</gl-alert>
<integrations-list
- :integrations="integrations.list"
+ :integrations="integrations"
:loading="loading"
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
index 5bd63820629..e9230812db2 100644
--- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql
@@ -6,7 +6,6 @@ mutation updateCurrentPrometheusIntegration(
$type: String
$url: String
$apiUrl: String
- $samplePayload: String
) {
updateCurrentIntegration(
id: $id
@@ -16,6 +15,5 @@ mutation updateCurrentPrometheusIntegration(
type: $type
url: $url
apiUrl: $apiUrl
- samplePayload: $samplePayload
) @client
}
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql
index 833a2d6c12f..d20a8b8334b 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integration.query.graphql
@@ -1,9 +1,8 @@
#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql"
-# TODO: this query need to accept http integration id to request a sepcific integration
-query getHttpIntegrations($projectPath: ID!) {
+query getHttpIntegration($projectPath: ID!, $id: ID) {
project(fullPath: $projectPath) {
- alertManagementHttpIntegrations {
+ alertManagementHttpIntegrations(id: $id) {
nodes {
...HttpIntegrationPayloadData
}
diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
index 716c709a931..a50b6515afa 100644
--- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js
+++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js
@@ -58,31 +58,6 @@ const addIntegrationToStore = (
});
};
-const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => {
- const integration = httpIntegrationCreate?.integration;
- if (!integration) {
- return;
- }
-
- const sourceData = store.readQuery({
- query,
- variables,
- });
-
- const data = produce(sourceData, (draftData) => {
- draftData.project.alertManagementHttpIntegrations.nodes = [
- integration,
- ...draftData.project.alertManagementHttpIntegrations.nodes,
- ];
- });
-
- store.writeQuery({
- query,
- variables,
- data,
- });
-};
-
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
@@ -105,11 +80,3 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) =>
addIntegrationToStore(store, query, data, variables);
}
};
-
-export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => {
- if (hasErrors(data)) {
- onError(data, ADD_INTEGRATION_ERROR);
- } else {
- addHttpIntegrationToStore(store, query, data, variables);
- }
-};
diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
index 5c4b9bcd505..ed126dfafd6 100644
--- a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
+++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js
@@ -1,4 +1,6 @@
import { isEqual } from 'lodash';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
/**
* Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any)
* creates an object in a form convenient to build UI && interact with it
@@ -32,6 +34,26 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
});
};
+export const setFieldsLabels = (fields) => {
+ return fields.map((field) => {
+ const { label } = field;
+ let displayLabel;
+ let tooltip;
+ const labels = label.split('/');
+ if (labels.length > 1) {
+ tooltip = labels.join('.');
+ displayLabel = `...${capitalizeFirstCharacter(labels.pop())}`;
+ } else {
+ displayLabel = capitalizeFirstCharacter(label);
+ }
+
+ return {
+ ...field,
+ displayLabel,
+ tooltip,
+ };
+ });
+};
/**
* Based on mapping data configured by the user creates an object in a format suitable for save on BE
* @param {Object} mappingData - structure describing mapping between GitLab fields and parsed payload fields
diff --git a/app/assets/javascripts/analytics/devops_report/components/devops_score.vue b/app/assets/javascripts/analytics/devops_report/components/devops_score.vue
new file mode 100644
index 00000000000..1a3289ffb75
--- /dev/null
+++ b/app/assets/javascripts/analytics/devops_report/components/devops_score.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlBadge, GlTable, GlLink, GlEmptyState } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { sprintf, s__ } from '~/locale';
+
+const defaultHeaderAttrs = {
+ thClass: 'gl-bg-white!',
+ thAttr: { 'data-testid': 'header' },
+};
+
+export default {
+ components: {
+ GlBadge,
+ GlTable,
+ GlSingleStat,
+ GlLink,
+ GlEmptyState,
+ },
+ inject: {
+ devopsScoreMetrics: {
+ default: null,
+ },
+ devopsReportDocsPath: {
+ default: '',
+ },
+ noDataImagePath: {
+ default: '',
+ },
+ },
+ computed: {
+ titleHelperText() {
+ return sprintf(
+ s__(
+ 'DevopsReport|DevOps score metrics are based on usage over the last 30 days. Last updated: %{timestamp}.',
+ ),
+ { timestamp: this.devopsScoreMetrics.createdAt },
+ );
+ },
+ isEmpty() {
+ return this.devopsScoreMetrics.averageScore === undefined;
+ },
+ },
+ tableHeaderFields: [
+ {
+ key: 'title',
+ label: '',
+ ...defaultHeaderAttrs,
+ },
+ {
+ key: 'usage',
+ label: s__('DevopsReport|Your usage'),
+ ...defaultHeaderAttrs,
+ },
+ {
+ key: 'leadInstance',
+ label: s__('DevopsReport|Leader usage'),
+ ...defaultHeaderAttrs,
+ },
+ {
+ key: 'score',
+ label: s__('DevopsReport|Score'),
+ ...defaultHeaderAttrs,
+ },
+ ],
+};
+</script>
+<template>
+ <gl-empty-state
+ v-if="isEmpty"
+ :title="__('Data is still calculating...')"
+ :svg-path="noDataImagePath"
+ >
+ <template #description>
+ <p class="gl-mb-0">{{ __('It may be several days before you see feature usage data.') }}</p>
+ <gl-link :href="devopsReportDocsPath">{{
+ __('See example DevOps Score page in our documentation.')
+ }}</gl-link>
+ </template>
+ </gl-empty-state>
+ <div v-else data-testid="devops-score-app">
+ <div class="gl-text-gray-400 gl-my-4" data-testid="devops-score-note-text">
+ {{ titleHelperText }}
+ </div>
+ <gl-single-stat
+ unit="%"
+ size="sm"
+ :title="s__('DevopsReport|Your score')"
+ :should-animate="true"
+ :value="devopsScoreMetrics.averageScore.value"
+ :meta-icon="devopsScoreMetrics.averageScore.scoreLevel.icon"
+ :meta-text="devopsScoreMetrics.averageScore.scoreLevel.label"
+ :variant="devopsScoreMetrics.averageScore.scoreLevel.variant"
+ />
+ <gl-table
+ :fields="$options.tableHeaderFields"
+ :items="devopsScoreMetrics.cards"
+ thead-class="gl-border-t-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
+ stacked="sm"
+ >
+ <template #cell(usage)="{ item }">
+ <div data-testid="usageCol">
+ <span>{{ item.usage }}</span>
+ <gl-badge :variant="item.scoreLevel.variant" size="sm" class="gl-ml-1">{{
+ item.scoreLevel.label
+ }}</gl-badge>
+ </div>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/devops_report/devops_score.js b/app/assets/javascripts/analytics/devops_report/devops_score.js
new file mode 100644
index 00000000000..18f7cf0c3ab
--- /dev/null
+++ b/app/assets/javascripts/analytics/devops_report/devops_score.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import DevopsScore from './components/devops_score.vue';
+
+export default () => {
+ const el = document.getElementById('js-devops-score');
+
+ if (!el) return false;
+
+ const { devopsScoreMetrics, devopsReportDocsPath, noDataImagePath } = el.dataset;
+
+ return new Vue({
+ el,
+ provide: {
+ devopsScoreMetrics: JSON.parse(devopsScoreMetrics),
+ devopsReportDocsPath,
+ noDataImagePath,
+ },
+ render(h) {
+ return h(DevopsScore);
+ },
+ });
+};
diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js
index 0cb8d9be0e4..0131407e723 100644
--- a/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js
+++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js
@@ -6,7 +6,7 @@ export default () => {
// eslint-disable-next-line no-new
new UserCallout();
- const emptyStateContainer = document.getElementById('js-devops-empty-state');
+ const emptyStateContainer = document.getElementById('js-devops-usage-ping-disabled');
if (!emptyStateContainer) return false;
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
index 55642aa64db..f89600fbed3 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
+import { GlSprintf, GlButton, GlAlert, GlCard } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
import { __ } from '~/locale';
import Tracking from '~/tracking';
@@ -34,7 +34,7 @@ export default {
recoveryCodeDownloadFilename: RECOVERY_CODE_DOWNLOAD_FILENAME,
i18n,
mousetrap: null,
- components: { GlSprintf, GlButton, GlAlert, ClipboardButton },
+ components: { GlSprintf, GlButton, GlAlert, ClipboardButton, GlCard },
mixins: [Tracking.mixin()],
props: {
codes: {
@@ -116,8 +116,8 @@ export default {
</gl-sprintf>
</p>
- <div
- class="codes-to-print gl-my-5 gl-p-5 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base"
+ <gl-card
+ class="codes-to-print gl-my-5"
data-testid="recovery-codes"
data-qa-selector="codes_content"
>
@@ -126,7 +126,7 @@ export default {
<span class="gl-font-monospace" data-qa-selector="code_content">{{ code }}</span>
</li>
</ul>
- </div>
+ </gl-card>
<div class="gl-my-n2 gl-mx-n2 gl-display-flex gl-flex-wrap">
<div class="gl-p-2">
<clipboard-button
@@ -140,6 +140,7 @@ export default {
</div>
<div class="gl-p-2">
<gl-button
+ is-unsafe-link
:href="codeDownloadUrl"
:title="$options.i18n.downloadButton"
icon="download"
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 3a2f2078e44..43f44370af8 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -12,7 +12,6 @@ import axios from './lib/utils/axios_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import { __ } from './locale';
-window.axios = axios;
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
diff --git a/app/assets/javascripts/badges/components/badge_list.vue b/app/assets/javascripts/badges/components/badge_list.vue
index f16a547e441..86c7b4c7a6e 100644
--- a/app/assets/javascripts/badges/components/badge_list.vue
+++ b/app/assets/javascripts/badges/components/badge_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { mapState } from 'vuex';
import { GROUP_BADGE } from '../constants';
import BadgeListRow from './badge_list_row.vue';
@@ -9,6 +9,7 @@ export default {
components: {
BadgeListRow,
GlLoadingIcon,
+ GlBadge,
},
computed: {
...mapState(['badges', 'isLoading', 'kind']),
@@ -26,7 +27,7 @@ export default {
<div class="card">
<div class="card-header">
{{ s__('Badges|Your badges') }}
- <span v-show="!isLoading" class="badge badge-pill">{{ badges.length }}</span>
+ <gl-badge v-show="!isLoading" size="sm">{{ badges.length }}</gl-badge>
</div>
<gl-loading-icon v-show="isLoading" size="lg" class="card-body" />
<div v-if="hasNoBadges" class="card-body">
diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue
index 5e110b101eb..61718b766d8 100644
--- a/app/assets/javascripts/batch_comments/components/drafts_count.vue
+++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue
@@ -12,7 +12,7 @@ export default {
};
</script>
<template>
- <gl-badge size="sm" variant="success">
+ <gl-badge size="sm" variant="info" class="gl-ml-2">
{{ draftsCount }}
<span class="sr-only"> {{ n__('draft', 'drafts', draftsCount) }} </span>
</gl-badge>
diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
index fb643d441ec..91b3b6a685c 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -1,6 +1,6 @@
<script>
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import PreviewItem from './preview_item.vue';
export default {
@@ -11,13 +11,22 @@ export default {
PreviewItem,
},
computed: {
+ ...mapState('diffs', ['viewDiffsFileByFile']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
},
methods: {
+ ...mapActions('diffs', ['toggleActiveFileByHash']),
...mapActions('batchComments', ['scrollToDraft']),
isLast(index) {
return index === this.sortedDrafts.length - 1;
},
+ async onClickDraft(draft) {
+ if (this.viewDiffsFileByFile && draft.file_hash) {
+ await this.toggleActiveFileByHash(draft.file_hash);
+ }
+
+ await this.scrollToDraft(draft);
+ },
},
};
</script>
@@ -26,7 +35,7 @@ export default {
<gl-dropdown
:header-text="n__('%d pending comment', '%d pending comments', draftsCount)"
dropup
- toggle-class="qa-review-preview-toggle"
+ data-qa-selector="review_preview_dropdown"
>
<template #button-content>
{{ __('Pending comments') }}
@@ -35,7 +44,8 @@ export default {
<gl-dropdown-item
v-for="(draft, index) in sortedDrafts"
:key="draft.id"
- @click="scrollToDraft(draft)"
+ data-testid="preview-item"
+ @click="onClickDraft(draft)"
>
<preview-item :draft="draft" :is-last="isLast(index)" />
</gl-dropdown-item>
diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue
index 2a7be605003..d4fc4ad744a 100644
--- a/app/assets/javascripts/batch_comments/components/publish_button.vue
+++ b/app/assets/javascripts/batch_comments/components/publish_button.vue
@@ -22,7 +22,7 @@ export default {
variant: {
type: String,
required: false,
- default: 'success',
+ default: 'confirm',
},
},
computed: {
diff --git a/app/assets/javascripts/behaviors/date_picker.js b/app/assets/javascripts/behaviors/date_picker.js
new file mode 100644
index 00000000000..efd89ec4330
--- /dev/null
+++ b/app/assets/javascripts/behaviors/date_picker.js
@@ -0,0 +1,33 @@
+import $ from 'jquery';
+import Pikaday from 'pikaday';
+import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility';
+
+export default function initDatePickers() {
+ $('.datepicker').each(function initPikaday() {
+ const $datePicker = $(this);
+ const datePickerVal = $datePicker.val();
+
+ const calendar = new Pikaday({
+ field: $datePicker.get(0),
+ theme: 'gitlab-theme animate-picker',
+ format: 'yyyy-mm-dd',
+ container: $datePicker.parent().get(0),
+ parse: (dateString) => parsePikadayDate(dateString),
+ toString: (date) => pikadayToString(date),
+ onSelect(dateText) {
+ $datePicker.val(calendar.toString(dateText));
+ },
+ firstDay: gon.first_day_of_week,
+ });
+
+ calendar.setDate(parsePikadayDate(datePickerVal));
+
+ $datePicker.data('pikaday', calendar);
+ });
+
+ $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => {
+ e.preventDefault();
+ const calendar = $(e.target).siblings('.datepicker').data('pikaday');
+ calendar.setDate(null);
+ });
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index 8238f5523f3..12f47255bdf 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -114,6 +114,12 @@ class SafeMathRenderer {
throwOnError: true,
maxSize: 20,
maxExpand: 20,
+ trust: (context) =>
+ // this config option restores the KaTeX pre-v0.11.0
+ // behavior of allowing certain commands and protocols
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ ['\\url', '\\href'].includes(context.command) &&
+ ['http', 'https', 'mailto', '_relative'].includes(context.protocol),
});
} catch (e) {
// Don't show a flash for now because it would override an existing flash message
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 5b5148a850b..f5b2d266c18 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { once } from 'lodash';
+import { once, countBy } from 'lodash';
import { deprecatedCreateFlash as flash } from '~/flash';
import { darkModeEnabled } from '~/lib/utils/color_utils';
import { __, sprintf } from '~/locale';
@@ -22,6 +22,8 @@ import { __, sprintf } from '~/locale';
const MAX_CHAR_LIMIT = 2000;
// Max # of mermaid blocks that can be rendered in a page.
const MAX_MERMAID_BLOCK_LIMIT = 50;
+// Max # of `&` allowed in Chaining of links syntax
+const MAX_CHAINING_OF_LINKS_LIMIT = 30;
// Keep a map of mermaid blocks we've already rendered.
const elsProcessingMap = new WeakMap();
let renderedMermaidBlocks = 0;
@@ -64,6 +66,18 @@ function importMermaidModule() {
});
}
+function shouldLazyLoadMermaidBlock(source) {
+ /**
+ * If source contains `&`, which means that it might
+ * contain Chaining of links a new syntax in Mermaid.
+ */
+ if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) {
+ return true;
+ }
+
+ return false;
+}
+
function fixElementSource(el) {
// Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly.
const source = el.textContent.replace(/<br\s*\/>/g, '<br>');
@@ -128,7 +142,8 @@ function renderMermaids($els) {
if (
(source && source.length > MAX_CHAR_LIMIT) ||
renderedChars > MAX_CHAR_LIMIT ||
- renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT
+ renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT ||
+ shouldLazyLoadMermaidBlock(source)
) {
const html = `
<div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert">
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 6abbd7f3243..c63dba05f10 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -375,7 +375,7 @@ export const MR_PREVIOUS_FILE_IN_DIFF = {
export const MR_GO_TO_FILE = {
id: 'mergeRequests.goToFile',
description: __('Go to file'),
- defaultKeys: ['t', 'mod+p'],
+ defaultKeys: ['mod+p', 't'],
customizable: false,
};
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcut.vue b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
new file mode 100644
index 00000000000..e5992779a99
--- /dev/null
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcut.vue
@@ -0,0 +1,80 @@
+<script>
+import { __, s__ } from '~/locale';
+
+// Map some keys to their proper representation depending on the system
+// See also: https://craig.is/killing/mice#keys
+const getKeyMap = () => {
+ const keyMap = {
+ up: '↑',
+ down: '↓',
+ left: '←',
+ right: '→',
+ ctrl: s__('KeyboardKey|Ctrl'),
+ shift: s__('KeyboardKey|Shift'),
+ enter: s__('KeyboardKey|Enter'),
+ esc: s__('KeyboardKey|Esc'),
+ command: '⌘',
+ option: window.gl?.client?.isMac ? '⌥' : s__('KeyboardKey|Alt'),
+ };
+
+ // Meta and alt are aliases
+ keyMap.meta = keyMap.command;
+ keyMap.alt = keyMap.option;
+
+ // Mod is Command on Mac, and Ctrl on Windows/Linux
+ keyMap.mod = window.gl?.client?.isMac ? keyMap.command : keyMap.ctrl;
+
+ return keyMap;
+};
+
+export default {
+ functional: true,
+ props: {
+ shortcuts: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ render(createElement, context) {
+ const keyMap = getKeyMap();
+
+ const { staticClass } = context.data;
+
+ const shortcuts = context.props.shortcuts.reduce((acc, shortcut, i) => {
+ if (
+ !window.gl?.client?.isMac &&
+ (shortcut.includes('command') || shortcut.includes('meta'))
+ ) {
+ return acc;
+ }
+ const keys = shortcut.split(/([ +])/);
+
+ if (i !== 0 && acc.length) {
+ acc.push(` ${__('or')} `);
+ // If there are multiple alternative shortcuts,
+ // we keep them on the same line if they are single-key, e.g. `]` or `j`
+ // but if they consist of multiple keys, we insert a line break, e.g.:
+ // `shift` + `]` <br> or `shift` + `j`
+ if (keys.length > 1) {
+ acc.push(createElement('br'));
+ }
+ }
+
+ keys.forEach((key) => {
+ if (key === '+') {
+ acc.push(' + ');
+ } else if (key === ' ') {
+ acc.push(` ${__('then')} `);
+ } else {
+ acc.push(createElement('kbd', {}, [keyMap[key] ?? key]));
+ }
+ });
+
+ return acc;
+ }, []);
+
+ return createElement('div', { staticClass }, shortcuts);
+ },
+};
+</script>
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
index 49216cc4aa0..cb7c6f9f6bc 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue
@@ -1,525 +1,99 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlIcon, GlModal } from '@gitlab/ui';
+import { GlModal, GlSearchBoxByType } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { keybindingGroups } from './keybindings';
+import Shortcut from './shortcut.vue';
import ShortcutsToggle from './shortcuts_toggle.vue';
export default {
components: {
- GlIcon,
GlModal,
+ GlSearchBoxByType,
ShortcutsToggle,
+ Shortcut,
+ },
+ data() {
+ return {
+ searchTerm: '',
+ };
},
computed: {
- ctrlCharacter() {
- return window.gl.client.isMac ? '⌘' : 'ctrl';
- },
- onDotCom() {
- return window.gon.dot_com;
+ filteredKeybindings() {
+ if (!this.searchTerm) {
+ return keybindingGroups;
+ }
+
+ const search = this.searchTerm.toLocaleLowerCase();
+
+ const mapped = keybindingGroups.map((group) => {
+ if (group.name.toLocaleLowerCase().includes(search)) {
+ return group;
+ }
+ return {
+ ...group,
+ keybindings: group.keybindings.filter((binding) =>
+ binding.description.toLocaleLowerCase().includes(search),
+ ),
+ };
+ });
+
+ return mapped.filter((group) => group.keybindings.length);
},
},
+ i18n: {
+ title: __(`Keyboard shortcuts`),
+ search: s__(`KeyboardShortcuts|Search keyboard shortcuts`),
+ noMatch: s__(`KeyboardShortcuts|No shortcuts matched your search`),
+ },
};
</script>
<template>
<gl-modal
modal-id="keyboard-shortcut-modal"
size="lg"
+ :title="$options.i18n.title"
data-testid="modal-shortcuts"
+ body-class="shortcut-help-body gl-p-0!"
:visible="true"
:hide-footer="true"
@hidden="$emit('hidden')"
>
- <template #modal-title>
- <shortcuts-toggle />
- </template>
- <div class="row">
- <div class="col-lg-4">
- <table class="shortcut-mappings text-2">
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Global Shortcuts') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>?</kbd>
- </td>
- <td>{{ __('Toggle this dialog') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift p</kbd>
- </td>
- <td>{{ __('Go to your projects') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift g</kbd>
- </td>
- <td>{{ __('Go to your groups') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift a</kbd>
- </td>
- <td>{{ __('Go to the activity feed') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift l</kbd>
- </td>
- <td>{{ __('Go to the milestone list') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift s</kbd>
- </td>
- <td>{{ __('Go to your snippets') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>s</kbd>
- /
- <kbd>/</kbd>
- </td>
- <td>{{ __('Start search') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift i</kbd>
- </td>
- <td>{{ __('Go to your issues') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift m</kbd>
- </td>
- <td>{{ __('Go to your merge requests') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>shift t</kbd>
- </td>
- <td>{{ __('Go to your To-Do list') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>p</kbd>
- <kbd>b</kbd>
- </td>
- <td>{{ __('Toggle the Performance Bar') }}</td>
- </tr>
- <tr v-if="onDotCom">
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>x</kbd>
- </td>
- <td>{{ __('Toggle GitLab Next') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Editing') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>{{ ctrlCharacter }} shift p</kbd>
- </td>
- <td>{{ __('Toggle Markdown preview') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-up" />
- </kbd>
- </td>
- <td>
- {{ __('Edit your most recent comment in a thread (from an empty textarea)') }}
- </td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Wiki') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>e</kbd>
- </td>
- <td>{{ __('Edit wiki page') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Repository Graph') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-left" />
- </kbd>
- /
- <kbd>h</kbd>
- </td>
- <td>{{ __('Scroll left') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-right" />
- </kbd>
- /
- <kbd>l</kbd>
- </td>
- <td>{{ __('Scroll right') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-up" />
- </kbd>
- /
- <kbd>k</kbd>
- </td>
- <td>{{ __('Scroll up') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-down" />
- </kbd>
- /
- <kbd>j</kbd>
- </td>
- <td>{{ __('Scroll down') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- shift
- <gl-icon name="arrow-up" />
- / k
- </kbd>
- </td>
- <td>{{ __('Scroll to top') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- shift
- <gl-icon name="arrow-down" />
- / j
- </kbd>
- </td>
- <td>{{ __('Scroll to bottom') }}</td>
- </tr>
- </tbody>
- </table>
- </div>
- <div class="col-lg-4">
- <table class="shortcut-mappings text-2">
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Project') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>p</kbd>
- </td>
- <td>{{ __("Go to the project's overview page") }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>v</kbd>
- </td>
- <td>{{ __("Go to the project's activity feed") }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>r</kbd>
- </td>
- <td>{{ __('Go to releases') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>f</kbd>
- </td>
- <td>{{ __('Go to files') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>t</kbd>
- </td>
- <td>{{ __('Go to find file') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>c</kbd>
- </td>
- <td>{{ __('Go to commits') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>n</kbd>
- </td>
- <td>{{ __('Go to repository graph') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>d</kbd>
- </td>
- <td>{{ __('Go to repository charts') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>i</kbd>
- </td>
- <td>{{ __('Go to issues') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>i</kbd>
- </td>
- <td>{{ __('New issue') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>b</kbd>
- </td>
- <td>{{ __('Go to issue boards') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>m</kbd>
- </td>
- <td>{{ __('Go to merge requests') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>j</kbd>
- </td>
- <td>{{ __('Go to jobs') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>l</kbd>
- </td>
- <td>{{ __('Go to metrics') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>e</kbd>
- </td>
- <td>{{ __('Go to environments') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>k</kbd>
- </td>
- <td>{{ __('Go to kubernetes') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>s</kbd>
- </td>
- <td>{{ __('Go to snippets') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>g</kbd>
- <kbd>w</kbd>
- </td>
- <td>{{ __('Go to wiki') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Project Files') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-up" />
- </kbd>
- </td>
- <td>{{ __('Move selection up') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>
- <gl-icon name="arrow-down" />
- </kbd>
- </td>
- <td>{{ __('Move selection down') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>enter</kbd>
- </td>
- <td>{{ __('Open Selection') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>esc</kbd>
- </td>
- <td>{{ __('Go back (while searching for files)') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>y</kbd>
- </td>
- <td>{{ __('Go to file permalink (while viewing a file)') }}</td>
- </tr>
- </tbody>
- </table>
- </div>
- <div class="col-lg-4">
- <table class="shortcut-mappings text-2">
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Epics, issues, and merge requests') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>r</kbd>
- </td>
- <td>{{ __('Comment/Reply (quoting selected text)') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>e</kbd>
- </td>
- <td>{{ __('Edit description') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>l</kbd>
- </td>
- <td>{{ __('Change label') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Issues and merge requests') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>a</kbd>
- </td>
- <td>{{ __('Change assignee') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>m</kbd>
- </td>
- <td>{{ __('Change milestone') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Merge requests') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>]</kbd>
- /
- <kbd>j</kbd>
- </td>
- <td>{{ __('Next file in diff') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>[</kbd>
- /
- <kbd>k</kbd>
- </td>
- <td>{{ __('Previous file in diff') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>{{ ctrlCharacter }} p</kbd>
- </td>
- <td>{{ __('Go to file') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>n</kbd>
- </td>
- <td>{{ __('Next unresolved discussion') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>p</kbd>
- </td>
- <td>{{ __('Previous unresolved discussion') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>b</kbd>
- </td>
- <td>{{ __('Copy source branch name') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Merge request commits') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>c</kbd>
- </td>
- <td>{{ __('Next commit') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>x</kbd>
- </td>
- <td>{{ __('Previous commit') }}</td>
- </tr>
- </tbody>
- <tbody>
- <tr>
- <th></th>
- <th>{{ __('Web IDE') }}</th>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>{{ ctrlCharacter }} p</kbd>
- </td>
- <td>{{ __('Go to file') }}</td>
- </tr>
- <tr>
- <td class="shortcut">
- <kbd>{{ ctrlCharacter }} enter</kbd>
- </td>
- <td>{{ __('Commit (when editing commit message)') }}</td>
- </tr>
- </tbody>
- </table>
- </div>
+ <div
+ class="gl-sticky gl-top-0 gl-py-5 gl-px-5 gl-display-flex gl-align-items-center gl-bg-white"
+ >
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :aria-label="$options.i18n.search"
+ class="gl-w-half gl-mr-3"
+ />
+ <shortcuts-toggle class="gl-w-half gl-ml-3" />
+ </div>
+ <div v-if="filteredKeybindings.length === 0" class="gl-px-5">
+ {{ $options.i18n.noMatch }}
+ </div>
+ <div v-else class="shortcut-help-container gl-mt-8 gl-px-5 gl-pb-5">
+ <section
+ v-for="group in filteredKeybindings"
+ :key="group.id"
+ class="shortcut-help-mapping gl-mb-4"
+ >
+ <strong class="shortcut-help-mapping-title gl-w-half gl-display-inline-block">
+ {{ group.name }}
+ </strong>
+ <div
+ v-for="keybinding in group.keybindings"
+ :key="keybinding.id"
+ class="gl-display-flex gl-align-items-center"
+ >
+ <shortcut
+ class="gl-w-40p gl-flex-shrink-0 gl-text-right gl-pr-4"
+ :shortcuts="keybinding.defaultKeys"
+ />
+ <div class="gl-w-half gl-flex-shrink-0 gl-flex-grow-1">
+ {{ keybinding.description }}
+ </div>
+ </div>
+ </section>
</div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
index 6cbe443062a..8f1518a1c9c 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
@@ -6,7 +6,7 @@ import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './sho
export default {
i18n: {
- toggleLabel: __('Keyboard shortcuts'),
+ toggleLabel: __('Toggle shortcuts'),
},
components: {
GlToggle,
@@ -31,14 +31,12 @@ export default {
</script>
<template>
- <div v-if="localStorageUsable" class="d-inline-flex align-items-center js-toggle-shortcuts">
+ <div v-if="localStorageUsable" class="js-toggle-shortcuts">
<gl-toggle
v-model="shortcutsEnabled"
- aria-describedby="shortcutsToggle"
:label="$options.i18n.toggleLabel"
label-position="left"
@change="onChange"
/>
- <div id="shortcutsToggle" class="sr-only">{{ __('Enable or disable keyboard shortcuts') }}</div>
</div>
</template>
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index f5f06436bcc..60729c11002 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -6,6 +6,7 @@ import BlobContentError from './blob_content_error.vue';
import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants';
export default {
+ name: 'BlobContent',
components: {
GlLoadingIcon,
BlobContentError,
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 77910850908..59ab84bf208 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -7,7 +7,6 @@ import toast from '~/vue_shared/plugins/global_toast';
import { deprecatedCreateFlash as Flash } from '../flash';
-import BlobCiSyntaxYamlSelector from './template_selectors/ci_syntax_yaml_selector';
import BlobCiYamlSelector from './template_selectors/ci_yaml_selector';
import DockerfileSelector from './template_selectors/dockerfile_selector';
import GitignoreSelector from './template_selectors/gitignore_selector';
@@ -34,7 +33,6 @@ export default class FileTemplateMediator {
this.templateSelectors = [
GitignoreSelector,
BlobCiYamlSelector,
- BlobCiSyntaxYamlSelector,
MetricsDashboardSelector,
DockerfileSelector,
LicenseSelector,
diff --git a/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js b/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js
deleted file mode 100644
index c30ff4f1290..00000000000
--- a/app/assets/javascripts/blob/template_selectors/ci_syntax_yaml_selector.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import FileTemplateSelector from '../file_template_selector';
-
-export default class BlobCiSyntaxYamlSelector extends FileTemplateSelector {
- constructor({ mediator }) {
- super(mediator);
- this.config = {
- key: 'gitlab-ci-yaml',
- name: '.gitlab-ci.yml',
- pattern: /(.gitlab-ci.yml)/,
- type: 'gitlab_ci_syntax_ymls',
- dropdown: '.js-gitlab-ci-syntax-yml-selector',
- wrapper: '.js-gitlab-ci-syntax-yml-selector-wrap',
- };
- }
-
- initDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- data: this.$dropdown.data('data'),
- filterable: true,
- selectable: true,
- search: {
- fields: ['name'],
- },
- clicked: (options) => this.reportSelectionName(options),
- text: (item) => item.name,
- });
- }
-}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index 4741152afce..22c6b31143f 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -1,6 +1,12 @@
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { __ } from '~/locale';
+import {
+ REPO_BLOB_LOAD_VIEWER_START,
+ REPO_BLOB_LOAD_VIEWER_FINISH,
+ REPO_BLOB_LOAD_VIEWER,
+} from '~/performance/constants';
+import { performanceMarkAndMeasure } from '~/performance/utils';
import { fixTitle } from '~/tooltips';
import { deprecatedCreateFlash as Flash } from '../../flash';
import axios from '../../lib/utils/axios_utils';
@@ -130,6 +136,9 @@ export default class BlobViewer {
}
switchToViewer(name) {
+ performanceMarkAndMeasure({
+ mark: REPO_BLOB_LOAD_VIEWER_START,
+ });
const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`);
if (this.activeViewer === newViewer) return;
@@ -163,6 +172,15 @@ export default class BlobViewer {
handleLocationHash();
this.toggleCopyButtonState();
+ performanceMarkAndMeasure({
+ mark: REPO_BLOB_LOAD_VIEWER_FINISH,
+ measures: [
+ {
+ name: REPO_BLOB_LOAD_VIEWER,
+ start: REPO_BLOB_LOAD_VIEWER_START,
+ },
+ ],
+ });
})
.catch(() => new Flash(__('Error loading viewer')));
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 173c82ef9b0..d26af07d54f 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import initPopover from '~/blob/suggest_gitlab_ci_yml';
+import initCodeQualityWalkthrough from '~/code_quality_walkthrough';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils';
import Tracking from '~/tracking';
@@ -38,6 +39,13 @@ const initPopovers = () => {
}
};
+const initCodeQualityWalkthroughStep = () => {
+ const codeQualityWalkthroughEl = document.querySelector('.js-code-quality-walkthrough');
+ if (codeQualityWalkthroughEl) {
+ initCodeQualityWalkthrough(codeQualityWalkthroughEl);
+ }
+};
+
export const initUploadForm = () => {
const uploadBlobForm = $('.js-upload-blob-form');
if (uploadBlobForm.length) {
@@ -74,6 +82,7 @@ export default () => {
isMarkdown,
});
initPopovers();
+ initCodeQualityWalkthroughStep();
})
.catch((e) => createFlash(e));
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index a8b870f9b8e..f53d41dd0f4 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,6 +1,6 @@
import { sortBy, cloneDeep } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { ListType, NOT_FILTER } from './constants';
+import { ListType, NOT_FILTER, AssigneeIdParamValues } from './constants';
export function getMilestone() {
return null;
@@ -186,6 +186,35 @@ export function transformNotFilters(filters) {
}, {});
}
+export function getSupportedParams(filters, supportedFilters) {
+ return supportedFilters.reduce((acc, f) => {
+ /**
+ * TODO the API endpoint for the classic boards
+ * accepts assignee wildcard value as 'assigneeId' param -
+ * while the GraphQL query accepts the value in 'assigneWildcardId' field.
+ * Once we deprecate the classics boards,
+ * we should change the filtered search bar to use 'asssigneeWildcardId' as a token name.
+ */
+ if (f === 'assigneeId' && filters[f]) {
+ return AssigneeIdParamValues.includes(filters[f])
+ ? {
+ ...acc,
+ assigneeWildcardId: filters[f].toUpperCase(),
+ }
+ : acc;
+ }
+
+ if (filters[f]) {
+ return {
+ ...acc,
+ [f]: filters[f],
+ };
+ }
+
+ return acc;
+ }, {});
+}
+
// EE-specific feature. Find the implementation in the `ee/`-folder
export function transformBoardConfig() {
return '';
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index aacea0b970c..2821b799cef 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import BoardCardInner from './board_card_inner.vue';
export default {
@@ -31,7 +31,6 @@ export default {
},
computed: {
...mapState(['selectedBoardItems', 'activeId']),
- ...mapGetters(['isSwimlanesOn']),
isActive() {
return this.item.id === this.activeId;
},
@@ -46,7 +45,7 @@ export default {
...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']),
toggleIssue(e) {
// Don't do anything if this happened on a no trigger element
- if (e.target.classList.contains('js-no-trigger')) return;
+ if (e.target.closest('.js-no-trigger')) return;
const isMultiSelect = e.ctrlKey || e.metaKey;
if (isMultiSelect) {
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 9ff2cdd76d0..0cb2e64042e 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -190,6 +190,7 @@ export default {
<template v-for="label in orderedLabels">
<gl-label
:key="label.id"
+ class="js-no-trigger"
:background-color="label.color"
:title="label.title"
:description="label.description"
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index a4b1e6adacf..b8a38d833ad 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -4,7 +4,6 @@ import { sortBy } from 'lodash';
import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
-import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
import defaultSortableConfig from '~/sortable/sortable_config';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BoardColumn from './board_column.vue';
@@ -48,7 +47,7 @@ export default {
: this.lists;
},
canDragColumns() {
- return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList;
+ return (this.isEpicBoard || this.glFeatures.graphqlBoardLists) && this.canAdminList;
},
boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div';
@@ -73,14 +72,7 @@ export default {
const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list;
el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' });
},
- handleDragOnStart() {
- sortableStart();
- },
-
handleDragOnEnd(params) {
- sortableEnd();
- if (this.isEpicBoard) return;
-
const { item, newIndex, oldIndex, to } = params;
const listId = item.dataset.id;
@@ -108,7 +100,6 @@ export default {
ref="list"
v-bind="draggableOptions"
class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
- @start="handleDragOnStart"
@end="handleDragOnEnd"
>
<board-column
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 46359cc2bca..e1f8457c0e2 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -4,13 +4,13 @@ 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';
+import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
+import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue';
export default {
headerHeight: `${contentTop()}px`,
@@ -18,10 +18,11 @@ export default {
GlDrawer,
BoardSidebarTitle,
SidebarAssigneesWidget,
+ SidebarConfidentialityWidget,
BoardSidebarTimeTracker,
BoardSidebarLabelsSelect,
BoardSidebarDueDate,
- BoardSidebarSubscription,
+ SidebarSubscriptionsWidget,
BoardSidebarMilestoneSelect,
BoardSidebarEpicSelect: () =>
import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'),
@@ -30,7 +31,20 @@ export default {
SidebarIterationWidget: () =>
import('ee_component/sidebar/components/sidebar_iteration_widget.vue'),
},
- mixins: [glFeatureFlagsMixin()],
+ inject: {
+ multipleAssigneesFeatureAvailable: {
+ default: false,
+ },
+ epicFeatureAvailable: {
+ default: false,
+ },
+ iterationFeatureAvailable: {
+ default: false,
+ },
+ weightFeatureAvailable: {
+ default: false,
+ },
+ },
computed: {
...mapGetters([
'isSidebarOpen',
@@ -50,7 +64,7 @@ export default {
},
},
methods: {
- ...mapActions(['toggleBoardItem', 'setAssignees']),
+ ...mapActions(['toggleBoardItem', 'setAssignees', 'setActiveItemConfidential']),
handleClose() {
this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType });
},
@@ -72,13 +86,14 @@ export default {
:iid="activeBoardItem.iid"
:full-path="fullPath"
:initial-assignees="activeBoardItem.assignees"
- class="assignee"
+ :allow-multiple-assignees="multipleAssigneesFeatureAvailable"
@assignees-updated="setAssignees"
/>
- <board-sidebar-epic-select class="epic" />
+ <board-sidebar-epic-select v-if="epicFeatureAvailable" class="epic" />
<div>
<board-sidebar-milestone-select />
<sidebar-iteration-widget
+ v-if="iterationFeatureAvailable"
:iid="activeBoardItem.iid"
:workspace-path="projectPathForActiveIssue"
:iterations-workspace-path="groupPathForActiveIssue"
@@ -89,8 +104,19 @@ export default {
<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" />
+ <board-sidebar-weight-input v-if="weightFeatureAvailable" class="weight" />
+ <sidebar-confidentiality-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ @confidentialityUpdated="setActiveItemConfidential($event)"
+ />
+ <sidebar-subscriptions-widget
+ :iid="activeBoardItem.iid"
+ :full-path="fullPath"
+ :issuable-type="issuableType"
+ data-testid="sidebar-notifications"
+ />
</template>
</gl-drawer>
</template>
diff --git a/app/assets/javascripts/boards/components/board_filtered_search.vue b/app/assets/javascripts/boards/components/board_filtered_search.vue
new file mode 100644
index 00000000000..e564af0c353
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_filtered_search.vue
@@ -0,0 +1,154 @@
+<script>
+import { pickBy } from 'lodash';
+import { mapActions } from 'vuex';
+import { updateHistory, 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'),
+ label: __('Label'),
+ author: __('Author'),
+ },
+ components: { FilteredSearch },
+ inject: ['initialFilterParams'],
+ props: {
+ tokens: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ filterParams: this.initialFilterParams,
+ };
+ },
+ computed: {
+ urlParams() {
+ const { authorUsername, labelName, search } = this.filterParams;
+ let notParams = {};
+
+ if (Object.prototype.hasOwnProperty.call(this.filterParams, 'not')) {
+ notParams = pickBy(
+ {
+ 'not[label_name][]': this.filterParams.not.labelName,
+ 'not[author_username]': this.filterParams.not.authorUsername,
+ },
+ undefined,
+ );
+ }
+
+ return {
+ ...notParams,
+ author_username: authorUsername,
+ 'label_name[]': labelName,
+ search,
+ };
+ },
+ },
+ methods: {
+ ...mapActions(['performSearch']),
+ handleFilter(filters) {
+ this.filterParams = this.getFilterParams(filters);
+
+ updateHistory({
+ url: setUrlParams(this.urlParams, window.location.href, true, false, true),
+ title: document.title,
+ replace: true,
+ });
+
+ this.performSearch();
+ },
+ getFilteredSearchValue() {
+ const { authorUsername, labelName, search } = this.filterParams;
+ const filteredSearchValue = [];
+
+ if (authorUsername) {
+ filteredSearchValue.push({
+ type: 'author_username',
+ value: { data: authorUsername, operator: '=' },
+ });
+ }
+
+ if (labelName?.length) {
+ filteredSearchValue.push(
+ ...labelName.map((label) => ({
+ type: 'label_name',
+ value: { data: label, operator: '=' },
+ })),
+ );
+ }
+
+ if (this.filterParams['not[authorUsername]']) {
+ filteredSearchValue.push({
+ type: 'author_username',
+ value: { data: this.filterParams['not[authorUsername]'], operator: '!=' },
+ });
+ }
+
+ if (this.filterParams['not[labelName]']) {
+ filteredSearchValue.push(
+ ...this.filterParams['not[labelName]'].map((label) => ({
+ type: 'label_name',
+ value: { data: label, operator: '!=' },
+ })),
+ );
+ }
+
+ if (search) {
+ filteredSearchValue.push(search);
+ }
+
+ return filteredSearchValue;
+ },
+ getFilterParams(filters = []) {
+ const notFilters = filters.filter((item) => item.value.operator === '!=');
+ const equalsFilters = filters.filter((item) => item.value.operator === '=');
+
+ return { ...this.generateParams(equalsFilters), not: { ...this.generateParams(notFilters) } };
+ },
+ generateParams(filters = []) {
+ const filterParams = {};
+ const labels = [];
+ const plainText = [];
+
+ filters.forEach((filter) => {
+ switch (filter.type) {
+ case 'author_username':
+ filterParams.authorUsername = filter.value.data;
+ break;
+ case 'label_name':
+ labels.push(filter.value.data);
+ break;
+ case 'filtered-search-term':
+ if (filter.value.data) plainText.push(filter.value.data);
+ break;
+ default:
+ break;
+ }
+ });
+
+ if (labels.length) {
+ filterParams.labelName = labels;
+ }
+
+ if (plainText.length) {
+ filterParams.search = plainText.join(' ');
+ }
+ return filterParams;
+ },
+ },
+};
+</script>
+
+<template>
+ <filtered-search
+ class="gl-w-full"
+ namespace=""
+ :tokens="tokens"
+ :search-input-placeholder="$options.i18n.search"
+ :initial-filter-value="getFilteredSearchValue()"
+ @onFilter="handleFilter"
+ />
+</template>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index ca66ad6934a..f94697172ac 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -161,7 +161,7 @@ export default {
const collapsed = !this.list.collapsed;
this.toggleListCollapsed({ listId: this.list.id, collapsed });
- if (!this.isLoggedIn || this.isEpicBoard) {
+ if (!this.isLoggedIn) {
this.addToLocalStorage();
} else {
this.updateListFunction();
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 997655c346a..3d7f1f38a34 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -29,17 +29,17 @@ export default {
};
},
computed: {
- ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']),
+ ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL', 'isEpicBoard']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
isWipLimitsOn() {
- return this.glFeatures.wipLimits;
+ return this.glFeatures.wipLimits && !this.isEpicBoard;
},
activeList() {
/*
Warning: Though a computed property it is not reactive because we are
referencing a List Model class. Reactivity only applies to plain JS objects
*/
- if (this.shouldUseGraphQL) {
+ if (this.shouldUseGraphQL || this.isEpicBoard) {
return this.boardLists[this.activeId];
}
return boardsStore.state.lists.find(({ id }) => id === this.activeId);
@@ -71,7 +71,7 @@ export default {
deleteBoard() {
// eslint-disable-next-line no-alert
if (window.confirm(__('Are you sure you want to remove this list?'))) {
- if (this.shouldUseGraphQL) {
+ if (this.shouldUseGraphQL || this.isEpicBoard) {
this.removeList(this.activeId);
} else {
this.activeList.destroy();
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 f78be83cd82..919ef0d3783 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
@@ -1,10 +1,12 @@
<script>
import { GlLabel } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
+import Api from '~/api';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
@@ -14,7 +16,13 @@ export default {
LabelsSelect,
GlLabel,
},
- inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
+ inject: {
+ labelsFetchPath: {
+ default: null,
+ },
+ labelsManagePath: {},
+ labelsFilterBasePath: {},
+ },
data() {
return {
loading: false,
@@ -38,6 +46,32 @@ export default {
scoped: isScopedLabel(label),
}));
},
+ fetchPath() {
+ /*
+ Labels fetched in epic boards are always group-level labels
+ and the correct path are passed from the backend (injected through labelsFetchPath)
+
+ For issue boards, we should always include project-level labels and use a different endpoint.
+ (it requires knowing the project path of a selected issue.)
+
+ Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget.
+ And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653.
+
+ Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates.
+ 'labels-select' has its own vuex store and initializes the passed props as states
+ and these states aren't reactively bound to the passed props.
+ */
+
+ const projectLabelsFetchPath = mergeUrlParams(
+ { include_ancestor_groups: true },
+ Api.buildUrl(Api.projectLabelsPath).replace(
+ ':namespace_path/:project_path',
+ this.projectPathForActiveIssue,
+ ),
+ );
+
+ return this.labelsFetchPath || projectLabelsFetchPath;
+ },
},
methods: {
...mapActions(['setActiveBoardItemLabels']),
@@ -77,7 +111,12 @@ export default {
</script>
<template>
- <board-editable-item ref="sidebarItem" :title="__('Labels')" :loading="loading">
+ <board-editable-item
+ ref="sidebarItem"
+ :title="__('Labels')"
+ :loading="loading"
+ data-testid="sidebar-labels"
+ >
<template #collapsed>
<gl-label
v-for="label in issueLabels"
@@ -95,12 +134,13 @@ export default {
<template #default="{ edit }">
<labels-select
ref="labelsSelect"
+ :key="fetchPath"
:allow-label-edit="false"
:allow-label-create="false"
:allow-multiselect="true"
:allow-scoped-labels="true"
:selected-labels="selectedLabels"
- :labels-fetch-path="labelsFetchPath"
+ :labels-fetch-path="fetchPath"
:labels-manage-path="labelsManagePath"
:labels-filter-base-path="labelsFilterBasePath"
:labels-list-title="__('Select label')"
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 4ebd30fe67b..d88774d11c1 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,10 +1,28 @@
+import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
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 destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
+import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
+
import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql';
import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql';
+export const SupportedFilters = [
+ 'assigneeUsername',
+ 'authorUsername',
+ 'labelName',
+ 'milestoneTitle',
+ 'releaseTag',
+ 'search',
+ 'myReactionEmoji',
+ 'assigneeId',
+];
+
+/* eslint-disable-next-line @gitlab/require-i18n-strings */
+export const AssigneeIdParamValues = ['Any', 'None'];
+
export const issuableTypes = {
issue: 'issue',
epic: 'epic',
@@ -46,9 +64,10 @@ export const NOT_FILTER = 'not[';
export const flashAnimationDuration = 2000;
-export default {
- BoardType,
- ListType,
+export const listsQuery = {
+ [issuableTypes.issue]: {
+ query: boardListsQuery,
+ },
};
export const blockingIssuablesQueries = {
@@ -57,6 +76,18 @@ export const blockingIssuablesQueries = {
},
};
+export const updateListQueries = {
+ [issuableTypes.issue]: {
+ mutation: updateBoardListMutation,
+ },
+};
+
+export const deleteListQueries = {
+ [issuableTypes.issue]: {
+ mutation: destroyBoardListMutation,
+ },
+};
+
export const titleQueries = {
[issuableTypes.issue]: {
mutation: issueSetTitleMutation,
@@ -74,3 +105,8 @@ export const subscriptionQueries = {
mutation: updateEpicSubscriptionMutation,
},
};
+
+export default {
+ BoardType,
+ ListType,
+};
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 66580bdd30f..c6040f1e4aa 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -28,6 +28,10 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) {
const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig);
+ // TODO Refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/329274
+ // here we are using "window.location.search" as a temporary store
+ // only to unpack the params and do another validation inside
+ // 'performSearch' and 'setFilter' vuex actions.
if (boardConfigPath !== '') {
const filterPath = window.location.search ? `${window.location.search}&` : '?';
updateHistory({
diff --git a/app/assets/javascripts/boards/graphql/group_projects.query.graphql b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
index 80a37c9943d..3218c06357c 100644
--- a/app/assets/javascripts/boards/graphql/group_projects.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_projects.query.graphql
@@ -2,7 +2,7 @@
query getGroupProjects($fullPath: ID!, $search: String, $after: String) {
group(fullPath: $fullPath) {
- projects(search: $search, after: $after, first: 100) {
+ projects(search: $search, after: $after, first: 100, includeSubgroups: true) {
nodes {
id
name
diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 7ecf9261214..47ecb55c72b 100644
--- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -13,7 +13,6 @@ fragment IssueNode on Issue {
emailsDisabled
confidential
webUrl
- subscribed
relativePosition
milestone {
id
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index e3f9d2f24c2..1888645ef78 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,3 +1,4 @@
+import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapActions, mapGetters } from 'vuex';
@@ -35,13 +36,27 @@ import {
} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import sidebarEventHub from '~/sidebar/event_hub';
+import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
+import { fullBoardId } from './boards_util';
import boardConfigToggle from './config_toggle';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
Vue.use(VueApollo);
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ fragmentMatcher,
+ },
+ assumeImmutableResults: true,
+ },
+ ),
});
let issueBoardsApp;
@@ -82,10 +97,14 @@ export default () => {
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,
timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours),
+ multipleAssigneesFeatureAvailable: parseBoolean(
+ $boardApp.dataset.multipleAssigneesFeatureAvailable,
+ ),
+ epicFeatureAvailable: parseBoolean($boardApp.dataset.epicFeatureAvailable),
+ iterationFeatureAvailable: parseBoolean($boardApp.dataset.iterationFeatureAvailable),
weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable),
boardWeight: $boardApp.dataset.boardWeight
? parseInt($boardApp.dataset.boardWeight, 10)
@@ -121,6 +140,7 @@ export default () => {
created() {
this.setInitialBoardData({
boardId: $boardApp.dataset.boardId,
+ fullBoardId: fullBoardId($boardApp.dataset.boardId),
fullPath: $boardApp.dataset.fullPath,
boardType: this.parent,
disabled: this.disabled,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 8005414962c..5158e82c320 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,8 +1,4 @@
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,
@@ -11,7 +7,14 @@ import {
ISSUABLE,
titleQueries,
subscriptionQueries,
-} from '~/boards/constants';
+ SupportedFilters,
+ deleteListQueries,
+ listsQuery,
+ updateListQueries,
+ issuableTypes,
+} from 'ee_else_ce/boards/constants';
+import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
+import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
@@ -19,7 +22,6 @@ import { s__ } from '~/locale';
import {
formatBoardLists,
formatListIssues,
- fullBoardId,
formatListsPageInfo,
formatIssue,
formatIssueInput,
@@ -27,10 +29,9 @@ import {
transformNotFilters,
moveItemListHelper,
getMoveData,
+ getSupportedParams,
} 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 issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql';
@@ -39,11 +40,6 @@ import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.g
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
import * as types from './mutation_types';
-const notImplemented = () => {
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- throw new Error('Not implemented!');
-};
-
export const gqlClient = createGqClient(
{},
{
@@ -65,16 +61,11 @@ export default {
},
setFilters: ({ commit }, filters) => {
- const filterParams = pick(filters, [
- 'assigneeUsername',
- 'authorUsername',
- 'labelName',
- 'milestoneTitle',
- 'releaseTag',
- 'search',
- 'myReactionEmoji',
- ]);
- filterParams.not = transformNotFilters(filters);
+ const filterParams = {
+ ...getSupportedParams(filters, SupportedFilters),
+ not: transformNotFilters(filters),
+ };
+
commit(types.SET_FILTERS, filterParams);
},
@@ -90,24 +81,22 @@ export default {
}
},
- fetchLists: ({ dispatch }) => {
- dispatch('fetchIssueLists');
- },
-
- fetchIssueLists: ({ commit, state, dispatch }) => {
- const { boardType, filterParams, fullPath, boardId } = state;
+ fetchLists: ({ commit, state, dispatch }) => {
+ const { boardType, filterParams, fullPath, fullBoardId, issuableType } = state;
const variables = {
fullPath,
- boardId: fullBoardId(boardId),
+ boardId: fullBoardId,
filters: filterParams,
- isGroup: boardType === BoardType.group,
- isProject: boardType === BoardType.project,
+ ...(issuableType === issuableTypes.issue && {
+ isGroup: boardType === BoardType.group,
+ isProject: boardType === BoardType.project,
+ }),
};
return gqlClient
.query({
- query: boardListsQuery,
+ query: listsQuery[issuableType].query,
variables,
})
.then(({ data }) => {
@@ -141,7 +130,7 @@ export default {
{ state, commit, dispatch, getters },
{ backlog, labelId, milestoneId, assigneeId, iterationId },
) => {
- const { boardId } = state;
+ const { fullBoardId } = state;
const existingList = getters.getListByLabelId(labelId);
@@ -154,7 +143,7 @@ export default {
.mutate({
mutation: createBoardListMutation,
variables: {
- boardId: fullBoardId(boardId),
+ boardId: fullBoardId,
backlog,
labelId,
milestoneId,
@@ -242,10 +231,13 @@ export default {
dispatch('updateList', { listId, position: newPosition, backupList });
},
- updateList: ({ commit }, { listId, position, collapsed, backupList }) => {
+ updateList: (
+ { commit, state: { issuableType } },
+ { listId, position, collapsed, backupList },
+ ) => {
gqlClient
.mutate({
- mutation: updateBoardListMutation,
+ mutation: updateListQueries[issuableType].mutation,
variables: {
listId,
position,
@@ -266,14 +258,14 @@ export default {
commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed });
},
- removeList: ({ state, commit }, listId) => {
- const listsBackup = { ...state.boardLists };
+ removeList: ({ state: { issuableType, boardLists }, commit }, listId) => {
+ const listsBackup = { ...boardLists };
commit(types.REMOVE_LIST, listId);
return gqlClient
.mutate({
- mutation: destroyBoardListMutation,
+ mutation: deleteListQueries[issuableType].mutation,
variables: {
listId,
},
@@ -297,11 +289,11 @@ export default {
fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
- const { fullPath, boardId, boardType, filterParams } = state;
+ const { fullPath, fullBoardId, boardType, filterParams } = state;
const variables = {
fullPath,
- boardId: fullBoardId(boardId),
+ boardId: fullBoardId,
id: listId,
filters: filterParams,
isGroup: boardType === BoardType.group,
@@ -430,7 +422,7 @@ export default {
try {
const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData;
const {
- boardId,
+ fullBoardId,
boardItems: {
[itemId]: { iid, referencePath },
},
@@ -441,7 +433,7 @@ export default {
variables: {
iid,
projectPath: referencePath.split(/[#]/)[0],
- boardId: fullBoardId(boardId),
+ boardId: fullBoardId,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
moveBeforeId,
@@ -653,6 +645,15 @@ export default {
});
},
+ setActiveItemConfidential: ({ commit, getters }, confidential) => {
+ const { activeBoardItem } = getters;
+ commit(types.UPDATE_BOARD_ITEM_BY_ID, {
+ itemId: activeBoardItem.id,
+ prop: 'confidential',
+ value: confidential,
+ });
+ },
+
fetchGroupProjects: ({ commit, state }, { search = '', fetchNext = false }) => {
commit(types.REQUEST_GROUP_PROJECTS, fetchNext);
@@ -731,28 +732,4 @@ export default {
unsetError: ({ commit }) => {
commit(types.SET_ERROR, undefined);
},
-
- fetchBacklog: () => {
- notImplemented();
- },
-
- bulkUpdateIssues: () => {
- notImplemented();
- },
-
- fetchIssue: () => {
- notImplemented();
- },
-
- toggleIssueSubscription: () => {
- notImplemented();
- },
-
- showPage: () => {
- notImplemented();
- },
-
- toggleEmptyState: () => {
- notImplemented();
- },
};
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index 0589851c658..b61ecc5ccb6 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -21,7 +21,7 @@ export default {
groupPathForActiveIssue: (_, getters) => {
const { referencePath = '' } = getters.activeBoardItem;
- return referencePath.slice(0, referencePath.indexOf('/'));
+ return referencePath.slice(0, referencePath.lastIndexOf('/'));
},
projectPathForActiveIssue: (_, getters) => {
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 22b9905ee62..ccea2917c2c 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -9,9 +9,7 @@ export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE';
export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS';
export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE';
export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST';
-export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST';
export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
-export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
export const MOVE_LIST = 'MOVE_LIST';
export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE';
export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
@@ -20,19 +18,11 @@ 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 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 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_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_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID';
export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 561c21b78c1..667628b2998 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -6,11 +6,6 @@ import { formatIssue } from '../boards_util';
import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
-const notImplemented = () => {
- /* eslint-disable-next-line @gitlab/require-i18n-strings */
- throw new Error('Not implemented!');
-};
-
const updateListItemsCount = ({ state, listId, value }) => {
const list = state.boardLists[listId];
if (state.issuableType === issuableTypes.epic) {
@@ -40,8 +35,9 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const { boardType, disabled, boardId, fullPath, boardConfig, issuableType } = data;
+ const { boardType, disabled, boardId, fullBoardId, fullPath, boardConfig, issuableType } = data;
state.boardId = boardId;
+ state.fullBoardId = fullBoardId;
state.fullPath = fullPath;
state.boardType = boardType;
state.disabled = disabled;
@@ -93,18 +89,10 @@ export default {
state.error = s__('Boards|An error occurred while generating lists. Please reload the page.');
},
- [mutationTypes.REQUEST_ADD_LIST]: () => {
- notImplemented();
- },
-
[mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: (state, list) => {
Vue.set(state.boardLists, list.id, list);
},
- [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => {
- notImplemented();
- },
-
[mutationTypes.MOVE_LIST]: (state, { movedList, listAtNewIndex }) => {
const { boardLists } = state;
Vue.set(boardLists, movedList.id, movedList);
@@ -171,35 +159,11 @@ export default {
state.isSettingAssignees = isLoading;
},
- [mutationTypes.REQUEST_ADD_ISSUE]: () => {
- notImplemented();
- },
-
- [mutationTypes.RECEIVE_ADD_ISSUE_SUCCESS]: () => {
- notImplemented();
- },
-
- [mutationTypes.RECEIVE_ADD_ISSUE_ERROR]: () => {
- notImplemented();
- },
-
[mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => {
const issueId = getIdFromGraphQLId(issue.id);
Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId }));
},
- [mutationTypes.REQUEST_UPDATE_ISSUE]: () => {
- notImplemented();
- },
-
- [mutationTypes.RECEIVE_UPDATE_ISSUE_SUCCESS]: () => {
- notImplemented();
- },
-
- [mutationTypes.RECEIVE_UPDATE_ISSUE_ERROR]: () => {
- notImplemented();
- },
-
[mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
state,
{ itemId, listId, moveBeforeId, moveAfterId, atIndex },
@@ -219,14 +183,6 @@ export default {
Vue.delete(state.boardItems, itemId);
},
- [mutationTypes.SET_CURRENT_PAGE]: () => {
- notImplemented();
- },
-
- [mutationTypes.TOGGLE_EMPTY_STATE]: () => {
- notImplemented();
- },
-
[mutationTypes.REQUEST_GROUP_PROJECTS]: (state, fetchNext) => {
Vue.set(state, 'groupProjectsFlags', {
[fetchNext ? 'isLoadingMore' : 'isLoading']: true,
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 e5923124653..b959d97daea 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
@@ -162,23 +162,26 @@ export default {
</p>
</template>
</gl-table>
- <div class="ci-variable-actions" :class="{ 'justify-content-center': !tableIsNotEmpty }">
+ <div
+ class="ci-variable-actions gl-display-flex"
+ :class="{ 'justify-content-center': !tableIsNotEmpty }"
+ >
+ <gl-button
+ ref="add-ci-variable"
+ v-gl-modal-directive="$options.modalId"
+ class="gl-mr-3"
+ data-qa-selector="add_ci_variable_button"
+ variant="confirm"
+ category="primary"
+ >{{ __('Add variable') }}</gl-button
+ >
<gl-button
v-if="tableIsNotEmpty"
ref="secret-value-reveal-button"
data-qa-selector="reveal_ci_variable_value_button"
- class="gl-mr-3"
@click="toggleValues(!valuesHidden)"
>{{ valuesButtonText }}</gl-button
>
- <gl-button
- ref="add-ci-variable"
- v-gl-modal-directive="$options.modalId"
- data-qa-selector="add_ci_variable_button"
- variant="success"
- category="primary"
- >{{ __('Add Variable') }}</gl-button
- >
</div>
</div>
</template>
diff --git a/app/assets/javascripts/code_quality_walkthrough/components/step.vue b/app/assets/javascripts/code_quality_walkthrough/components/step.vue
new file mode 100644
index 00000000000..1a23c96b7d6
--- /dev/null
+++ b/app/assets/javascripts/code_quality_walkthrough/components/step.vue
@@ -0,0 +1,150 @@
+<script>
+import { GlPopover, GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
+import { STEPS, STEPSTATES } from '../constants';
+import {
+ isWalkthroughEnabled,
+ getExperimentSettings,
+ setExperimentSettings,
+ track,
+} from '../utils';
+
+export default {
+ target: '#js-code-quality-walkthrough',
+ components: {
+ GlPopover,
+ GlSprintf,
+ GlButton,
+ GlAlert,
+ },
+ props: {
+ step: {
+ type: String,
+ required: true,
+ },
+ link: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ dismissedSettings: getExperimentSettings(),
+ currentStep: STEPSTATES[this.step],
+ };
+ },
+ computed: {
+ isPopoverVisible() {
+ return (
+ [
+ STEPS.commitCiFile,
+ STEPS.runningPipeline,
+ STEPS.successPipeline,
+ STEPS.failedPipeline,
+ ].includes(this.step) &&
+ isWalkthroughEnabled() &&
+ !this.isDismissed
+ );
+ },
+ isAlertVisible() {
+ return this.step === STEPS.troubleshootJob && isWalkthroughEnabled() && !this.isDismissed;
+ },
+ isDismissed() {
+ return this.dismissedSettings[this.step];
+ },
+ title() {
+ return this.currentStep?.title || '';
+ },
+ body() {
+ return this.currentStep?.body || '';
+ },
+ buttonText() {
+ return this.currentStep?.buttonText || '';
+ },
+ buttonLink() {
+ return [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step) ? this.link : '';
+ },
+ placement() {
+ return this.currentStep?.placement || 'bottom';
+ },
+ offset() {
+ return this.currentStep?.offset || 0;
+ },
+ },
+ created() {
+ this.trackDisplayed();
+ },
+ updated() {
+ this.trackDisplayed();
+ },
+ methods: {
+ onDismiss() {
+ this.$set(this.dismissedSettings, this.step, true);
+ setExperimentSettings(this.dismissedSettings);
+ const action = [STEPS.successPipeline, STEPS.failedPipeline].includes(this.step)
+ ? 'view_logs'
+ : 'dismissed';
+ this.trackAction(action);
+ },
+ trackDisplayed() {
+ if (this.isPopoverVisible || this.isAlertVisible) {
+ this.trackAction('displayed');
+ }
+ },
+ trackAction(action) {
+ track(`${this.step}_${action}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-popover
+ v-if="isPopoverVisible"
+ :key="step"
+ :target="$options.target"
+ :placement="placement"
+ :offset="offset"
+ show
+ triggers="manual"
+ container="viewport"
+ >
+ <template #title>
+ <gl-sprintf :message="title">
+ <template #emoji="{ content }">
+ <gl-emoji class="gl-mr-2" :data-name="content"
+ /></template>
+ </gl-sprintf>
+ </template>
+ <gl-sprintf :message="body">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #lineBreak>
+ <div class="gl-mt-5"></div>
+ </template>
+ <template #emoji="{ content }">
+ <gl-emoji :data-name="content" />
+ </template>
+ </gl-sprintf>
+ <div class="gl-mt-2 gl-text-right">
+ <gl-button category="tertiary" variant="link" :href="buttonLink" @click="onDismiss">
+ {{ buttonText }}
+ </gl-button>
+ </div>
+ </gl-popover>
+ <gl-alert
+ v-if="isAlertVisible"
+ variant="tip"
+ :title="title"
+ :primary-button-text="buttonText"
+ :primary-button-link="link"
+ class="gl-my-5"
+ @primaryAction="trackAction('clicked')"
+ @dismiss="onDismiss"
+ >
+ {{ body }}
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/code_quality_walkthrough/constants.js b/app/assets/javascripts/code_quality_walkthrough/constants.js
new file mode 100644
index 00000000000..011df06b5cc
--- /dev/null
+++ b/app/assets/javascripts/code_quality_walkthrough/constants.js
@@ -0,0 +1,67 @@
+import { s__ } from '~/locale';
+
+export const EXPERIMENT_NAME = 'code_quality_walkthrough';
+
+export const STEPS = {
+ commitCiFile: 'commit_ci_file',
+ runningPipeline: 'running_pipeline',
+ successPipeline: 'success_pipeline',
+ failedPipeline: 'failed_pipeline',
+ troubleshootJob: 'troubleshoot_job',
+};
+
+export const STEPSTATES = {
+ [STEPS.commitCiFile]: {
+ title: s__("codeQualityWalkthrough|Let's start by creating a new CI file."),
+ body: s__(
+ 'codeQualityWalkthrough|To begin with code quality, we first need to create a new CI file using our code editor. We added a code quality template in the code editor to help you get started %{emojiStart}wink%{emojiEnd} .%{lineBreak}Take some time to review the template, when you are ready, use the %{strongStart}commit changes%{strongEnd} button at the bottom of the page.',
+ ),
+ buttonText: s__('codeQualityWalkthrough|Got it'),
+ placement: 'right',
+ offset: 90,
+ },
+ [STEPS.runningPipeline]: {
+ title: s__(
+ 'codeQualityWalkthrough|Congrats! Your first pipeline is running %{emojiStart}zap%{emojiEnd}',
+ ),
+ body: s__(
+ "codeQualityWalkthrough|Your pipeline can take a few minutes to run. If you enabled email notifications, you'll receive an email with your pipeline status. In the meantime, why don't you get some coffee? You earned it!",
+ ),
+ buttonText: s__('codeQualityWalkthrough|Got it'),
+ offset: 97,
+ },
+ [STEPS.successPipeline]: {
+ title: s__(
+ "codeQualityWalkthrough|Well done! You've just automated your code quality review. %{emojiStart}raised_hands%{emojiEnd}",
+ ),
+ body: s__(
+ 'codeQualityWalkthrough|A code quality job will now run every time you or your team members commit changes to your project. You can view the results of the code quality job in the job logs.',
+ ),
+ buttonText: s__('codeQualityWalkthrough|View the logs'),
+ offset: 98,
+ },
+ [STEPS.failedPipeline]: {
+ title: s__(
+ "codeQualityWalkthrough|Something went wrong. %{emojiStart}thinking%{emojiEnd} Let's fix it.",
+ ),
+ body: s__(
+ "codeQualityWalkthrough|Your job failed. No worries - this happens. Let's view the logs, and see how we can fix it.",
+ ),
+ buttonText: s__('codeQualityWalkthrough|View the logs'),
+ offset: 98,
+ },
+ [STEPS.troubleshootJob]: {
+ title: s__('codeQualityWalkthrough|Troubleshoot your code quality job'),
+ body: s__(
+ 'codeQualityWalkthrough|Not sure how to fix your failed job? We have compiled some tips on how to troubleshoot code quality jobs in the documentation.',
+ ),
+ buttonText: s__('codeQualityWalkthrough|Read the documentation'),
+ },
+};
+
+export const PIPELINE_STATUSES = {
+ running: 'running',
+ successWithWarnings: 'success-with-warnings',
+ success: 'success',
+ failed: 'failed',
+};
diff --git a/app/assets/javascripts/code_quality_walkthrough/index.js b/app/assets/javascripts/code_quality_walkthrough/index.js
new file mode 100644
index 00000000000..b0592b8a84b
--- /dev/null
+++ b/app/assets/javascripts/code_quality_walkthrough/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Step from './components/step.vue';
+
+export default (el) =>
+ new Vue({
+ el,
+ render(createElement) {
+ return createElement(Step, {
+ props: {
+ step: el.dataset.step,
+ },
+ });
+ },
+ });
diff --git a/app/assets/javascripts/code_quality_walkthrough/utils.js b/app/assets/javascripts/code_quality_walkthrough/utils.js
new file mode 100644
index 00000000000..97c80f6eff7
--- /dev/null
+++ b/app/assets/javascripts/code_quality_walkthrough/utils.js
@@ -0,0 +1,38 @@
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import { getExperimentData } from '~/experimentation/utils';
+import { setCookie, getCookie, getParameterByName } from '~/lib/utils/common_utils';
+import Tracking from '~/tracking';
+import { EXPERIMENT_NAME } from './constants';
+
+export function getExperimentSettings() {
+ return JSON.parse(getCookie(EXPERIMENT_NAME) || '{}');
+}
+
+export function setExperimentSettings(settings) {
+ setCookie(EXPERIMENT_NAME, settings);
+}
+
+export function isWalkthroughEnabled() {
+ return getParameterByName(EXPERIMENT_NAME);
+}
+
+export function track(action) {
+ const { data } = getExperimentSettings();
+
+ if (data) {
+ Tracking.event(EXPERIMENT_NAME, action, {
+ context: {
+ schema: TRACKING_CONTEXT_SCHEMA,
+ data,
+ },
+ });
+ }
+}
+
+export function startCodeQualityWalkthrough() {
+ const data = getExperimentData(EXPERIMENT_NAME);
+
+ if (data) {
+ setExperimentSettings({ data });
+ }
+}
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 6f496ffc6ae..29ad6cc4125 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -27,6 +27,10 @@ export default () => {
if (pipelineTableViewEl.dataset.disableInitialization === undefined) {
const table = new Vue({
+ provide: {
+ artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
+ artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
+ },
render(createElement) {
return createElement(CommitPipelinesTable, {
props: {
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 839d4de912d..7896268acf0 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,18 +1,24 @@
<script>
-import { EditorContent } from 'tiptap';
-import createEditor from '../services/create_editor';
+import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
+import { ContentEditor } from '../services/content_editor';
+import TopToolbar from './top_toolbar.vue';
export default {
components: {
- EditorContent,
+ TiptapEditorContent,
+ TopToolbar,
},
- data() {
- return {
- editor: createEditor(),
- };
+ props: {
+ contentEditor: {
+ type: ContentEditor,
+ required: true,
+ },
},
};
</script>
<template>
- <editor-content :editor="editor" />
+ <div class="md md-area" :class="{ 'is-focused': contentEditor.tiptapEditor.isFocused }">
+ <top-toolbar class="gl-mb-4" :content-editor="contentEditor" />
+ <tiptap-editor-content :editor="contentEditor.tiptapEditor" />
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/divider.vue b/app/assets/javascripts/content_editor/components/divider.vue
new file mode 100644
index 00000000000..b77bd7b7cf3
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/divider.vue
@@ -0,0 +1,3 @@
+<template>
+ <span class="gl-mx-3 gl-border-r-solid gl-border-r-1 gl-border-gray-200"></span>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue
new file mode 100644
index 00000000000..0af12812f3b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { Editor as TiptapEditor } from '@tiptap/vue-2';
+
+export default {
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ iconName: {
+ type: String,
+ required: true,
+ },
+ tiptapEditor: {
+ type: TiptapEditor,
+ required: true,
+ },
+ contentType: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ editorCommand: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ isActive() {
+ return this.tiptapEditor.isActive(this.contentType) && this.tiptapEditor.isFocused;
+ },
+ },
+ methods: {
+ execute() {
+ const { contentType } = this;
+
+ if (this.editorCommand) {
+ this.tiptapEditor.chain()[this.editorCommand]().focus().run();
+ }
+
+ this.$emit('execute', { contentType });
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ size="small"
+ class="gl-mx-2"
+ :class="{ active: isActive }"
+ :aria-label="label"
+ :title="label"
+ :icon="iconName"
+ @click="execute"
+ />
+</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
new file mode 100644
index 00000000000..b18649d4e57
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -0,0 +1,94 @@
+<script>
+import Tracking from '~/tracking';
+import { CONTENT_EDITOR_TRACKING_LABEL, TOOLBAR_CONTROL_TRACKING_ACTION } from '../constants';
+import { ContentEditor } from '../services/content_editor';
+import Divider from './divider.vue';
+import ToolbarButton from './toolbar_button.vue';
+
+const trackingMixin = Tracking.mixin({
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+});
+
+export default {
+ components: {
+ ToolbarButton,
+ Divider,
+ },
+ mixins: [trackingMixin],
+ props: {
+ contentEditor: {
+ type: ContentEditor,
+ required: true,
+ },
+ },
+ methods: {
+ trackToolbarControlExecution({ contentType: property, value }) {
+ this.track(TOOLBAR_CONTROL_TRACKING_ACTION, {
+ property,
+ value,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-display-flex gl-justify-content-end gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
+ >
+ <toolbar-button
+ data-testid="bold"
+ content-type="bold"
+ icon-name="bold"
+ editor-command="toggleBold"
+ :label="__('Bold text')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="italic"
+ content-type="italic"
+ icon-name="italic"
+ editor-command="toggleItalic"
+ :label="__('Italic text')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="code"
+ content-type="code"
+ icon-name="code"
+ editor-command="toggleCode"
+ :label="__('Code')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <divider />
+ <toolbar-button
+ data-testid="blockquote"
+ content-type="blockquote"
+ icon-name="quote"
+ editor-command="toggleBlockquote"
+ :label="__('Insert a quote')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="bullet-list"
+ content-type="bulletList"
+ icon-name="list-bulleted"
+ editor-command="toggleBulletList"
+ :label="__('Add a bullet list')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ <toolbar-button
+ data-testid="ordered-list"
+ content-type="orderedList"
+ icon-name="list-numbered"
+ editor-command="toggleOrderedList"
+ :label="__('Add a numbered list')"
+ :tiptap-editor="contentEditor.tiptapEditor"
+ @execute="trackToolbarControlExecution"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index eb6deff434d..45ebd87dac9 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -3,3 +3,8 @@ import { s__ } from '~/locale';
export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__(
'ContentEditor|You have to provide a renderMarkdown function or a custom serializer',
);
+
+export const CONTENT_EDITOR_TRACKING_LABEL = 'content_editor';
+export const TOOLBAR_CONTROL_TRACKING_ACTION = 'execute_toolbar_control';
+export const KEYBOARD_SHORTCUT_TRACKING_ACTION = 'execute_keyboard_shortcut';
+export const INPUT_RULE_TRACKING_ACTION = 'execute_input_rule';
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
new file mode 100644
index 00000000000..a4297b4550c
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -0,0 +1,5 @@
+import { Blockquote } from '@tiptap/extension-blockquote';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Blockquote;
+export const serializer = defaultMarkdownSerializer.nodes.blockquote;
diff --git a/app/assets/javascripts/content_editor/extensions/bold.js b/app/assets/javascripts/content_editor/extensions/bold.js
new file mode 100644
index 00000000000..e90e7b59da0
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/bold.js
@@ -0,0 +1,5 @@
+import { Bold } from '@tiptap/extension-bold';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Bold;
+export const serializer = defaultMarkdownSerializer.marks.strong;
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
new file mode 100644
index 00000000000..178b798e2d4
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -0,0 +1,5 @@
+import { BulletList } from '@tiptap/extension-bullet-list';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = BulletList;
+export const serializer = defaultMarkdownSerializer.nodes.bullet_list;
diff --git a/app/assets/javascripts/content_editor/extensions/code.js b/app/assets/javascripts/content_editor/extensions/code.js
new file mode 100644
index 00000000000..8be50dc39c5
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/code.js
@@ -0,0 +1,5 @@
+import { Code } from '@tiptap/extension-code';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Code;
+export const serializer = defaultMarkdownSerializer.marks.code;
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index 1d050ed208b..ce8bd57c7e3 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,38 +1,27 @@
-import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions';
+import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
-export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight {
- get schema() {
- const baseSchema = super.schema;
+const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang');
+const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
+ addAttributes() {
return {
- ...baseSchema,
- attrs: {
- params: {
- default: null,
+ ...this.parent(),
+ /* `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: {
+ parseHTML: (element) => {
+ return {
+ params: extractLanguage(element),
+ };
},
},
- 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'),
- };
- },
- },
- ],
};
- }
-}
+ },
+});
+
+export const tiptapExtension = ExtendedCodeBlockLowlight;
+export const serializer = defaultMarkdownSerializer.nodes.code_block;
diff --git a/app/assets/javascripts/content_editor/extensions/document.js b/app/assets/javascripts/content_editor/extensions/document.js
new file mode 100644
index 00000000000..99aa8d6235a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/document.js
@@ -0,0 +1,3 @@
+import Document from '@tiptap/extension-document';
+
+export const tiptapExtension = Document;
diff --git a/app/assets/javascripts/content_editor/extensions/dropcursor.js b/app/assets/javascripts/content_editor/extensions/dropcursor.js
new file mode 100644
index 00000000000..44c378ac7db
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/dropcursor.js
@@ -0,0 +1,3 @@
+import Dropcursor from '@tiptap/extension-dropcursor';
+
+export const tiptapExtension = Dropcursor;
diff --git a/app/assets/javascripts/content_editor/extensions/gapcursor.js b/app/assets/javascripts/content_editor/extensions/gapcursor.js
new file mode 100644
index 00000000000..2db862e4580
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/gapcursor.js
@@ -0,0 +1,3 @@
+import Gapcursor from '@tiptap/extension-gapcursor';
+
+export const tiptapExtension = Gapcursor;
diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js
new file mode 100644
index 00000000000..dc1ba431151
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/hard_break.js
@@ -0,0 +1,5 @@
+import { HardBreak } from '@tiptap/extension-hard-break';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = HardBreak;
+export const serializer = defaultMarkdownSerializer.nodes.hard_break;
diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js
new file mode 100644
index 00000000000..f69869d1e09
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/heading.js
@@ -0,0 +1,5 @@
+import { Heading } from '@tiptap/extension-heading';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Heading;
+export const serializer = defaultMarkdownSerializer.nodes.heading;
diff --git a/app/assets/javascripts/content_editor/extensions/history.js b/app/assets/javascripts/content_editor/extensions/history.js
new file mode 100644
index 00000000000..554d797d30a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/history.js
@@ -0,0 +1,3 @@
+import History from '@tiptap/extension-history';
+
+export const tiptapExtension = History;
diff --git a/app/assets/javascripts/content_editor/extensions/horizontal_rule.js b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
new file mode 100644
index 00000000000..dcc59476518
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/horizontal_rule.js
@@ -0,0 +1,5 @@
+import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = HorizontalRule;
+export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule;
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
new file mode 100644
index 00000000000..4f0109fd751
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -0,0 +1,9 @@
+import { Image } from '@tiptap/extension-image';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+const ExtendedImage = Image.extend({
+ defaultOptions: { inline: true },
+});
+
+export const tiptapExtension = ExtendedImage;
+export const serializer = defaultMarkdownSerializer.nodes.image;
diff --git a/app/assets/javascripts/content_editor/extensions/italic.js b/app/assets/javascripts/content_editor/extensions/italic.js
new file mode 100644
index 00000000000..b8a7c4aba3e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/italic.js
@@ -0,0 +1,4 @@
+import { Italic } from '@tiptap/extension-italic';
+
+export const tiptapExtension = Italic;
+export const serializer = { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true };
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
new file mode 100644
index 00000000000..9a2fa7a5c98
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -0,0 +1,5 @@
+import { Link } from '@tiptap/extension-link';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Link;
+export const serializer = defaultMarkdownSerializer.marks.link;
diff --git a/app/assets/javascripts/content_editor/extensions/list_item.js b/app/assets/javascripts/content_editor/extensions/list_item.js
new file mode 100644
index 00000000000..86da98f6df7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/list_item.js
@@ -0,0 +1,5 @@
+import { ListItem } from '@tiptap/extension-list-item';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = ListItem;
+export const serializer = defaultMarkdownSerializer.nodes.list_item;
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
new file mode 100644
index 00000000000..d980ab8bf10
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -0,0 +1,5 @@
+import { OrderedList } from '@tiptap/extension-ordered-list';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = OrderedList;
+export const serializer = defaultMarkdownSerializer.nodes.ordered_list;
diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js
new file mode 100644
index 00000000000..6c9f204b8ac
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/paragraph.js
@@ -0,0 +1,5 @@
+import { Paragraph } from '@tiptap/extension-paragraph';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Paragraph;
+export const serializer = defaultMarkdownSerializer.nodes.paragraph;
diff --git a/app/assets/javascripts/content_editor/extensions/text.js b/app/assets/javascripts/content_editor/extensions/text.js
new file mode 100644
index 00000000000..0d76aa1f1a7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/text.js
@@ -0,0 +1,5 @@
+import { Text } from '@tiptap/extension-text';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
+
+export const tiptapExtension = Text;
+export const serializer = defaultMarkdownSerializer.nodes.text;
diff --git a/app/assets/javascripts/content_editor/index.js b/app/assets/javascripts/content_editor/index.js
index e6ef3965da1..2a7dc9b713d 100644
--- a/app/assets/javascripts/content_editor/index.js
+++ b/app/assets/javascripts/content_editor/index.js
@@ -1,2 +1,2 @@
-export { default as createEditor } from './services/create_editor';
+export * from './services/create_content_editor';
export { default as ContentEditor } from './components/content_editor.vue';
diff --git a/app/assets/javascripts/content_editor/services/build_serializer_config.js b/app/assets/javascripts/content_editor/services/build_serializer_config.js
new file mode 100644
index 00000000000..75e2b0f9eba
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/build_serializer_config.js
@@ -0,0 +1,22 @@
+const buildSerializerConfig = (extensions = []) =>
+ extensions
+ .filter(({ serializer }) => serializer)
+ .reduce(
+ (serializers, { serializer, tiptapExtension: { name, type } }) => {
+ const collection = `${type}s`;
+
+ return {
+ ...serializers,
+ [collection]: {
+ ...serializers[collection],
+ [name]: serializer,
+ },
+ };
+ },
+ {
+ nodes: {},
+ marks: {},
+ },
+ );
+
+export default buildSerializerConfig;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
new file mode 100644
index 00000000000..e2188f5aa69
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -0,0 +1,25 @@
+/* eslint-disable no-underscore-dangle */
+export class ContentEditor {
+ constructor({ tiptapEditor, serializer }) {
+ this._tiptapEditor = tiptapEditor;
+ this._serializer = serializer;
+ }
+
+ get tiptapEditor() {
+ return this._tiptapEditor;
+ }
+
+ async setSerializedContent(serializedContent) {
+ const { _tiptapEditor: editor, _serializer: serializer } = this;
+
+ editor.commands.setContent(
+ await serializer.deserialize({ schema: editor.schema, content: serializedContent }),
+ );
+ }
+
+ getSerializedContent() {
+ const { _tiptapEditor: editor, _serializer: serializer } = this;
+
+ return serializer.serialize({ schema: editor.schema, content: editor.getJSON() });
+ }
+}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
new file mode 100644
index 00000000000..df45287e6cb
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -0,0 +1,76 @@
+import { Editor } from '@tiptap/vue-2';
+import { isFunction } from 'lodash';
+import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
+import * as Blockquote from '../extensions/blockquote';
+import * as Bold from '../extensions/bold';
+import * as BulletList from '../extensions/bullet_list';
+import * as Code from '../extensions/code';
+import * as CodeBlockHighlight from '../extensions/code_block_highlight';
+import * as Document from '../extensions/document';
+import * as Dropcursor from '../extensions/dropcursor';
+import * as Gapcursor from '../extensions/gapcursor';
+import * as HardBreak from '../extensions/hard_break';
+import * as Heading from '../extensions/heading';
+import * as History from '../extensions/history';
+import * as HorizontalRule from '../extensions/horizontal_rule';
+import * as Image from '../extensions/image';
+import * as Italic from '../extensions/italic';
+import * as Link from '../extensions/link';
+import * as ListItem from '../extensions/list_item';
+import * as OrderedList from '../extensions/ordered_list';
+import * as Paragraph from '../extensions/paragraph';
+import * as Text from '../extensions/text';
+import buildSerializerConfig from './build_serializer_config';
+import { ContentEditor } from './content_editor';
+import createMarkdownSerializer from './markdown_serializer';
+import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
+
+const builtInContentEditorExtensions = [
+ Blockquote,
+ Bold,
+ BulletList,
+ Code,
+ CodeBlockHighlight,
+ Document,
+ Dropcursor,
+ Gapcursor,
+ HardBreak,
+ Heading,
+ History,
+ HorizontalRule,
+ Image,
+ Italic,
+ Link,
+ ListItem,
+ OrderedList,
+ Paragraph,
+ Text,
+];
+
+const collectTiptapExtensions = (extensions = []) =>
+ extensions.map(({ tiptapExtension }) => tiptapExtension);
+
+const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
+ new Editor({
+ extensions: [...extensions],
+ editorProps: {
+ attributes: {
+ class: 'gl-outline-0!',
+ },
+ },
+ ...options,
+ });
+
+export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOptions } = {}) => {
+ if (!isFunction(renderMarkdown)) {
+ throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
+ }
+
+ const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+ const tiptapExtensions = collectTiptapExtensions(allExtensions).map(trackInputRulesAndShortcuts);
+ const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
+ const serializerConfig = buildSerializerConfig(allExtensions);
+ const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
+
+ return new ContentEditor({ tiptapEditor, serializer });
+};
diff --git a/app/assets/javascripts/content_editor/services/create_editor.js b/app/assets/javascripts/content_editor/services/create_editor.js
deleted file mode 100644
index 128d332b0a2..00000000000
--- a/app/assets/javascripts/content_editor/services/create_editor.js
+++ /dev/null
@@ -1,60 +0,0 @@
-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
index e3b5775e320..f121cc9affd 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -1,7 +1,4 @@
-import {
- MarkdownSerializer as ProseMirrorMarkdownSerializer,
- defaultMarkdownSerializer,
-} from 'prosemirror-markdown';
+import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
@@ -18,56 +15,46 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* 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;
+export default ({ render = () => null, serializerConfig }) => ({
+ /**
+ * 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 proseMirrorDocument = schema.nodeFromJSON(content);
+ const { nodes, marks } = serializerConfig;
+ const serializer = new ProseMirrorMarkdownSerializer(nodes, marks);
+
+ return serializer.serialize(proseMirrorDocument, {
+ tightLists: true,
+ });
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
new file mode 100644
index 00000000000..860e5372bc2
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/track_input_rules_and_shortcuts.js
@@ -0,0 +1,61 @@
+import { mapValues, omit } from 'lodash';
+import { InputRule } from 'prosemirror-inputrules';
+import { ENTER_KEY, BACKSPACE_KEY } from '~/lib/utils/keys';
+import Tracking from '~/tracking';
+import {
+ CONTENT_EDITOR_TRACKING_LABEL,
+ KEYBOARD_SHORTCUT_TRACKING_ACTION,
+ INPUT_RULE_TRACKING_ACTION,
+} from '../constants';
+
+const trackKeyboardShortcut = (contentType, commandFn, shortcut) => () => {
+ Tracking.event(undefined, KEYBOARD_SHORTCUT_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: `${contentType}.${shortcut}`,
+ });
+ return commandFn();
+};
+
+const trackInputRule = (contentType, inputRule) => {
+ return new InputRule(inputRule.match, (...args) => {
+ const result = inputRule.handler(...args);
+
+ if (result) {
+ Tracking.event(undefined, INPUT_RULE_TRACKING_ACTION, {
+ label: CONTENT_EDITOR_TRACKING_LABEL,
+ property: contentType,
+ });
+ }
+
+ return result;
+ });
+};
+
+const trackInputRulesAndShortcuts = (tiptapExtension) => {
+ return tiptapExtension.extend({
+ addKeyboardShortcuts() {
+ const shortcuts = this.parent?.() || {};
+ const { name } = this;
+
+ /**
+ * We don’t want to track keyboard shortcuts
+ * that are not deliberately executed to create
+ * new types of content
+ */
+ const withoutEnterShortcut = omit(shortcuts, [ENTER_KEY, BACKSPACE_KEY]);
+ const decorated = mapValues(withoutEnterShortcut, (commandFn, shortcut) =>
+ trackKeyboardShortcut(name, commandFn, shortcut),
+ );
+
+ return decorated;
+ },
+ addInputRules() {
+ const inputRules = this.parent?.() || [];
+ const { name } = this;
+
+ return inputRules.map((inputRule) => trackInputRule(name, inputRule));
+ },
+ });
+};
+
+export default trackInputRulesAndShortcuts;
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 000faacb7d7..1c0dab11392 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -42,7 +42,7 @@ export default class CreateMergeRequestDropdown {
this.refInput = this.wrapperEl.querySelector('.js-ref');
this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
- this.unavailableButtonSpinner = this.unavailableButton.querySelector('.spinner');
+ this.unavailableButtonSpinner = this.unavailableButton.querySelector('.gl-spinner');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
this.branchCreated = false;
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index df77d641e21..11a263015e4 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Cookies from 'js-cookie';
-import { deprecatedCreateFlash as Flash } from '~/flash';
+import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import banner from './banner.vue';
import stageCodeComponent from './stage_code_component.vue';
@@ -39,94 +39,59 @@ export default {
type: String,
required: true,
},
- store: {
- type: Object,
- required: true,
- },
- service: {
- type: Object,
- required: true,
- },
},
data() {
return {
- state: this.store.state,
- isLoading: false,
- isLoadingStage: false,
- isEmptyStage: false,
- hasError: true,
- startDate: 30,
isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
};
},
computed: {
- currentStage() {
- return this.store.currentActiveStage();
+ ...mapState([
+ 'isLoading',
+ 'isLoadingStage',
+ 'isEmptyStage',
+ 'selectedStage',
+ 'selectedStageEvents',
+ 'stages',
+ 'summary',
+ 'startDate',
+ ]),
+ displayStageEvents() {
+ const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
+ return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
+ },
+ displayNotEnoughData() {
+ const { selectedStage, isEmptyStage, isLoadingStage } = this;
+ return selectedStage && isEmptyStage && !isLoadingStage;
+ },
+ displayNoAccess() {
+ const { selectedStage } = this;
+ return selectedStage && !selectedStage.isUserAllowed;
},
- },
- created() {
- this.fetchCycleAnalyticsData();
},
methods: {
- handleError() {
- this.store.setErrorState(true);
- return new Flash(__('There was an error while fetching value stream analytics data.'));
- },
+ ...mapActions([
+ 'fetchCycleAnalyticsData',
+ 'fetchStageData',
+ 'setSelectedStage',
+ 'setDateRange',
+ ]),
handleDateSelect(startDate) {
- this.startDate = startDate;
- this.fetchCycleAnalyticsData({ startDate: this.startDate });
+ this.setDateRange({ startDate });
+ this.fetchCycleAnalyticsData();
},
- fetchCycleAnalyticsData(options) {
- const fetchOptions = options || { startDate: this.startDate };
-
- this.isLoading = true;
-
- this.service
- .fetchCycleAnalyticsData(fetchOptions)
- .then((response) => {
- this.store.setCycleAnalyticsData(response);
- this.selectDefaultStage();
- })
- .catch(() => {
- this.handleError();
- })
- .finally(() => {
- this.isLoading = false;
- });
- },
- selectDefaultStage() {
- const stage = this.state.stages[0];
- this.selectStage(stage);
+ isActiveStage(stage) {
+ return stage.slug === this.selectedStage.slug;
},
selectStage(stage) {
- if (this.isLoadingStage) return;
- if (this.currentStage === stage) return;
+ if (this.selectedStage === stage) return;
+ this.setSelectedStage(stage);
if (!stage.isUserAllowed) {
- this.store.setActiveStage(stage);
return;
}
- this.isLoadingStage = true;
- this.store.setStageEvents([], stage);
- this.store.setActiveStage(stage);
-
- this.service
- .fetchStageData({
- stage,
- startDate: this.startDate,
- projectIds: this.selectedProjectIds,
- })
- .then((response) => {
- this.isEmptyStage = !response.events.length;
- this.store.setStageEvents(response.events, stage);
- })
- .catch(() => {
- this.isEmptyStage = true;
- })
- .finally(() => {
- this.isLoadingStage = false;
- });
+ this.fetchStageData();
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
@@ -146,12 +111,13 @@ export default {
<div class="card">
<div class="card-header">{{ __('Recent Project Activity') }}</div>
<div class="d-flex justify-content-between">
- <div v-for="item in state.summary" :key="item.title" class="flex-grow text-center">
+ <div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center">
<h3 class="header">{{ item.value }}</h3>
<p class="text">{{ item.title }}</p>
</div>
<div class="flex-grow align-self-center text-center">
<div class="js-ca-dropdown dropdown inline">
+ <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
<button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
<span class="dropdown-label">
<gl-sprintf :message="$options.i18n.dropdownText">
@@ -207,11 +173,9 @@ export default {
</span>
</li>
<li class="event-header pl-3">
- <span
- v-if="currentStage && currentStage.legend"
- class="stage-name font-weight-bold"
- >{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}</span
- >
+ <span v-if="selectedStage" class="stage-name font-weight-bold">{{
+ selectedStage.legend ? __(selectedStage.legend) : __('Related Issues')
+ }}</span>
<span
class="has-tooltip"
data-placement="top"
@@ -242,19 +206,19 @@ export default {
<nav class="stage-nav">
<ul>
<stage-nav-item
- v-for="stage in state.stages"
+ v-for="stage in stages"
:key="stage.title"
:title="stage.title"
:is-user-allowed="stage.isUserAllowed"
:value="stage.value"
- :is-active="stage.active"
+ :is-active="isActiveStage(stage)"
@select="selectStage(stage)"
/>
</ul>
</nav>
<section class="stage-events overflow-auto">
<gl-loading-icon v-show="isLoadingStage" size="lg" />
- <template v-if="currentStage && !currentStage.isUserAllowed">
+ <template v-if="displayNoAccess">
<gl-empty-state
class="js-empty-state"
:title="__('You need permission.')"
@@ -263,19 +227,19 @@ export default {
/>
</template>
<template v-else>
- <template v-if="currentStage && isEmptyStage && !isLoadingStage">
+ <template v-if="displayNotEnoughData">
<gl-empty-state
class="js-empty-state"
- :description="currentStage.emptyStageText"
+ :description="selectedStage.emptyStageText"
:svg-path="noDataSvgPath"
:title="__('We don\'t have enough data to show this stage.')"
/>
</template>
- <template v-if="state.events.length && !isLoadingStage && !isEmptyStage">
+ <template v-if="displayStageEvents">
<component
- :is="currentStage.component"
- :stage="currentStage"
- :items="state.events"
+ :is="selectedStage.component"
+ :stage="selectedStage"
+ :items="selectedStageEvents"
/>
</template>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
new file mode 100644
index 00000000000..d79de207afe
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -0,0 +1 @@
+export const DEFAULT_DAYS_TO_DISPLAY = 30;
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
deleted file mode 100644
index d7fcda24352..00000000000
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default class CycleAnalyticsService {
- constructor(options) {
- this.axios = axios.create({
- baseURL: options.requestPath,
- });
- }
-
- fetchCycleAnalyticsData(options = { startDate: 30 }) {
- const { startDate, projectIds } = options;
-
- return this.axios
- .get('', {
- params: {
- 'cycle_analytics[start_date]': startDate,
- 'cycle_analytics[project_ids]': projectIds,
- },
- })
- .then((x) => x.data);
- }
-
- fetchStageData(options) {
- const { stage, startDate, projectIds } = options;
-
- return this.axios
- .get(`events/${stage.name}.json`, {
- params: {
- 'cycle_analytics[start_date]': startDate,
- 'cycle_analytics[project_ids]': projectIds,
- },
- })
- .then((x) => x.data);
- }
-}
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
deleted file mode 100644
index 24ad6ef4c88..00000000000
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ /dev/null
@@ -1,112 +0,0 @@
-/* eslint-disable no-param-reassign */
-
-import { dasherize } from '../lib/utils/text_utility';
-import { __ } from '../locale';
-import DEFAULT_EVENT_OBJECTS from './default_event_objects';
-
-const EMPTY_STAGE_TEXTS = {
- issue: __(
- 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
- ),
- plan: __(
- 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
- ),
- code: __(
- 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
- ),
- test: __(
- 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
- ),
- review: __(
- 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
- ),
- staging: __(
- 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
- ),
-};
-
-export default {
- state: {
- summary: '',
- stats: '',
- analytics: '',
- events: [],
- stages: [],
- },
- setCycleAnalyticsData(data) {
- this.state = Object.assign(this.state, this.decorateData(data));
- },
- decorateData(data) {
- const newData = {};
-
- newData.stages = data.stats || [];
- newData.summary = data.summary || [];
-
- newData.summary.forEach((item) => {
- item.value = item.value || '-';
- });
-
- newData.stages.forEach((item) => {
- const stageSlug = dasherize(item.name.toLowerCase());
- item.active = false;
- item.isUserAllowed = data.permissions[stageSlug];
- item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
- item.component = `stage-${stageSlug}-component`;
- item.slug = stageSlug;
- });
- newData.analytics = data;
- return newData;
- },
- setLoadingState(state) {
- this.state.isLoading = state;
- },
- setErrorState(state) {
- this.state.hasError = state;
- },
- deactivateAllStages() {
- this.state.stages.forEach((stage) => {
- stage.active = false;
- });
- },
- setActiveStage(stage) {
- this.deactivateAllStages();
- stage.active = true;
- },
- setStageEvents(events, stage) {
- this.state.events = this.decorateEvents(events, stage);
- },
- decorateEvents(events, stage) {
- const newEvents = [];
-
- events.forEach((item) => {
- if (!item) return;
-
- const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item };
-
- eventItem.totalTime = eventItem.total_time;
-
- if (eventItem.author) {
- eventItem.author.webUrl = eventItem.author.web_url;
- eventItem.author.avatarUrl = eventItem.author.avatar_url;
- }
-
- if (eventItem.created_at) eventItem.createdAt = eventItem.created_at;
- if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha;
- if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url;
-
- delete eventItem.author.web_url;
- delete eventItem.author.avatar_url;
- delete eventItem.total_time;
- delete eventItem.created_at;
- delete eventItem.short_sha;
- delete eventItem.commit_url;
-
- newEvents.push(eventItem);
- });
-
- return newEvents;
- },
- currentActiveStage() {
- return this.state.stages.find((stage) => stage.active);
- },
-};
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 42d6700fae1..00192cc61f8 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -1,31 +1,29 @@
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import CycleAnalytics from './components/base.vue';
-import CycleAnalyticsService from './cycle_analytics_service';
-import CycleAnalyticsStore from './cycle_analytics_store';
+import createStore from './store';
Vue.use(Translate);
-const createCycleAnalyticsService = (requestPath) =>
- new CycleAnalyticsService({
- requestPath,
- });
-
export default () => {
+ const store = createStore();
const el = document.querySelector('#js-cycle-analytics');
- const { noAccessSvgPath, noDataSvgPath } = el.dataset;
+ const { noAccessSvgPath, noDataSvgPath, requestPath } = el.dataset;
+
+ store.dispatch('initializeVsa', {
+ requestPath,
+ });
// eslint-disable-next-line no-new
new Vue({
el,
name: 'CycleAnalytics',
+ store,
render: (createElement) =>
createElement(CycleAnalytics, {
props: {
noDataSvgPath,
noAccessSvgPath,
- store: CycleAnalyticsStore,
- service: createCycleAnalyticsService(el.dataset.requestPath),
},
}),
});
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
new file mode 100644
index 00000000000..fe3c6d6b3ba
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -0,0 +1,51 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
+import * as types from './mutation_types';
+
+export const fetchCycleAnalyticsData = ({
+ state: { requestPath, startDate },
+ dispatch,
+ commit,
+}) => {
+ commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
+
+ return axios
+ .get(requestPath, {
+ params: { 'cycle_analytics[start_date]': startDate },
+ })
+ .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data))
+ .then(() => dispatch('setSelectedStage'))
+ .then(() => dispatch('fetchStageData'))
+ .catch(() => {
+ commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR);
+ createFlash({
+ message: __('There was an error while fetching value stream analytics data.'),
+ });
+ });
+};
+
+export const fetchStageData = ({ state: { requestPath, selectedStage, startDate }, commit }) => {
+ commit(types.REQUEST_STAGE_DATA);
+
+ return axios
+ .get(`${requestPath}/events/${selectedStage.name}.json`, {
+ params: { 'cycle_analytics[start_date]': startDate },
+ })
+ .then(({ data }) => commit(types.RECEIVE_STAGE_DATA_SUCCESS, data))
+ .catch(() => commit(types.RECEIVE_STAGE_DATA_ERROR));
+};
+
+export const setSelectedStage = ({ commit, state: { stages } }, selectedStage = null) => {
+ const stage = selectedStage || stages[0];
+ commit(types.SET_SELECTED_STAGE, stage);
+};
+
+export const setDateRange = ({ commit }, { startDate = DEFAULT_DAYS_TO_DISPLAY }) =>
+ commit(types.SET_DATE_RANGE, { startDate });
+
+export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
+ commit(types.INITIALIZE_VSA, initialData);
+ return dispatch('fetchCycleAnalyticsData');
+};
diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/cycle_analytics/store/index.js
new file mode 100644
index 00000000000..ab47538dcf5
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/store/index.js
@@ -0,0 +1,21 @@
+/**
+ * While we are in the process implementing group level features at the project level
+ * we will use a simplified vuex store for the project level, eventually this can be
+ * replaced with the store at ee/app/assets/javascripts/analytics/cycle_analytics/store/index.js
+ * once we have enough of the same features implemented across the project and group level
+ */
+
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state,
+ });
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
new file mode 100644
index 00000000000..00aae49ae9f
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -0,0 +1,12 @@
+export const INITIALIZE_VSA = 'INITIALIZE_VSA';
+
+export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
+export const SET_DATE_RANGE = 'SET_DATE_RANGE';
+
+export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
+export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
+export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR';
+
+export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
+export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
+export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
new file mode 100644
index 00000000000..8fd5c78339a
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -0,0 +1,52 @@
+import { decorateData, decorateEvents } from '../utils';
+import * as types from './mutation_types';
+
+export default {
+ [types.INITIALIZE_VSA](state, { requestPath }) {
+ state.requestPath = requestPath;
+ },
+ [types.SET_SELECTED_STAGE](state, stage) {
+ state.isLoadingStage = true;
+ state.selectedStage = stage;
+ state.isLoadingStage = false;
+ },
+ [types.SET_DATE_RANGE](state, { startDate }) {
+ state.startDate = startDate;
+ },
+ [types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
+ state.isLoading = true;
+ state.stages = [];
+ state.hasError = false;
+ },
+ [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
+ state.isLoading = false;
+ const { stages, summary } = decorateData(data);
+ state.stages = stages;
+ state.summary = summary;
+ state.hasError = false;
+ },
+ [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
+ state.isLoading = false;
+ state.stages = [];
+ state.hasError = true;
+ },
+ [types.REQUEST_STAGE_DATA](state) {
+ state.isLoadingStage = true;
+ state.isEmptyStage = false;
+ state.selectedStageEvents = [];
+ state.hasError = false;
+ },
+ [types.RECEIVE_STAGE_DATA_SUCCESS](state, { events = [] }) {
+ const { selectedStage } = state;
+ state.isLoadingStage = false;
+ state.isEmptyStage = !events.length;
+ state.selectedStageEvents = decorateEvents(events, selectedStage);
+ state.hasError = false;
+ },
+ [types.RECEIVE_STAGE_DATA_ERROR](state) {
+ state.isLoadingStage = false;
+ state.isEmptyStage = true;
+ state.selectedStageEvents = [];
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
new file mode 100644
index 00000000000..5db4e1878a9
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -0,0 +1,17 @@
+import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
+
+export default () => ({
+ requestPath: '',
+ startDate: DEFAULT_DAYS_TO_DISPLAY,
+ stages: [],
+ summary: [],
+ analytics: [],
+ stats: [],
+ selectedStage: {},
+ selectedStageEvents: [],
+ medians: {},
+ hasError: false,
+ isLoading: false,
+ isLoadingStage: false,
+ isEmptyStage: false,
+});
diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/cycle_analytics/utils.js
new file mode 100644
index 00000000000..3afe4b021be
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/utils.js
@@ -0,0 +1,63 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { dasherize } from '~/lib/utils/text_utility';
+import { __ } from '../locale';
+import DEFAULT_EVENT_OBJECTS from './default_event_objects';
+
+const EMPTY_STAGE_TEXTS = {
+ issue: __(
+ 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
+ ),
+ plan: __(
+ 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
+ ),
+ code: __(
+ 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
+ ),
+ test: __(
+ 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
+ ),
+ review: __(
+ 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
+ ),
+ staging: __(
+ 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
+ ),
+};
+
+/**
+ * These `decorate` methods will be removed when me migrate to the
+ * new table layout https://gitlab.com/gitlab-org/gitlab/-/issues/326704
+ */
+const mapToEvent = (event, stage) => {
+ return convertObjectPropsToCamelCase(
+ {
+ ...DEFAULT_EVENT_OBJECTS[stage.slug],
+ ...event,
+ },
+ { deep: true },
+ );
+};
+
+export const decorateEvents = (events, stage) => events.map((event) => mapToEvent(event, stage));
+
+const mapToStage = (permissions, item) => {
+ const slug = dasherize(item.name.toLowerCase());
+ return {
+ ...item,
+ slug,
+ active: false,
+ isUserAllowed: permissions[slug],
+ emptyStageText: EMPTY_STAGE_TEXTS[slug],
+ component: `stage-${slug}-component`,
+ };
+};
+
+const mapToSummary = ({ value, ...rest }) => ({ ...rest, value: value || '-' });
+
+export const decorateData = (data = {}) => {
+ const { permissions, stats, summary } = data;
+ return {
+ stages: stats?.map((item) => mapToStage(permissions, item)) || [],
+ summary: summary?.map((item) => mapToSummary(item)) || [],
+ };
+};
diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js
index e62000c007c..fdd1ea6e32e 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutations.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutations.js
@@ -7,7 +7,7 @@ const formatTimezoneName = (freezePeriod, timezoneList) =>
cron_timezone: {
formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)
?.name,
- identifier: freezePeriod.cronTimezone,
+ identifier: freezePeriod.cron_timezone,
},
});
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue
index af7c391ab70..7bc1eb5d652 100644
--- a/app/assets/javascripts/deploy_keys/components/action_btn.vue
+++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue
@@ -1,10 +1,10 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import eventHub from '../eventhub';
export default {
components: {
- GlLoadingIcon,
+ GlButton,
},
props: {
deployKey: {
@@ -15,10 +15,20 @@ export default {
type: String,
required: true,
},
- btnCssClass: {
+ category: {
type: String,
required: false,
- default: 'btn-default',
+ default: 'tertiary',
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: 'default',
+ },
+ icon: {
+ type: String,
+ required: false,
+ default: '',
},
},
data() {
@@ -39,13 +49,14 @@ export default {
</script>
<template>
- <button
- :class="[{ disabled: isLoading }, btnCssClass]"
- :disabled="isLoading"
+ <gl-button
+ :category="category"
+ :variant="variant"
+ :icon="icon"
+ :loading="isLoading"
class="btn"
@click="doAction"
>
<slot></slot>
- <gl-loading-icon v-if="isLoading" :inline="true" />
- </button>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue
index 425cca13ae8..02c57164f47 100644
--- a/app/assets/javascripts/deploy_keys/components/app.vue
+++ b/app/assets/javascripts/deploy_keys/components/app.vue
@@ -6,10 +6,12 @@ import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import eventHub from '../eventhub';
import DeployKeysService from '../service';
import DeployKeysStore from '../store';
+import ConfirmModal from './confirm_modal.vue';
import KeysPanel from './keys_panel.vue';
export default {
components: {
+ ConfirmModal,
KeysPanel,
NavigationTabs,
GlLoadingIcon,
@@ -30,6 +32,9 @@ export default {
currentTab: 'enabled_keys',
isLoading: false,
store: new DeployKeysStore(),
+ removeKey: () => {},
+ cancel: () => {},
+ confirmModalVisible: false,
};
},
scopes: {
@@ -61,16 +66,16 @@ export default {
this.service = new DeployKeysService(this.endpoint);
eventHub.$on('enable.key', this.enableKey);
- eventHub.$on('remove.key', this.disableKey);
- eventHub.$on('disable.key', this.disableKey);
+ eventHub.$on('remove.key', this.confirmRemoveKey);
+ eventHub.$on('disable.key', this.confirmRemoveKey);
},
mounted() {
this.fetchKeys();
},
beforeDestroy() {
eventHub.$off('enable.key', this.enableKey);
- eventHub.$off('remove.key', this.disableKey);
- eventHub.$off('disable.key', this.disableKey);
+ eventHub.$off('remove.key', this.confirmRemoveKey);
+ eventHub.$off('disable.key', this.confirmRemoveKey);
},
methods: {
onChangeTab(tab) {
@@ -97,19 +102,20 @@ export default {
.then(this.fetchKeys)
.catch(() => new Flash(s__('DeployKeys|Error enabling deploy key')));
},
- disableKey(deployKey, callback) {
- if (
- // eslint-disable-next-line no-alert
- window.confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?'))
- ) {
+ confirmRemoveKey(deployKey, callback) {
+ const hideModal = () => {
+ this.confirmModalVisible = false;
+ callback?.();
+ };
+ this.removeKey = () => {
this.service
.disableKey(deployKey.id)
.then(this.fetchKeys)
- .then(callback)
+ .then(hideModal)
.catch(() => new Flash(s__('DeployKeys|Error removing deploy key')));
- } else {
- callback();
- }
+ };
+ this.cancel = hideModal;
+ this.confirmModalVisible = true;
},
},
};
@@ -117,6 +123,7 @@ export default {
<template>
<div class="gl-mb-3 deploy-keys">
+ <confirm-modal :visible="confirmModalVisible" @remove="removeKey" @cancel="cancel" />
<gl-loading-icon
v-if="isLoading && !hasKeys"
:label="s__('DeployKeys|Loading deploy keys')"
@@ -124,8 +131,12 @@ export default {
/>
<template v-else-if="hasKeys">
<div class="top-area scrolling-tabs-container inner-page-scroll-tabs">
- <div class="fade-left"><gl-icon name="chevron-lg-left" :size="12" /></div>
- <div class="fade-right"><gl-icon name="chevron-lg-right" :size="12" /></div>
+ <div class="fade-left">
+ <gl-icon name="chevron-lg-left" :size="12" />
+ </div>
+ <div class="fade-right">
+ <gl-icon name="chevron-lg-right" :size="12" />
+ </div>
<navigation-tabs :tabs="tabs" scope="deployKeys" @onChangeTab="onChangeTab" />
</div>
@@ -134,7 +145,7 @@ export default {
:keys="keys[currentTab]"
:store="store"
:endpoint="endpoint"
- data-qa-selector="project_deploy_keys"
+ data-qa-selector="project_deploy_keys_container"
/>
</template>
</div>
diff --git a/app/assets/javascripts/deploy_keys/components/confirm_modal.vue b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
new file mode 100644
index 00000000000..1932435c42a
--- /dev/null
+++ b/app/assets/javascripts/deploy_keys/components/confirm_modal.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ visible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ body: __(
+ 'Are you sure you want to remove this deploy key? If anything is still using this key, it will stop working.',
+ ),
+ },
+ modalOptions: {
+ title: __('Do you want to remove this deploy key?'),
+ actionPrimary: {
+ text: __('Remove deploy key'),
+ attributes: [{ variant: 'danger' }],
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: [{ category: 'tertiary' }],
+ },
+ static: true,
+ modalId: 'confirm-remove-deploy-key',
+ },
+};
+</script>
+<template>
+ <gl-modal
+ v-bind="$options.modalOptions"
+ :visible="visible"
+ @primary="$emit('remove')"
+ @secondary="$emit('cancel')"
+ @hidden="$emit('cancel')"
+ >
+ {{ $options.i18n.body }}
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index e70ca18bb71..8a7d3430063 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlLink, GlTooltipDirective, GlButton } from '@gitlab/ui';
import { head, tail } from 'lodash';
import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -9,7 +9,9 @@ import actionBtn from './action_btn.vue';
export default {
components: {
actionBtn,
+ GlButton,
GlIcon,
+ GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -111,9 +113,9 @@ export default {
<div class="gl-responsive-table-row deploy-key">
<div class="table-section section-40">
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
- <div class="table-mobile-content qa-key">
- <strong class="title qa-key-title"> {{ deployKey.title }} </strong>
- <div class="fingerprint" data-qa-selector="key_md5_fingerprint">
+ <div class="table-mobile-content" data-qa-selector="key_container">
+ <strong class="title" data-qa-selector="key_title_content"> {{ deployKey.title }} </strong>
+ <div class="fingerprint" data-qa-selector="key_md5_fingerprint_content">
{{ __('MD5') }}:{{ deployKey.fingerprint }}
</div>
<div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div>
@@ -123,15 +125,15 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
- <a
+ <gl-link
v-gl-tooltip
:title="projectTooltipTitle(firstProject)"
class="label deploy-project-label"
>
<span> {{ firstProject.project.full_name }} </span>
<gl-icon :name="firstProject.can_push ? 'lock-open' : 'lock'" />
- </a>
- <a
+ </gl-link>
+ <gl-link
v-if="isExpandable"
v-gl-tooltip
:title="restProjectsTooltip"
@@ -139,8 +141,8 @@ export default {
@click="toggleExpanded"
>
<span>{{ restProjectsLabel }}</span>
- </a>
- <a
+ </gl-link>
+ <gl-link
v-for="deployKeysProject in restProjects"
v-else-if="isExpanded"
:key="deployKeysProject.project.full_path"
@@ -151,7 +153,7 @@ export default {
>
<span> {{ deployKeysProject.project.full_name }} </span>
<gl-icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'" />
- </a>
+ </gl-link>
</template>
<span v-else class="text-secondary">{{ __('None') }}</span>
</div>
@@ -166,41 +168,43 @@ export default {
</div>
<div class="table-section section-15 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
- <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable">
+ <action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
{{ __('Enable') }}
</action-btn>
- <a
+ <gl-button
v-if="deployKey.can_edit"
v-gl-tooltip
:href="editDeployKeyPath"
:title="__('Edit')"
- class="btn btn-default text-secondary"
+ :aria-label="__('Edit')"
data-container="body"
- >
- <gl-icon name="pencil" />
- </a>
+ icon="pencil"
+ category="secondary"
+ />
<action-btn
v-if="isRemovable"
v-gl-tooltip
:deploy-key="deployKey"
:title="__('Remove')"
- btn-css-class="btn-danger"
+ :aria-label="__('Remove')"
+ category="primary"
+ variant="danger"
+ icon="remove"
type="remove"
data-container="body"
- >
- <gl-icon name="remove" />
- </action-btn>
+ />
<action-btn
v-else-if="isEnabled"
v-gl-tooltip
:deploy-key="deployKey"
:title="__('Disable')"
- btn-css-class="btn-warning"
+ :aria-label="__('Disable')"
type="disable"
data-container="body"
- >
- <gl-icon name="cancel" />
- </action-btn>
+ icon="cancel"
+ category="primary"
+ variant="danger"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index fb25d3618ab..336ce714a05 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -115,12 +115,13 @@ export default {
</template>
</markdown-field>
<slot name="resolve-checkbox"></slot>
- <div class="note-form-actions gl-display-flex gl-justify-content-space-between">
+ <div class="note-form-actions gl-display-flex">
<gl-button
ref="submitButton"
:disabled="!hasValue || isSaving"
+ class="gl-mr-3 gl-w-auto!"
category="primary"
- variant="success"
+ variant="confirm"
type="submit"
data-track-event="click_button"
data-qa-selector="save_comment_button"
@@ -128,9 +129,14 @@ export default {
>
{{ buttonText }}
</gl-button>
- <gl-button ref="cancelButton" variant="default" category="primary" @click="cancelComment">{{
- __('Cancel')
- }}</gl-button>
+ <gl-button
+ ref="cancelButton"
+ class="gl-w-auto!"
+ variant="default"
+ category="primary"
+ @click="cancelComment"
+ >{{ __('Cancel') }}</gl-button
+ >
</div>
<gl-modal
ref="cancelCommentModal"
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 04d80dc0069..ad557f64ce4 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -333,7 +333,7 @@ export default {
ghostClass: 'gl-visibility-hidden',
},
i18n: {
- dropzoneDescriptionText: __('Drop or %{linkStart}upload%{linkEnd} designs to attach'),
+ dropzoneDescriptionText: __('Drag your designs here or %{linkStart}click to upload%{linkEnd}.'),
},
};
</script>
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 7c610968209..6a3f5993a22 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -3,6 +3,8 @@ 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 { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
+import api from '~/api';
import {
keysFor,
MR_PREVIOUS_FILE_IN_DIFF,
@@ -16,7 +18,6 @@ import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '../../notes/event_hub';
import {
@@ -30,6 +31,15 @@ import {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
+ INLINE_DIFF_VIEW_TYPE,
+ TRACKING_DIFF_VIEW_INLINE,
+ TRACKING_DIFF_VIEW_PARALLEL,
+ TRACKING_FILE_BROWSER_TREE,
+ TRACKING_FILE_BROWSER_LIST,
+ TRACKING_WHITESPACE_SHOW,
+ TRACKING_WHITESPACE_HIDE,
+ TRACKING_SINGLE_FILE_MODE,
+ TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
import { reviewStatuses } from '../utils/file_reviews';
@@ -59,8 +69,9 @@ export default {
PanelResizer,
GlPagination,
GlSprintf,
+ DynamicScroller,
+ DynamicScrollerItem,
},
- mixins: [glFeatureFlagsMixin()],
alerts: {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
@@ -183,8 +194,15 @@ export default {
'hasConflicts',
'viewDiffsFileByFile',
'mrReviews',
+ 'renderTreeList',
+ 'showWhitespace',
+ ]),
+ ...mapGetters('diffs', [
+ 'whichCollapsedTypes',
+ 'isParallelView',
+ 'currentDiffIndex',
+ 'isVirtualScrollingEnabled',
]),
- ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters('batchComments', ['draftsCount']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
@@ -305,6 +323,32 @@ export default {
if (id && id.indexOf('#note') !== 0) {
this.setHighlightedRow(id.split('diff-content').pop().slice(1));
}
+
+ if (window.gon?.features?.diffSettingsUsageData) {
+ if (this.renderTreeList) {
+ api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_LIST);
+ }
+
+ if (this.diffViewType === INLINE_DIFF_VIEW_TYPE) {
+ api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_INLINE);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_PARALLEL);
+ }
+
+ if (this.showWhitespace) {
+ api.trackRedisHllUserEvent(TRACKING_WHITESPACE_SHOW);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_WHITESPACE_HIDE);
+ }
+
+ if (this.viewDiffsFileByFile) {
+ api.trackRedisHllUserEvent(TRACKING_SINGLE_FILE_MODE);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_MULTIPLE_FILES_MODE);
+ }
+ }
},
beforeCreate() {
diffsApp.instrument();
@@ -523,17 +567,41 @@ export default {
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
<div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
<template v-else-if="renderDiffFiles">
- <diff-file
- v-for="(file, index) in diffs"
- :key="file.newPath"
- :file="file"
- :reviewed="fileReviews[file.id]"
- :is-first-file="index === 0"
- :is-last-file="index === diffFilesLength - 1"
- :help-page-path="helpPagePath"
- :can-current-user-fork="canCurrentUserFork"
- :view-diffs-file-by-file="viewDiffsFileByFile"
- />
+ <dynamic-scroller
+ v-if="isVirtualScrollingEnabled"
+ :items="diffs"
+ :min-item-size="70"
+ :buffer="1000"
+ :use-transform="false"
+ page-mode
+ >
+ <template #default="{ item, index, active }">
+ <dynamic-scroller-item :item="item" :active="active">
+ <diff-file
+ :file="item"
+ :reviewed="fileReviews[item.id]"
+ :is-first-file="index === 0"
+ :is-last-file="index === diffFilesLength - 1"
+ :help-page-path="helpPagePath"
+ :can-current-user-fork="canCurrentUserFork"
+ :view-diffs-file-by-file="viewDiffsFileByFile"
+ />
+ </dynamic-scroller-item>
+ </template>
+ </dynamic-scroller>
+ <template v-else>
+ <diff-file
+ v-for="(file, index) in diffs"
+ :key="file.new_path"
+ :file="file"
+ :reviewed="fileReviews[file.id]"
+ :is-first-file="index === 0"
+ :is-last-file="index === diffFilesLength - 1"
+ :help-page-path="helpPagePath"
+ :can-current-user-fork="canCurrentUserFork"
+ :view-diffs-file-by-file="viewDiffsFileByFile"
+ />
+ </template>
<div
v-if="showFileByFileNavigation"
data-testid="file-by-file-navigation"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index bc0f2fb0b69..820c64a9502 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -138,7 +138,7 @@ export default {
/>
</div>
<div class="commit-detail flex-list">
- <div class="commit-content qa-commit-content">
+ <div class="commit-content" data-qa-selector="commit_content">
<a
:href="commit.commit_url"
class="commit-row-message item-title"
@@ -173,7 +173,7 @@ export default {
<pre
v-if="commit.description_html"
:class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
- class="commit-row-description gl-mb-3 text-dark"
+ class="commit-row-description gl-mb-3 gl-text-body"
v-html="commitDescription"
></pre>
</div>
diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
index 2c249f71091..6c5973b7c28 100644
--- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
+++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
@@ -1,11 +1,12 @@
<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
TimeAgo,
},
props: {
@@ -24,34 +25,38 @@ export default {
<template>
<gl-dropdown :text="selectedVersionName" data-qa-selector="dropdown_content">
- <gl-dropdown-item
- v-for="version in versions"
- :key="version.id"
- :class="{
- 'is-active': version.selected,
- }"
- :is-check-item="true"
- :is-checked="version.selected"
- :href="version.href"
- >
- <div>
- <strong>
- {{ version.versionName }}
- <template v-if="version.isHead">{{ s__('DiffsCompareBaseBranch|(HEAD)') }}</template>
- <template v-else-if="version.isBase">{{ s__('DiffsCompareBaseBranch|(base)') }}</template>
- </strong>
- </div>
- <div>
- <small class="commit-sha"> {{ version.short_commit_sha }} </small>
- </div>
- <div>
- <small>
- <template v-if="version.commitsText">
- {{ version.commitsText }}
- </template>
- <time-ago v-if="version.created_at" :time="version.created_at" class="js-timeago" />
- </small>
- </div>
- </gl-dropdown-item>
+ <template v-for="version in versions">
+ <gl-dropdown-divider v-if="version.addDivider" :key="version.id" />
+ <gl-dropdown-item
+ :key="version.id"
+ :class="{
+ 'is-active': version.selected,
+ }"
+ :is-check-item="true"
+ :is-checked="version.selected"
+ :href="version.href"
+ >
+ <div>
+ <strong>
+ {{ version.versionName }}
+ <template v-if="version.isHead">{{ s__('DiffsCompareBaseBranch|(HEAD)') }}</template>
+ <template v-else-if="version.isBase">{{
+ s__('DiffsCompareBaseBranch|(base)')
+ }}</template>
+ </strong>
+ </div>
+ <div>
+ <small class="commit-sha"> {{ version.short_commit_sha }} </small>
+ </div>
+ <div>
+ <small>
+ <template v-if="version.commitsText">
+ {{ version.commitsText }}
+ </template>
+ <time-ago v-if="version.created_at" :time="version.created_at" class="js-timeago" />
+ </small>
+ </div>
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 7526c5347f7..e2a1f7236c5 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -142,7 +142,7 @@ export default {
</gl-button-group>
</div>
<gl-sprintf
- v-else-if="hasSourceVersions"
+ v-else-if="!commit && hasSourceVersions"
class="d-flex align-items-center compare-versions-container"
:message="s__('MergeRequest|Compare %{target} and %{source}')"
>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 663d2bb3cf8..283dbc6031c 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -49,9 +49,7 @@ export default {
},
},
computed: {
- ...mapState({
- projectPath: (state) => state.diffs.projectPath,
- }),
+ ...mapState('diffs', ['projectPath']),
...mapGetters('diffs', [
'isInlineView',
'isParallelView',
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index bdbc13a38c4..ce867dbb9e0 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -5,6 +5,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { hasDiff } from '~/helpers/diffs_helper';
import { diffViewerErrors } from '~/ide/constants';
+import { scrollToElement } from '~/lib/utils/common_utils';
import { sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '../../notes/event_hub';
@@ -82,7 +83,7 @@ export default {
computed: {
...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']),
...mapGetters(['isNotesFetched']),
- ...mapGetters('diffs', ['getDiffFileDiscussions']),
+ ...mapGetters('diffs', ['getDiffFileDiscussions', 'isVirtualScrollingEnabled']),
viewBlobHref() {
return escape(this.file.view_path);
},
@@ -148,10 +149,8 @@ export default {
return loggedIn && featureOn;
},
- hasCodequalityChanges() {
- return (
- this.codequalityDiff?.files && this.codequalityDiff?.files[this.file.file_path]?.length > 0
- );
+ codequalityDiffForFile() {
+ return this.codequalityDiff?.files?.[this.file.file_path] || [];
},
},
watch: {
@@ -235,15 +234,20 @@ export default {
eventHub.$emit(event);
});
},
- handleToggle() {
- const currentCollapsedFlag = this.isCollapsed;
+ handleToggle({ viaUserInteraction = false } = {}) {
+ const collapsingNow = !this.isCollapsed;
+ const contentElement = this.$el.querySelector(`#diff-content-${this.file.file_hash}`);
this.setFileCollapsedByUser({
filePath: this.file.file_path,
- collapsed: !currentCollapsedFlag,
+ collapsed: collapsingNow,
});
- if (!this.hasDiff && currentCollapsedFlag) {
+ if (collapsingNow && viaUserInteraction && contentElement) {
+ scrollToElement(contentElement, { duration: 1 });
+ }
+
+ if (!this.hasDiff && !collapsingNow) {
this.requestDiff();
}
},
@@ -286,6 +290,7 @@ export default {
'is-active': currentDiffFileId === file.file_hash,
'comments-disabled': Boolean(file.brokenSymlink),
'has-body': showBody,
+ 'is-virtual-scrolling': isVirtualScrollingEnabled,
}"
:data-path="file.new_path"
class="diff-file file-holder gl-border-none"
@@ -299,10 +304,10 @@ export default {
:add-merge-request-buttons="true"
:view-diffs-file-by-file="viewDiffsFileByFile"
:show-local-file-reviews="showLocalFileReviews"
- :has-codequality-changes="hasCodequalityChanges"
+ :codequality-diff="codequalityDiffForFile"
class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100"
:class="hasBodyClasses.header"
- @toggleFile="handleToggle"
+ @toggleFile="handleToggle({ viaUserInteraction: true })"
@showForkMessage="showForkMessage"
/>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 3b4e21ab61b..676c9a3c7bc 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -96,10 +96,10 @@ export default {
required: false,
default: false,
},
- hasCodequalityChanges: {
- type: Boolean,
+ codequalityDiff: {
+ type: Array,
required: false,
- default: false,
+ default: () => [],
},
},
data() {
@@ -333,7 +333,12 @@ export default {
data-track-property="diff_copy_file"
/>
- <code-quality-badge v-if="hasCodequalityChanges" class="gl-mr-2" />
+ <code-quality-badge
+ v-if="codequalityDiff.length"
+ :file-name="filePath"
+ :codequality-diff="codequalityDiff"
+ class="gl-mr-2"
+ />
<small v-if="isModeChanged" ref="fileMode" class="mr-1">
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
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 51da1966630..c907b5dffaf 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -207,14 +207,14 @@ export default {
</div>
<note-form
ref="noteForm"
- :is-editing="true"
+ :is-editing="false"
:line-code="line.line_code"
:line="line"
:lines="commentLines"
:help-page-path="helpPagePath"
:diff-file="diffFile"
:show-suggest-popover="showSuggestPopover"
- save-button-title="Comment"
+ :save-button-title="__('Comment')"
class="diff-comment-form gl-mt-3"
@handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 8d398a2ded4..d4a1a9e0e46 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -204,27 +204,33 @@ export default {
<template v-if="line.left && line.left.type !== $options.CONFLICT_MARKER">
<div
:class="classNameMapCellLeft"
- data-testid="leftLineNumber"
+ data-testid="left-line-number"
class="diff-td diff-line-num"
+ data-qa-selector="new_diff_line_link"
>
<template v-if="!isLeftConflictMarker">
<span
v-if="shouldRenderCommentButton && !line.hasDiscussionsLeft"
v-gl-tooltip
- data-testid="leftCommentButton"
class="add-diff-note tooltip-wrapper"
:title="addCommentTooltipLeft"
>
- <button
- :draggable="glFeatures.dragCommentSelection"
+ <div
+ data-testid="left-comment-button"
+ role="button"
+ tabindex="0"
+ :draggable="!line.left.commentsDisabled && glFeatures.dragCommentSelection"
type="button"
- class="add-diff-note unified-diff-components-diff-note-button 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"
data-qa-selector="diff_comment_button"
:class="{ 'gl-cursor-grab': dragging }"
:disabled="line.left.commentsDisabled"
- @click="handleCommentButton(line.left)"
- @dragstart="onDragStart({ ...line.left, index })"
- ></button>
+ :aria-disabled="line.left.commentsDisabled"
+ @click="!line.left.commentsDisabled && handleCommentButton(line.left)"
+ @keydown.enter="!line.left.commentsDisabled && handleCommentButton(line.left)"
+ @keydown.space="!line.left.commentsDisabled && handleCommentButton(line.left)"
+ @dragstart="!line.left.commentsDisabled && onDragStart({ ...line.left, index })"
+ ></div>
</span>
</template>
<a
@@ -238,7 +244,7 @@ export default {
v-if="line.hasDiscussionsLeft"
:discussions="line.left.discussions"
:discussions-expanded="line.left.discussionsExpanded"
- data-testid="leftDiscussions"
+ data-testid="left-discussions"
@toggleLineDiscussions="
toggleLineDiscussions({
lineCode: line.left.line_code,
@@ -268,7 +274,7 @@ export default {
:key="line.left.line_code"
:class="[parallelViewLeftLineType, { parallel: !inline }]"
class="diff-td line_content with-coverage left-side"
- data-testid="leftContent"
+ data-testid="left-content"
@mousedown="handleParallelLineMouseDown"
>
<strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong>
@@ -277,7 +283,7 @@ export default {
</template>
<template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)">
<div
- data-testid="leftEmptyCell"
+ data-testid="left-empty-cell"
class="diff-td diff-line-num old_line empty-cell"
:class="emptyCellLeftClassMap"
>
@@ -313,19 +319,24 @@ export default {
<span
v-if="shouldRenderCommentButton && !line.hasDiscussionsRight"
v-gl-tooltip
- data-testid="rightCommentButton"
class="add-diff-note tooltip-wrapper"
:title="addCommentTooltipRight"
>
- <button
- :draggable="glFeatures.dragCommentSelection"
+ <div
+ data-testid="right-comment-button"
+ role="button"
+ tabindex="0"
+ :draggable="!line.right.commentsDisabled && glFeatures.dragCommentSelection"
type="button"
- class="add-diff-note unified-diff-components-diff-note-button 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"
:class="{ 'gl-cursor-grab': dragging }"
:disabled="line.right.commentsDisabled"
- @click="handleCommentButton(line.right)"
- @dragstart="onDragStart({ ...line.right, index })"
- ></button>
+ :aria-disabled="line.right.commentsDisabled"
+ @click="!line.right.commentsDisabled && handleCommentButton(line.right)"
+ @keydown.enter="!line.right.commentsDisabled && handleCommentButton(line.right)"
+ @keydown.space="!line.right.commentsDisabled && handleCommentButton(line.right)"
+ @dragstart="!line.right.commentsDisabled && onDragStart({ ...line.right, index })"
+ ></div>
</span>
</template>
<a
@@ -339,7 +350,7 @@ export default {
v-if="line.hasDiscussionsRight"
:discussions="line.right.discussions"
:discussions-expanded="line.right.discussionsExpanded"
- data-testid="rightDiscussions"
+ data-testid="right-discussions"
@toggleLineDiscussions="
toggleLineDiscussions({
lineCode: line.right.line_code,
@@ -381,7 +392,7 @@ export default {
</template>
<template v-else>
<div
- data-testid="rightEmptyCell"
+ data-testid="right-empty-cell"
class="diff-td diff-line-num old_line empty-cell"
:class="emptyCellRightClassMap"
></div>
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 25403b1547e..f903fef72b7 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -177,7 +177,6 @@ export default {
<a
v-if="line.new_line"
ref="lineNumberRefNew"
- data-qa-selector="new_diff_line_link"
:data-linenumber="line.new_line"
:href="line.lineHref"
@click="setHighlightedRow(line.lineCode)"
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 96946d0fd88..2d33926c8aa 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -193,7 +193,7 @@ export default {
v-show="shouldShowCommentButtonLeft"
ref="addDiffNoteButtonLeft"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note note-button js-add-diff-note-button"
:disabled="line.left.commentsDisabled"
:aria-label="addCommentTooltipLeft"
@click="handleCommentButton(line.left)"
@@ -251,7 +251,7 @@ export default {
v-show="shouldShowCommentButtonRight"
ref="addDiffNoteButtonRight"
type="button"
- class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ class="add-diff-note note-button js-add-diff-note-button"
:disabled="line.right.commentsDisabled"
:aria-label="addCommentTooltipRight"
@click="handleCommentButton(line.right)"
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 0163f508fea..f0e15983336 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -114,3 +114,20 @@ export const CONFLICT_THEIR = 'conflict_their';
export const CONFLICT_MARKER = 'conflict_marker';
export const CONFLICT_MARKER_OUR = 'conflict_marker_our';
export const CONFLICT_MARKER_THEIR = 'conflict_marker_their';
+
+// Tracking events
+export const TRACKING_CLICK_DIFF_VIEW_SETTING = 'i_code_review_click_diff_view_setting';
+export const TRACKING_DIFF_VIEW_INLINE = 'i_code_review_diff_view_inline';
+export const TRACKING_DIFF_VIEW_PARALLEL = 'i_code_review_diff_view_parallel';
+
+export const TRACKING_CLICK_FILE_BROWSER_SETTING = 'i_code_review_click_file_browser_setting';
+export const TRACKING_FILE_BROWSER_TREE = 'i_code_review_file_browser_tree_view';
+export const TRACKING_FILE_BROWSER_LIST = 'i_code_review_file_browser_list_view';
+
+export const TRACKING_CLICK_WHITESPACE_SETTING = 'i_code_review_click_whitespace_setting';
+export const TRACKING_WHITESPACE_SHOW = 'i_code_review_diff_show_whitespace';
+export const TRACKING_WHITESPACE_HIDE = 'i_code_review_diff_hide_whitespace';
+
+export const TRACKING_CLICK_SINGLE_FILE_SETTING = 'i_code_review_click_single_file_mode_setting';
+export const TRACKING_SINGLE_FILE_MODE = 'i_code_review_diff_single_file';
+export const TRACKING_MULTIPLE_FILES_MODE = 'i_code_review_diff_multiple_files';
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 428faf693b0..d0730e18228 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -1,5 +1,6 @@
import Cookies from 'js-cookie';
import Vue from 'vue';
+import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { diffViewerModes } from '~/ide/constants';
import axios from '~/lib/utils/axios_utils';
@@ -36,6 +37,18 @@ import {
DIFF_VIEW_FILE_BY_FILE,
DIFF_VIEW_ALL_FILES,
DIFF_FILE_BY_FILE_COOKIE_NAME,
+ TRACKING_CLICK_DIFF_VIEW_SETTING,
+ TRACKING_DIFF_VIEW_INLINE,
+ TRACKING_DIFF_VIEW_PARALLEL,
+ TRACKING_CLICK_FILE_BROWSER_SETTING,
+ TRACKING_FILE_BROWSER_TREE,
+ TRACKING_FILE_BROWSER_LIST,
+ TRACKING_CLICK_WHITESPACE_SETTING,
+ TRACKING_WHITESPACE_SHOW,
+ TRACKING_WHITESPACE_HIDE,
+ TRACKING_CLICK_SINGLE_FILE_SETTING,
+ TRACKING_SINGLE_FILE_MODE,
+ TRACKING_MULTIPLE_FILES_MODE,
} from '../constants';
import eventHub from '../event_hub';
import { isCollapsed } from '../utils/diff_file';
@@ -352,6 +365,11 @@ export const setInlineDiffViewType = ({ commit }) => {
Cookies.set(DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: INLINE_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
+
+ if (window.gon?.features?.diffSettingsUsageData) {
+ api.trackRedisHllUserEvent(TRACKING_CLICK_DIFF_VIEW_SETTING);
+ api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_INLINE);
+ }
};
export const setParallelDiffViewType = ({ commit }) => {
@@ -360,6 +378,11 @@ export const setParallelDiffViewType = ({ commit }) => {
Cookies.set(DIFF_VIEW_COOKIE_NAME, PARALLEL_DIFF_VIEW_TYPE);
const url = mergeUrlParams({ view: PARALLEL_DIFF_VIEW_TYPE }, window.location.href);
historyPushState(url);
+
+ if (window.gon?.features?.diffSettingsUsageData) {
+ api.trackRedisHllUserEvent(TRACKING_CLICK_DIFF_VIEW_SETTING);
+ api.trackRedisHllUserEvent(TRACKING_DIFF_VIEW_PARALLEL);
+ }
};
export const showCommentForm = ({ commit }, { lineCode, fileHash }) => {
@@ -527,6 +550,16 @@ export const setRenderTreeList = ({ commit }, renderTreeList) => {
commit(types.SET_RENDER_TREE_LIST, renderTreeList);
localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList);
+
+ if (window.gon?.features?.diffSettingsUsageData) {
+ api.trackRedisHllUserEvent(TRACKING_CLICK_FILE_BROWSER_SETTING);
+
+ if (renderTreeList) {
+ api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_TREE);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_FILE_BROWSER_LIST);
+ }
+ }
};
export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => {
@@ -540,6 +573,16 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals
}
notesEventHub.$emit('refetchDiffData');
+
+ if (window.gon?.features?.diffSettingsUsageData) {
+ api.trackRedisHllUserEvent(TRACKING_CLICK_WHITESPACE_SETTING);
+
+ if (showWhitespace) {
+ api.trackRedisHllUserEvent(TRACKING_WHITESPACE_SHOW);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_WHITESPACE_HIDE);
+ }
+ }
};
export const toggleFileFinder = ({ commit }, visible) => {
@@ -754,6 +797,16 @@ export const setFileByFile = ({ state, commit }, { fileByFile }) => {
commit(types.SET_FILE_BY_FILE, fileByFile);
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
+ if (window.gon?.features?.diffSettingsUsageData) {
+ api.trackRedisHllUserEvent(TRACKING_CLICK_SINGLE_FILE_SETTING);
+
+ if (fileByFile) {
+ api.trackRedisHllUserEvent(TRACKING_SINGLE_FILE_MODE);
+ } else {
+ api.trackRedisHllUserEvent(TRACKING_MULTIPLE_FILES_MODE);
+ }
+ }
+
return axios
.put(state.endpointUpdateUser, {
view_diffs_file_by_file: fileByFile,
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index dec3f87b03e..0a9623c13a3 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -170,3 +170,6 @@ export function suggestionCommitMessage(state, _, rootState) {
},
});
}
+
+export const isVirtualScrollingEnabled = (state) =>
+ !state.viewDiffsFileByFile && window.gon?.features?.diffsVirtualScrolling;
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index 01811e60caa..673ec821b58 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -7,6 +7,9 @@ export const selectedTargetIndex = (state) =>
export const selectedSourceIndex = (state) => state.mergeRequestDiff.version_index;
+export const selectedContextCommitsDiffs = (state) =>
+ state.contextCommitsDiff && state.contextCommitsDiff.showing_context_commits_diff;
+
export const diffCompareDropdownTargetVersions = (state, getters) => {
// startVersion only exists if the user has selected a version other
// than "base" so if startVersion is null then base must be selected
@@ -58,7 +61,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
export const diffCompareDropdownSourceVersions = (state, getters) => {
// Appended properties here are to make the compare_dropdown_layout easier to reason about
- return state.mergeRequestDiffs.map((v, i) => {
+ const versions = state.mergeRequestDiffs.map((v, i) => {
const isLatestVersion = i === 0;
return {
@@ -69,7 +72,20 @@ export const diffCompareDropdownSourceVersions = (state, getters) => {
versionName: isLatestVersion
? __('latest version')
: sprintf(__(`version %{versionIndex}`), { versionIndex: v.version_index }),
- selected: v.version_index === getters.selectedSourceIndex,
+ selected:
+ v.version_index === getters.selectedSourceIndex && !getters.selectedContextCommitsDiffs,
};
});
+
+ const { contextCommitsDiff } = state;
+ if (contextCommitsDiff) {
+ versions.push({
+ href: contextCommitsDiff.diffs_path,
+ commitsText: n__(`%d commit`, `%d commits`, contextCommitsDiff.commits_count),
+ versionName: __('previously merged commits'),
+ selected: getters.selectedContextCommitsDiffs,
+ addDivider: state.mergeRequestDiffs.length > 0,
+ });
+ }
+ return versions;
};
diff --git a/app/assets/javascripts/diffs/utils/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js
index 7e6fde320d2..a96c1207a04 100644
--- a/app/assets/javascripts/diffs/utils/diff_file.js
+++ b/app/assets/javascripts/diffs/utils/diff_file.js
@@ -1,4 +1,5 @@
import { truncateSha } from '~/lib/utils/text_utility';
+import { uuids } from '~/lib/utils/uuids';
import {
DIFF_FILE_SYMLINK_MODE,
@@ -7,7 +8,6 @@ import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
} from '../constants';
import { getDerivedMergeRequestInformation } from './merge_request';
-import { uuids } from './uuids';
function fileSymlinkInformation(file, fileList) {
const duplicates = fileList.filter((iteratedFile) => iteratedFile.file_hash === file.file_hash);
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index 1f57d73d3d3..aa223270f2c 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -2,6 +2,7 @@
import dateFormat from 'dateformat';
import $ from 'jquery';
import Pikaday from 'pikaday';
+import initDatePicker from '~/behaviors/date_picker';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { __ } from '~/locale';
import boardsStore from './boards/stores/boards_store';
@@ -168,40 +169,10 @@ class DueDateSelect {
export default class DueDateSelectors {
constructor() {
- this.initMilestoneDatePicker();
+ initDatePicker();
this.initIssuableSelect();
}
// eslint-disable-next-line class-methods-use-this
- initMilestoneDatePicker() {
- $('.datepicker').each(function initPikadayMilestone() {
- const $datePicker = $(this);
- const datePickerVal = $datePicker.val();
-
- const calendar = new Pikaday({
- field: $datePicker.get(0),
- theme: 'gitlab-theme animate-picker',
- format: 'yyyy-mm-dd',
- container: $datePicker.parent().get(0),
- parse: (dateString) => parsePikadayDate(dateString),
- toString: (date) => pikadayToString(date),
- onSelect(dateText) {
- $datePicker.val(calendar.toString(dateText));
- },
- firstDay: gon.first_day_of_week,
- });
-
- calendar.setDate(parsePikadayDate(datePickerVal));
-
- $datePicker.data('pikaday', calendar);
- });
-
- $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => {
- e.preventDefault();
- const calendar = $(e.target).siblings('.datepicker').data('pikaday');
- calendar.setDate(null);
- });
- }
- // eslint-disable-next-line class-methods-use-this
initIssuableSelect() {
const $loading = $('.js-issuable-update .due_date')
.find('.block-loading')
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index 79beb3a4857..249888ede9b 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -1,10 +1,10 @@
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
-import { uuids } from '~/diffs/utils/uuids';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import languages from '~/ide/lib/languages';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
import { registerLanguages } from '~/ide/utils';
import { joinPaths } from '~/lib/utils/url_utility';
+import { uuids } from '~/lib/utils/uuids';
import {
EDITOR_LITE_INSTANCE_ERROR_NO_EL,
URI_PREFIX,
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 3d4f08131c1..05a020bd958 100644
--- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
+++ b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js
@@ -1,4 +1,5 @@
import { Range } from 'monaco-editor';
+import { waitForCSSLoaded } from '~/helpers/startup_css_helper';
import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants';
const hashRegexp = new RegExp('#?L', 'g');
@@ -23,11 +24,18 @@ export class EditorLiteExtension {
if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) {
EditorLiteExtension.setupLineLinking(instance);
}
+ EditorLiteExtension.deferRerender(instance);
} else if (Object.entries(options).length) {
throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
}
}
+ static deferRerender(instance) {
+ waitForCSSLoaded(() => {
+ instance.layout();
+ });
+ }
+
static highlightLines(instance) {
const { hash } = window.location;
if (!hash) {
diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue
index 71cabe80529..e08d294b8c5 100644
--- a/app/assets/javascripts/emoji/components/picker.vue
+++ b/app/assets/javascripts/emoji/components/picker.vue
@@ -79,6 +79,7 @@ export default {
:toggle-class="toggleClass"
:boundary="getBoundaryElement()"
menu-class="dropdown-extended-height"
+ category="tertiary"
no-flip
right
lazy
diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js
index 5b4d1afc9d0..69c81c35bd4 100644
--- a/app/assets/javascripts/ensure_data.js
+++ b/app/assets/javascripts/ensure_data.js
@@ -3,8 +3,8 @@ 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 = __(
+export const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly');
+export const ERROR_FETCHING_DATA_DESCRIPTION = __(
'Please try and refresh the page. If the problem persists please contact support.',
);
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index cbce887f491..f82d3065ca5 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -188,15 +188,37 @@ export default {
</div>
<template v-else>
- <div
- is="environment-item"
- v-for="(children, index) in model.children"
- :key="`env-item-${i}-${index}`"
- :model="children"
- :can-read-environment="canReadEnvironment"
- :table-data="tableData"
- data-qa-selector="environment_item"
- />
+ <template v-for="(child, index) in model.children">
+ <div
+ is="environment-item"
+ :key="`environment-row-${i}-${index}`"
+ :model="child"
+ :can-read-environment="canReadEnvironment"
+ :table-data="tableData"
+ data-qa-selector="environment_item"
+ />
+
+ <div
+ v-if="shouldRenderDeployBoard(child)"
+ :key="`deploy-board-row-${i}-${index}`"
+ class="js-deploy-board-row"
+ >
+ <div class="deploy-board-container">
+ <deploy-board
+ :deploy-board-data="child.deployBoardData"
+ :is-loading="child.isLoadingDeployBoard"
+ :is-empty="child.isEmptyDeployBoard"
+ :logs-path="child.logs_path"
+ @changeCanaryWeight="changeCanaryWeight(child, $event)"
+ />
+ </div>
+ </div>
+ <environment-alert
+ v-if="shouldRenderAlert(model)"
+ :key="`alert-row-${i}-${index}`"
+ :environment="child"
+ />
+ </template>
<div :key="`sub-div-${i}`">
<div class="text-center gl-mt-3">
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index f7fdbb03f04..a67e44b3348 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -185,6 +185,8 @@ export default class EnvironmentsStore {
updated.isChildren = true;
+ updated = setDeployBoard(env, updated);
+
return updated;
});
diff --git a/app/assets/javascripts/environments/stores/helpers.js b/app/assets/javascripts/environments/stores/helpers.js
index 89457da0614..3330edd8830 100644
--- a/app/assets/javascripts/environments/stores/helpers.js
+++ b/app/assets/javascripts/environments/stores/helpers.js
@@ -4,7 +4,7 @@
*/
export const setDeployBoard = (oldEnvironmentState, environment) => {
let parsedEnvironment = environment;
- if (environment.size === 1 && environment.rollout_status) {
+ if (!environment.isFolder && environment.rollout_status) {
parsedEnvironment = {
...environment,
hasDeployBoard: true,
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index 971eb21ee3b..d188574e721 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -1,11 +1,17 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import ErrorTrackingForm from './error_tracking_form.vue';
import ProjectDropdown from './project_dropdown.vue';
export default {
- components: { ProjectDropdown, ErrorTrackingForm, GlButton },
+ components: {
+ ErrorTrackingForm,
+ GlButton,
+ GlFormCheckbox,
+ GlFormGroup,
+ ProjectDropdown,
+ },
props: {
initialApiHost: {
type: String,
@@ -66,18 +72,18 @@ export default {
<template>
<div>
- <div class="form-check form-group">
- <input
+ <gl-form-group
+ :label="s__('ErrorTracking|Enable error tracking')"
+ label-for="error-tracking-enabled"
+ >
+ <gl-form-checkbox
id="error-tracking-enabled"
:checked="enabled"
- class="form-check-input"
- type="checkbox"
- @change="updateEnabled($event.target.checked)"
- />
- <label class="form-check-label" for="error-tracking-enabled">{{
- s__('ErrorTracking|Active')
- }}</label>
- </div>
+ @change="updateEnabled($event)"
+ >
+ {{ s__('ErrorTracking|Active') }}
+ </gl-form-checkbox>
+ </gl-form-group>
<error-tracking-form />
<div class="form-group">
<project-dropdown
@@ -95,7 +101,7 @@ export default {
<gl-button
:disabled="settingsLoading"
class="js-error-tracking-button"
- variant="success"
+ variant="confirm"
@click="handleSubmit"
>
{{ __('Save changes') }}
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 4df324b396c..da942dbd0ae 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
@@ -70,7 +70,7 @@ export default {
v-show="connectSuccessful"
class="js-error-tracking-connect-success gl-ml-2 text-success align-middle"
:aria-label="__('Projects Successfully Retrieved')"
- name="check-circle"
+ name="check"
/>
</div>
</div>
diff --git a/app/assets/javascripts/experimentation/components/experiment.vue b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
index 294dbf77991..294dbf77991 100644
--- a/app/assets/javascripts/experimentation/components/experiment.vue
+++ b/app/assets/javascripts/experimentation/components/gitlab_experiment.vue
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index 572907f226d..e572280a62c 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,11 +1,20 @@
// 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';
+import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
export function getExperimentData(experimentName) {
return get(window, ['gon', 'experiment', experimentName]);
}
+export function getExperimentContexts(...experimentNames) {
+ return experimentNames
+ .map((name) => {
+ const data = getExperimentData(name);
+ return data && { schema: TRACKING_CONTEXT_SCHEMA, data };
+ })
+ .filter((context) => context);
+}
+
export function isExperimentVariant(experimentName, variantName) {
return getExperimentData(experimentName)?.variant === variantName;
}
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 115d68de5c9..9aa1accb0f2 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -35,8 +35,9 @@ export default {
inject: {
newUserListPath: { default: '' },
newFeatureFlagPath: { default: '' },
- canUserConfigure: { required: true },
- featureFlagsLimitExceeded: { required: true },
+ canUserConfigure: {},
+ featureFlagsLimitExceeded: {},
+ featureFlagsLimit: {},
},
data() {
const scope = getParameterByName('scope') || SCOPES.FEATURE_FLAG_SCOPE;
diff --git a/app/assets/javascripts/feature_flags/index.js b/app/assets/javascripts/feature_flags/index.js
index a92805d5d85..d2371a2aa8b 100644
--- a/app/assets/javascripts/feature_flags/index.js
+++ b/app/assets/javascripts/feature_flags/index.js
@@ -24,6 +24,7 @@ export default () => {
newFeatureFlagPath,
newUserListPath,
featureFlagsLimitExceeded,
+ featureFlagsLimit,
} = el.dataset;
return new Vue({
@@ -40,7 +41,8 @@ export default () => {
canUserConfigure: canUserAdminFeatureFlag !== undefined,
newFeatureFlagPath,
newUserListPath,
- featureFlagsLimitExceeded,
+ featureFlagsLimitExceeded: featureFlagsLimitExceeded !== undefined,
+ featureFlagsLimit,
},
render(createElement) {
return createElement(FeatureFlagsComponent);
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index 0da8cd0ad83..f933338514a 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -50,7 +50,7 @@ export default class DropdownOperator extends FilteredSearchDropdown {
];
const dropdownToken = this.tokenKeys.searchByKey(dropdownName.toLowerCase());
- if (gon.features?.notIssuableQueries && !dropdownToken?.hideNotEqual) {
+ if (!dropdownToken?.hideNotEqual) {
dropdownData.push({
tag: 'not-equal',
type: 'string',
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 2bec39ff4d8..7a79f8f5bfc 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -9,6 +9,10 @@ const FLASH_TYPES = {
WARNING: 'warning',
};
+const getCloseEl = (flashEl) => {
+ return flashEl.querySelector('.js-close-icon');
+};
+
const hideFlash = (flashEl, fadeTransition = true) => {
if (fadeTransition) {
Object.assign(flashEl.style, {
@@ -56,9 +60,7 @@ const createFlashEl = (message, type) => `
`;
const removeFlashClickListener = (flashEl, fadeTransition) => {
- flashEl
- .querySelector('.js-close-icon')
- .addEventListener('click', () => hideFlash(flashEl, fadeTransition));
+ getCloseEl(flashEl).addEventListener('click', () => hideFlash(flashEl, fadeTransition));
};
/*
@@ -114,6 +116,10 @@ const createFlash = function createFlash({
if (captureError && error) Sentry.captureException(error);
+ flashContainer.close = () => {
+ getCloseEl(flashEl).click();
+ };
+
return flashContainer;
};
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 69f89aa3857..e103949b86a 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -1,7 +1,11 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import { mapState, mapActions, mapGetters } from 'vuex';
import AccessorUtilities from '~/lib/utils/accessor';
+import {
+ mapVuexModuleState,
+ mapVuexModuleActions,
+ mapVuexModuleGetters,
+} from '~/lib/utils/vuex_module_mappers';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import eventHub from '../event_hub';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
@@ -16,6 +20,7 @@ export default {
GlLoadingIcon,
},
mixins: [frequentItemsMixin],
+ inject: ['vuexModule'],
props: {
currentUserName: {
type: String,
@@ -27,8 +32,13 @@ export default {
},
},
computed: {
- ...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']),
- ...mapGetters(['hasSearchQuery']),
+ ...mapVuexModuleState((vm) => vm.vuexModule, [
+ 'searchQuery',
+ 'isLoadingItems',
+ 'isFetchFailed',
+ 'items',
+ ]),
+ ...mapVuexModuleGetters((vm) => vm.vuexModule, ['hasSearchQuery']),
translations() {
return this.getTranslations(['loadingMessage', 'header']);
},
@@ -56,7 +66,11 @@ export default {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
},
methods: {
- ...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']),
+ ...mapVuexModuleActions((vm) => vm.vuexModule, [
+ 'setNamespace',
+ 'setStorageKey',
+ 'fetchFrequentItems',
+ ]),
dropdownOpenHandler() {
if (this.searchQuery === '' || isMobile()) {
this.fetchFrequentItems();
@@ -100,15 +114,16 @@ export default {
</script>
<template>
- <div>
- <frequent-items-search-input :namespace="namespace" />
+ <div class="gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch gl-h-full">
+ <frequent-items-search-input :namespace="namespace" data-testid="frequent-items-search-input" />
<gl-loading-icon
v-if="isLoadingItems"
:label="translations.loadingMessage"
size="lg"
class="loading-animation prepend-top-20"
+ data-testid="loading"
/>
- <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header">
+ <div v-if="!isLoadingItems && !hasSearchQuery" class="section-header" data-testid="header">
{{ translations.header }}
</div>
<frequent-items-list
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
index 6feeb5f03ad..1da0b88c9e9 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
@@ -59,7 +59,11 @@ export default {
<template>
<div class="frequent-items-list-container">
<ul ref="frequentItemsList" class="list-unstyled">
- <li v-if="isListEmpty" :class="{ 'section-failure': isFetchFailed }" class="section-empty">
+ <li
+ v-if="isListEmpty"
+ :class="{ 'section-failure': isFetchFailed }"
+ class="section-empty gl-mb-3"
+ >
{{ listEmptyMessage }}
</li>
<frequent-items-list-item
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 6f17e6a5282..c2f77cc8bc4 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,8 +1,8 @@
<script>
/* eslint-disable vue/require-default-prop, vue/no-v-html */
-import { mapState } from 'vuex';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
+import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking';
import Identicon from '~/vue_shared/components/identicon.vue';
@@ -13,6 +13,7 @@ export default {
Identicon,
},
mixins: [trackingMixin],
+ inject: ['vuexModule'],
props: {
matcher: {
type: String,
@@ -42,7 +43,7 @@ export default {
},
},
computed: {
- ...mapState(['dropdownType']),
+ ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
truncatedNamespace() {
return truncateNamespace(this.namespace);
},
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
index b0972246e70..fa14ee15cf3 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
@@ -1,7 +1,7 @@
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
import { debounce } from 'lodash';
-import { mapActions, mapState } from 'vuex';
+import { mapVuexModuleActions, mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
import Tracking from '~/tracking';
import frequentItemsMixin from './frequent_items_mixin';
@@ -12,13 +12,14 @@ export default {
GlSearchBoxByType,
},
mixins: [frequentItemsMixin, trackingMixin],
+ inject: ['vuexModule'],
data() {
return {
searchQuery: '',
};
},
computed: {
- ...mapState(['dropdownType']),
+ ...mapVuexModuleState((vm) => vm.vuexModule, ['dropdownType']),
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
@@ -32,7 +33,7 @@ export default {
}, 500),
},
methods: {
- ...mapActions(['setSearchQuery']),
+ ...mapVuexModuleActions((vm) => vm.vuexModule, ['setSearchQuery']),
},
};
</script>
diff --git a/app/assets/javascripts/frequent_items/constants.js b/app/assets/javascripts/frequent_items/constants.js
index 9bc17f5ef4f..9e1dcf70aa5 100644
--- a/app/assets/javascripts/frequent_items/constants.js
+++ b/app/assets/javascripts/frequent_items/constants.js
@@ -36,3 +36,17 @@ export const TRANSLATION_KEYS = {
searchInputPlaceholder: s__('GroupsDropdown|Search your groups'),
},
};
+
+export const FREQUENT_ITEMS_PROJECTS = {
+ namespace: 'projects',
+ key: 'project',
+ vuexModule: 'frequentProjects',
+};
+
+export const FREQUENT_ITEMS_GROUPS = {
+ namespace: 'groups',
+ key: 'group',
+ vuexModule: 'frequentGroups',
+};
+
+export const FREQUENT_ITEMS_DROPDOWNS = [FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS];
diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js
index eb8a404e8a5..f1540ffac28 100644
--- a/app/assets/javascripts/frequent_items/index.js
+++ b/app/assets/javascripts/frequent_items/index.js
@@ -1,25 +1,20 @@
import $ from 'jquery';
import Vue from 'vue';
+import Vuex from 'vuex';
import { createStore } from '~/frequent_items/store';
+import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
import Translate from '~/vue_shared/translate';
+import { FREQUENT_ITEMS_DROPDOWNS } from './constants';
import eventHub from './event_hub';
+Vue.use(Vuex);
Vue.use(Translate);
-const frequentItemDropdowns = [
- {
- namespace: 'projects',
- key: 'project',
- },
- {
- namespace: 'groups',
- key: 'group',
- },
-];
-
export default function initFrequentItemDropdowns() {
- frequentItemDropdowns.forEach((dropdown) => {
- const { namespace, key } = dropdown;
+ const store = createStore();
+
+ FREQUENT_ITEMS_DROPDOWNS.forEach((dropdown) => {
+ const { namespace, key, vuexModule } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
const navEl = document.getElementById(`nav-${namespace}-dropdown`);
@@ -29,9 +24,6 @@ export default function initFrequentItemDropdowns() {
return;
}
- const dropdownType = namespace;
- const store = createStore({ dropdownType });
-
import('./components/app.vue')
.then(({ default: FrequentItems }) => {
// eslint-disable-next-line no-new
@@ -55,13 +47,23 @@ export default function initFrequentItemDropdowns() {
};
},
render(createElement) {
- return createElement(FrequentItems, {
- props: {
- namespace,
- currentUserName: this.currentUserName,
- currentItem: this.currentItem,
+ return createElement(
+ VuexModuleProvider,
+ {
+ props: {
+ vuexModule,
+ },
},
- });
+ [
+ createElement(FrequentItems, {
+ props: {
+ namespace,
+ currentUserName: this.currentUserName,
+ currentItem: this.currentItem,
+ },
+ }),
+ ],
+ );
},
});
})
diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js
index 83176d69802..1faacff84e5 100644
--- a/app/assets/javascripts/frequent_items/store/index.js
+++ b/app/assets/javascripts/frequent_items/store/index.js
@@ -1,17 +1,28 @@
-import Vue from 'vue';
import Vuex from 'vuex';
+import { FREQUENT_ITEMS_DROPDOWNS } from '../constants';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
-Vue.use(Vuex);
+export const createFrequentItemsModule = (initState = {}) => ({
+ namespaced: true,
+ actions,
+ getters,
+ mutations,
+ state: state(initState),
+});
-export const createStore = (initState = {}) => {
- return new Vuex.Store({
- actions,
- getters,
- mutations,
- state: state(initState),
- });
+export const createStoreOptions = () => ({
+ modules: FREQUENT_ITEMS_DROPDOWNS.reduce(
+ (acc, { namespace, vuexModule }) =>
+ Object.assign(acc, {
+ [vuexModule]: createFrequentItemsModule({ dropdownType: namespace }),
+ }),
+ {},
+ ),
+});
+
+export const createStore = () => {
+ return new Vuex.Store(createStoreOptions());
};
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 22f88b1caa7..470c785f7e4 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -238,10 +238,13 @@ class GfmAutoComplete {
const MEMBER_COMMAND = {
ASSIGN: '/assign',
UNASSIGN: '/unassign',
+ ASSIGN_REVIEWER: '/assign_reviewer',
+ UNASSIGN_REVIEWER: '/unassign_reviewer',
REASSIGN: '/reassign',
CC: '/cc',
};
let assignees = [];
+ let reviewers = [];
let command = '';
// Team Members
@@ -286,9 +289,11 @@ class GfmAutoComplete {
return null;
});
- // Cache assignees list for easier filtering later
+ // Cache assignees & reviewers list for easier filtering later
assignees =
SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || [];
+ reviewers =
+ SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || [];
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
return match && match.length ? match[1] : null;
@@ -309,6 +314,12 @@ class GfmAutoComplete {
} else if (command === MEMBER_COMMAND.UNASSIGN) {
// Only include members which are assigned to Issuable currently
return data.filter((member) => assignees.includes(member.search));
+ } else if (command === MEMBER_COMMAND.ASSIGN_REVIEWER) {
+ // Only include members which are not assigned as a reviewer to Issuable currently
+ return data.filter((member) => !reviewers.includes(member.search));
+ } else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
+ // Only include members which are not assigned as a reviewer to Issuable currently
+ return data.filter((member) => reviewers.includes(member.search));
}
return data;
@@ -823,10 +834,10 @@ GfmAutoComplete.Members = {
const lowercaseQuery = query.toLowerCase();
const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
- return sortBy(members, [
+ return sortBy(
+ members.filter((member) => nameOrUsernameIncludes(member, lowercaseQuery)),
(member) => (nameOrUsernameStartsWith(member, lowercaseQuery) ? -1 : 0),
- (member) => (nameOrUsernameIncludes(member, lowercaseQuery) ? -1 : 0),
- ]);
+ );
},
};
GfmAutoComplete.Labels = {
diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
index 101633ef7a7..41e7ed98c78 100644
--- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql
@@ -9,6 +9,7 @@ fragment AlertListItem on AlertManagementAlert {
iid
state
title
+ webUrl
}
assignees {
nodes {
diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
new file mode 100644
index 00000000000..6ed3be84cd8
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
@@ -0,0 +1,10 @@
+fragment TimelogFragment on Timelog {
+ timeSpent
+ user {
+ name
+ }
+ spentAt
+ note {
+ body
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql
new file mode 100644
index 00000000000..2b831bf1338
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/mutations/dismiss_user_callout.mutation.graphql
@@ -0,0 +1,9 @@
+mutation dismissUserCallout($input: UserCalloutCreateInput!) {
+ userCalloutCreate(input: $input) {
+ errors
+ userCallout {
+ dismissedAt
+ featureName
+ }
+ }
+}
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index 747cea6a46e..402d9a07c53 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -1,5 +1,6 @@
<script>
import { GlBanner } from '@gitlab/ui';
+import eventHub from '~/invite_members/event_hub';
import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -11,7 +12,7 @@ export default {
GlBanner,
},
mixins: [trackingMixin],
- inject: ['svgPath', 'inviteMembersPath', 'isDismissedKey', 'trackLabel'],
+ inject: ['svgPath', 'isDismissedKey', 'trackLabel'],
data() {
return {
isDismissed: parseBoolean(getCookie(this.isDismissedKey)),
@@ -20,11 +21,6 @@ export default {
},
};
},
- created() {
- this.$nextTick(() => {
- this.addTrackingAttributesToButton();
- });
- },
mounted() {
this.trackOnShow();
},
@@ -39,15 +35,12 @@ export default {
if (!this.isDismissed) this.track(this.$options.displayEvent);
});
},
- addTrackingAttributesToButton() {
- if (this.$refs.banner === undefined) return;
-
- const button = this.$refs.banner.$el.querySelector(`[href='${this.inviteMembersPath}']`);
-
- if (button) {
- button.setAttribute('data-track-event', this.$options.buttonClickEvent);
- button.setAttribute('data-track-label', this.trackLabel);
- }
+ openModal() {
+ eventHub.$emit('openModal', {
+ inviteeType: 'members',
+ source: this.$options.openModalSource,
+ });
+ this.track(this.$options.buttonClickEvent);
},
},
i18n: {
@@ -59,6 +52,7 @@ export default {
},
displayEvent: 'invite_members_banner_displayed',
buttonClickEvent: 'invite_members_banner_button_clicked',
+ openModalSource: 'invite_members_banner',
dismissEvent: 'invite_members_banner_dismissed',
};
</script>
@@ -70,8 +64,8 @@ export default {
:title="$options.i18n.title"
:button-text="$options.i18n.button_text"
:svg-path="svgPath"
- :button-link="inviteMembersPath"
@close="handleClose"
+ @primary="openModal"
>
<p>{{ $options.i18n.body }}</p>
</gl-banner>
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 4fed7f555f6..c2ef6414716 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -45,7 +45,6 @@ function initStatusTriggers() {
defaultEmoji,
currentMessage,
currentAvailability,
- canSetUserAvailability,
currentClearStatusAfter,
} = setStatusModalWrapperEl.dataset;
@@ -54,7 +53,6 @@ function initStatusTriggers() {
defaultEmoji,
currentMessage,
currentAvailability,
- canSetUserAvailability,
currentClearStatusAfter,
};
},
@@ -64,7 +62,6 @@ function initStatusTriggers() {
defaultEmoji,
currentMessage,
currentAvailability,
- canSetUserAvailability,
currentClearStatusAfter,
} = this;
@@ -74,7 +71,6 @@ function initStatusTriggers() {
defaultEmoji,
currentMessage,
currentAvailability,
- canSetUserAvailability,
currentClearStatusAfter,
},
});
diff --git a/app/assets/javascripts/help/help.js b/app/assets/javascripts/help/help.js
deleted file mode 100644
index f5333042bb8..00000000000
--- a/app/assets/javascripts/help/help.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// We will render the icons list here
-
-import $ from 'jquery';
-
-export default () => {
- if ($('#user-content-gitlab-icons').length > 0) {
- const $iconsHeader = $('#user-content-gitlab-icons');
- const $iconsList = $('<div id="iconsList">ICONS</div>');
- $($iconsList).insertAfter($iconsHeader.parent());
- }
-};
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index 35e2f99cb6a..bdfcff3136b 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -34,7 +34,7 @@ export default {
<template>
<a :href="branchHref" class="btn-link d-flex align-items-center">
<span class="d-flex gl-mr-3 ide-search-list-current-icon">
- <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes />
+ <gl-icon v-if="isActive" :size="16" name="mobile-issue-close" />
</span>
<span>
<strong> {{ item.name }} </strong>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 273d8d972f7..fcc900bbc96 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -76,8 +76,9 @@ export default {
:value="$options.commitToCurrentBranch"
:disabled="!canPushToBranch"
:title="$options.currentBranchPermissionsTooltip"
+ data-qa-selector="commit_to_current_branch_radio_container"
>
- <span class="ide-option-label" data-qa-selector="commit_to_current_branch_radio">
+ <span class="ide-option-label">
<gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')">
<template #branchName>
<strong class="monospace">{{ currentBranchText }}</strong>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index 039b4a54b26..870355e884e 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -64,6 +64,7 @@ export default {
:disabled="disabled"
type="radio"
name="commit-action"
+ data-qa-selector="commit_type_radio"
@change="updateCommitAction($event.target.value)"
/>
<span class="gl-ml-3">
diff --git a/app/assets/javascripts/ide/components/file_alert.vue b/app/assets/javascripts/ide/components/file_alert.vue
new file mode 100644
index 00000000000..2a894596bf4
--- /dev/null
+++ b/app/assets/javascripts/ide/components/file_alert.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { getAlert } from '../lib/alerts';
+
+export default {
+ components: {
+ GlAlert,
+ },
+ props: {
+ alertKey: {
+ type: Symbol,
+ required: true,
+ },
+ },
+ computed: {
+ alert() {
+ return getAlert(this.alertKey);
+ },
+ },
+};
+</script>
+<template>
+ <gl-alert v-bind="alert.props" @dismiss="alert.dismiss($store)">
+ <component :is="alert.message" />
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b57dcd4276c..bf2af9ffd49 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,4 +1,5 @@
<script>
+import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
@@ -34,11 +35,13 @@ import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils';
+import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue';
export default {
name: 'RepoEditor',
components: {
+ FileAlert,
ContentViewer,
DiffViewer,
FileTemplatesBar,
@@ -57,6 +60,7 @@ export default {
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
+ unwatchCiYaml: null,
};
},
computed: {
@@ -74,6 +78,7 @@ export default {
'currentProjectId',
]),
...mapGetters([
+ 'getAlert',
'currentMergeRequest',
'getStagedFile',
'isEditModeActive',
@@ -82,6 +87,9 @@ export default {
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
+ alertKey() {
+ return this.getAlert(this.file);
+ },
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
@@ -136,6 +144,16 @@ export default {
},
},
watch: {
+ 'file.name': {
+ handler() {
+ this.stopWatchingCiYaml();
+
+ if (this.file.name === '.gitlab-ci.yml') {
+ this.startWatchingCiYaml();
+ }
+ },
+ immediate: true,
+ },
file(newVal, oldVal) {
if (oldVal.pending) {
this.removePendingTab(oldVal);
@@ -216,6 +234,7 @@ export default {
'removePendingTab',
'triggerFilesChange',
'addTempImage',
+ 'detectGitlabCiFileAlerts',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
@@ -422,6 +441,18 @@ export default {
this.updateFileEditor({ path: this.file.path, data });
},
+ startWatchingCiYaml() {
+ this.unwatchCiYaml = this.$watch(
+ 'file.content',
+ debounce(this.detectGitlabCiFileAlerts, 500),
+ );
+ },
+ stopWatchingCiYaml() {
+ if (this.unwatchCiYaml) {
+ this.unwatchCiYaml();
+ this.unwatchCiYaml = null;
+ }
+ },
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
@@ -439,9 +470,8 @@ export default {
role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
+ >{{ __('Edit') }}</a
>
- {{ __('Edit') }}
- </a>
</li>
<li v-if="previewMode" :class="previewTabCSS">
<a
@@ -454,7 +484,8 @@ export default {
</li>
</ul>
</div>
- <file-templates-bar v-if="showFileTemplatesBar(file.name)" />
+ <file-alert v-if="alertKey" :alert-key="alertKey" />
+ <file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
v-show="showEditor"
ref="editor"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 2ce5bf7e271..7109c45a3fe 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -56,11 +56,12 @@ export function initIde(el, options = {}) {
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
- this.setInitialData({
+ this.init({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl,
+ environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance),
});
},
beforeDestroy() {
@@ -68,7 +69,7 @@ export function initIde(el, options = {}) {
this.$emit('destroy');
},
methods: {
- ...mapActions(['setEmptyStateSvgs', 'setLinks', 'setInitialData']),
+ ...mapActions(['setEmptyStateSvgs', 'setLinks', 'init']),
},
render(createElement) {
return createElement(rootComponent);
diff --git a/app/assets/javascripts/ide/lib/alerts/environments.vue b/app/assets/javascripts/ide/lib/alerts/environments.vue
new file mode 100644
index 00000000000..ac9a3c3f82c
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/alerts/environments.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __ } from '~/locale';
+
+export default {
+ components: { GlSprintf, GlLink },
+ message: __(
+ "No deployments detected. Use environments to control your software's continuous deployment. %{linkStart}Learn more about deployment jobs.%{linkEnd}",
+ ),
+ computed: {
+ helpLink() {
+ return helpPagePath('ci/environments/index.md');
+ },
+ },
+};
+</script>
+<template>
+ <span>
+ <gl-sprintf :message="$options.message">
+ <template #link="{ content }">
+ <gl-link
+ :href="helpLink"
+ target="_blank"
+ data-track-action="click_link"
+ data-track-experiment="in_product_guidance_environments_webide"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+</template>
diff --git a/app/assets/javascripts/ide/lib/alerts/index.js b/app/assets/javascripts/ide/lib/alerts/index.js
new file mode 100644
index 00000000000..c9db9779b1f
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/alerts/index.js
@@ -0,0 +1,20 @@
+import { leftSidebarViews } from '../../constants';
+import EnvironmentsMessage from './environments.vue';
+
+const alerts = [
+ {
+ key: Symbol('ALERT_ENVIRONMENT'),
+ show: (state, file) =>
+ state.currentActivityView === leftSidebarViews.commit.name &&
+ file.path === '.gitlab-ci.yml' &&
+ state.environmentsGuidanceAlertDetected &&
+ !state.environmentsGuidanceAlertDismissed,
+ props: { variant: 'tip' },
+ dismiss: ({ dispatch }) => dispatch('dismissEnvironmentsGuidance'),
+ message: EnvironmentsMessage,
+ },
+];
+
+export const findAlertKeyToShow = (...args) => alerts.find((x) => x.show(...args))?.key;
+
+export const getAlert = (key) => alerts.find((x) => x.key === key);
diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js
index 189226ef835..fe8eba823a8 100644
--- a/app/assets/javascripts/ide/messages.js
+++ b/app/assets/javascripts/ide/messages.js
@@ -1,11 +1,11 @@
import { s__ } from '~/locale';
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.',
+ 'WebIDE|You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.',
);
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.',
+ 'WebIDE|You can’t edit files directly in this project. Go to your fork and submit a merge request with your changes.',
);
export const MSG_CANNOT_PUSH_CODE = s__(
@@ -13,7 +13,7 @@ export const MSG_CANNOT_PUSH_CODE = s__(
);
export const MSG_CANNOT_PUSH_UNSIGNED = s__(
- 'WebIDE|This project does not accept unsigned commits. You will not be able to commit your changes through the Web IDE.',
+ 'WebIDE|This project does not accept unsigned commits. You can’t commit changes through the Web IDE.',
);
export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__(
diff --git a/app/assets/javascripts/ide/services/gql.js b/app/assets/javascripts/ide/services/gql.js
index 89dda187360..c8c1031c0f3 100644
--- a/app/assets/javascripts/ide/services/gql.js
+++ b/app/assets/javascripts/ide/services/gql.js
@@ -18,3 +18,4 @@ const getClient = memoize(() =>
);
export const query = (...args) => getClient().query(...args);
+export const mutate = (...args) => getClient().mutate(...args);
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 0aa08323d13..6bd28cd4fb6 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,8 +1,10 @@
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import Api from '~/api';
+import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
-import { query } from './gql';
+import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.graphql';
+import { query, mutate } from './gql';
const fetchApiProjectData = (projectPath) => Api.project(projectPath).then(({ data }) => data);
@@ -101,4 +103,16 @@ export default {
const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
return axios.post(url);
},
+ getCiConfig(projectPath, content) {
+ return query({
+ query: ciConfig,
+ variables: { projectPath, content },
+ }).then(({ data }) => data.ciConfig);
+ },
+ dismissUserCallout(name) {
+ return mutate({
+ mutation: dismissUserCallout,
+ variables: { input: { featureName: name } },
+ }).then(({ data }) => data);
+ },
};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index bf94f9d31c8..062dc150805 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -17,7 +17,7 @@ import * as types from './mutation_types';
export const redirectToUrl = (self, url) => visitUrl(url);
-export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
+export const init = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const discardAllChanges = ({ state, commit, dispatch }) => {
state.changedFiles.forEach((file) => dispatch('restoreOriginalFile', file.path));
@@ -316,3 +316,4 @@ export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
export * from './actions/merge_request';
+export * from './actions/alert';
diff --git a/app/assets/javascripts/ide/stores/actions/alert.js b/app/assets/javascripts/ide/stores/actions/alert.js
new file mode 100644
index 00000000000..4c33dc19520
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/actions/alert.js
@@ -0,0 +1,18 @@
+import service from '../../services';
+import {
+ DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
+ DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
+} from '../mutation_types';
+
+export const detectGitlabCiFileAlerts = ({ dispatch }, content) =>
+ dispatch('detectEnvironmentsGuidance', content);
+
+export const detectEnvironmentsGuidance = ({ commit, state }, content) =>
+ service.getCiConfig(state.currentProjectId, content).then((data) => {
+ commit(DETECT_ENVIRONMENTS_GUIDANCE_ALERT, data?.stages);
+ });
+
+export const dismissEnvironmentsGuidance = ({ commit }) =>
+ service.dismissUserCallout('web_ide_ci_environments_guidance').then(() => {
+ commit(DISMISS_ENVIRONMENTS_GUIDANCE_ALERT);
+ });
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index e8b1a0ea494..3c02b1d1da7 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -262,3 +262,5 @@ export const getJsonSchemaForPath = (state, getters) => (path) => {
fileMatch: [`*${path}`],
};
};
+
+export * from './getters/alert';
diff --git a/app/assets/javascripts/ide/stores/getters/alert.js b/app/assets/javascripts/ide/stores/getters/alert.js
new file mode 100644
index 00000000000..714e2d89b4f
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/getters/alert.js
@@ -0,0 +1,3 @@
+import { findAlertKeyToShow } from '../../lib/alerts';
+
+export const getAlert = (state) => (file) => findAlertKeyToShow(state, file);
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 76ba8339703..77755b179ef 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -70,3 +70,8 @@ export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
export const RESTORE_TREE = 'RESTORE_TREE';
+
+// Alert mutation types
+
+export const DETECT_ENVIRONMENTS_GUIDANCE_ALERT = 'DETECT_ENVIRONMENTS_GUIDANCE_ALERT';
+export const DISMISS_ENVIRONMENTS_GUIDANCE_ALERT = 'DISMISS_ENVIRONMENTS_GUIDANCE_ALERT';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 576f861a090..48648796e66 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import * as types from './mutation_types';
+import alertMutations from './mutations/alert';
import branchMutations from './mutations/branch';
import fileMutations from './mutations/file';
import mergeRequestMutation from './mutations/merge_request';
@@ -244,4 +245,5 @@ export default {
...fileMutations,
...treeMutations,
...branchMutations,
+ ...alertMutations,
};
diff --git a/app/assets/javascripts/ide/stores/mutations/alert.js b/app/assets/javascripts/ide/stores/mutations/alert.js
new file mode 100644
index 00000000000..bb2d33a836b
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/mutations/alert.js
@@ -0,0 +1,21 @@
+import {
+ DETECT_ENVIRONMENTS_GUIDANCE_ALERT,
+ DISMISS_ENVIRONMENTS_GUIDANCE_ALERT,
+} from '../mutation_types';
+
+export default {
+ [DETECT_ENVIRONMENTS_GUIDANCE_ALERT](state, stages) {
+ if (!stages) {
+ return;
+ }
+ const hasEnvironments = stages?.nodes?.some((stage) =>
+ stage.groups.nodes.some((group) => group.jobs.nodes.some((job) => job.environment)),
+ );
+ const hasParsedCi = Array.isArray(stages.nodes);
+
+ state.environmentsGuidanceAlertDetected = !hasEnvironments && hasParsedCi;
+ },
+ [DISMISS_ENVIRONMENTS_GUIDANCE_ALERT](state) {
+ state.environmentsGuidanceAlertDismissed = true;
+ },
+};
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index c1a83bf0726..83551e87f09 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -30,4 +30,6 @@ export default () => ({
renderWhitespaceInCode: false,
editorTheme: DEFAULT_THEME,
codesandboxBundlerUrl: null,
+ environmentsGuidanceAlertDismissed: false,
+ environmentsGuidanceAlertDetected: false,
});
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
index f337520b0db..3daa5eebcb6 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -1,5 +1,6 @@
<script>
import {
+ GlButton,
GlEmptyState,
GlDropdown,
GlDropdownItem,
@@ -8,10 +9,13 @@ import {
GlLoadingIcon,
GlSearchBoxByClick,
GlSprintf,
+ GlSafeHtmlDirective as SafeHtml,
+ GlTooltip,
} from '@gitlab/ui';
-import { s__, __ } from '~/locale';
+import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
-import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql';
+import { STATUSES } from '../../constants';
+import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql';
import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
@@ -23,6 +27,7 @@ const DEFAULT_PAGE_SIZE = PAGE_SIZES[0];
export default {
components: {
+ GlButton,
GlEmptyState,
GlDropdown,
GlDropdownItem,
@@ -31,9 +36,13 @@ export default {
GlLoadingIcon,
GlSearchBoxByClick,
GlSprintf,
+ GlTooltip,
ImportTableRow,
PaginationLinks,
},
+ directives: {
+ SafeHtml,
+ },
props: {
sourceUrl: {
@@ -65,12 +74,28 @@ export default {
},
computed: {
+ groups() {
+ return this.bulkImportSourceGroups?.nodes ?? [];
+ },
+
+ hasGroupsWithValidationError() {
+ return this.groups.some((g) => g.validation_errors.length);
+ },
+
+ availableGroupsForImport() {
+ return this.groups.filter((g) => g.progress.status === STATUSES.NONE);
+ },
+
+ isImportAllButtonDisabled() {
+ return this.hasGroupsWithValidationError || this.availableGroupsForImport.length === 0;
+ },
+
humanizedTotal() {
return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total;
},
hasGroups() {
- return this.bulkImportSourceGroups?.nodes?.length > 0;
+ return this.groups.length > 0;
},
hasEmptyFilter() {
@@ -105,6 +130,10 @@ export default {
},
methods: {
+ groupsCount(count) {
+ return n__('%d group', '%d groups', count);
+ },
+
setPage(page) {
this.page = page;
},
@@ -123,24 +152,57 @@ export default {
});
},
- importGroup(sourceGroupId) {
+ importGroups(sourceGroupIds) {
this.$apollo.mutate({
- mutation: importGroupMutation,
- variables: { sourceGroupId },
+ mutation: importGroupsMutation,
+ variables: { sourceGroupIds },
});
},
+ importAllGroups() {
+ this.importGroups(this.availableGroupsForImport.map((g) => g.id));
+ },
+
setPageSize(size) {
this.perPage = size;
},
},
+ gitlabLogo: window.gon.gitlab_logo,
PAGE_SIZES,
};
</script>
<template>
<div>
+ <h1
+ class="gl-my-0 gl-py-4 gl-font-size-h1 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
+ >
+ <img :src="$options.gitlabLogo" class="gl-w-6 gl-h-6 gl-mb-2 gl-display-inline gl-mr-2" />
+ {{ s__('BulkImport|Import groups from GitLab') }}
+ <div ref="importAllButtonWrapper" class="gl-ml-auto">
+ <gl-button
+ v-if="!$apollo.loading && hasGroups"
+ :disabled="isImportAllButtonDisabled"
+ variant="confirm"
+ @click="importAllGroups"
+ >
+ <gl-sprintf :message="s__('BulkImport|Import %{groups}')">
+ <template #groups>
+ {{ groupsCount(availableGroupsForImport.length) }}
+ </template>
+ </gl-sprintf>
+ </gl-button>
+ </div>
+ <gl-tooltip v-if="isImportAllButtonDisabled" :target="() => $refs.importAllButtonWrapper">
+ <template v-if="hasGroupsWithValidationError">
+ {{ s__('BulkImport|One or more groups has validation errors') }}
+ </template>
+ <template v-else>
+ {{ s__('BulkImport|No groups on this page are available for import') }}
+ </template>
+ </gl-tooltip>
+ </h1>
<div
class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
@@ -153,7 +215,7 @@ export default {
<strong>{{ paginationInfo.end }}</strong>
</template>
<template #total>
- <strong>{{ n__('%d group', '%d groups', paginationInfo.total) }}</strong>
+ <strong>{{ groupsCount(paginationInfo.total) }}</strong>
</template>
<template #filter>
<strong>{{ filter }}</strong>
@@ -180,7 +242,7 @@ export default {
:description="s__('Check your source instance permissions.')"
/>
<template v-else>
- <table class="gl-w-full">
+ <table class="gl-w-full" data-qa-selector="import_table">
<thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1">
<th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th>
<th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th>
@@ -196,7 +258,7 @@ export default {
:group-path-regex="groupPathRegex"
@update-target-namespace="updateTargetNamespace(group.id, $event)"
@update-new-name="updateNewName(group.id, $event)"
- @import-group="importGroup(group.id)"
+ @import-group="importGroups([group.id])"
/>
</template>
</tbody>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
index aed879e75fb..60cd5bb0a96 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
@@ -10,8 +10,11 @@ import {
GlFormInput,
} from '@gitlab/ui';
import { joinPaths } from '~/lib/utils/url_utility';
+import { s__ } from '~/locale';
import ImportStatus from '../../components/import_status.vue';
import { STATUSES } from '../../constants';
+import addValidationErrorMutation from '../graphql/mutations/add_validation_error.mutation.graphql';
+import removeValidationErrorMutation from '../graphql/mutations/remove_validation_error.mutation.graphql';
import groupQuery from '../graphql/queries/group.query.graphql';
const DEBOUNCE_INTERVAL = 300;
@@ -52,6 +55,27 @@ export default {
fullPath: this.fullPath,
};
},
+ update({ existingGroup }) {
+ const variables = {
+ field: 'new_name',
+ sourceGroupId: this.group.id,
+ };
+
+ if (!existingGroup) {
+ this.$apollo.mutate({
+ mutation: removeValidationErrorMutation,
+ variables,
+ });
+ } else {
+ this.$apollo.mutate({
+ mutation: addValidationErrorMutation,
+ variables: {
+ ...variables,
+ message: s__('BulkImport|Name already exists.'),
+ },
+ });
+ }
+ },
skip() {
return !this.isNameValid || this.isAlreadyImported;
},
@@ -63,8 +87,12 @@ export default {
return this.group.import_target;
},
+ invalidNameValidationMessage() {
+ return this.group.validation_errors.find(({ field }) => field === 'new_name')?.message;
+ },
+
isInvalid() {
- return Boolean(!this.isNameValid || this.existingGroup);
+ return Boolean(!this.isNameValid || this.invalidNameValidationMessage);
},
isNameValid() {
@@ -72,11 +100,11 @@ export default {
},
isAlreadyImported() {
- return this.group.status !== STATUSES.NONE;
+ return this.group.progress.status !== STATUSES.NONE;
},
isFinished() {
- return this.group.status === STATUSES.FINISHED;
+ return this.group.progress.status === STATUSES.FINISHED;
},
fullPath() {
@@ -91,7 +119,11 @@ export default {
</script>
<template>
- <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid">
+ <tr
+ class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"
+ data-qa-selector="import_item"
+ :data-qa-source-group="group.full_path"
+ >
<td class="gl-p-4">
<gl-link
:href="group.web_url"
@@ -122,6 +154,7 @@ export default {
:disabled="isAlreadyImported"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1"
+ data-qa-selector="target_namespace_selector_dropdown"
>
<gl-dropdown-item @click="$emit('update-target-namespace', '')">{{
s__('BulkImport|No parent')
@@ -134,6 +167,8 @@ export default {
<gl-dropdown-item
v-for="ns in availableNamespaces"
:key="ns.full_path"
+ data-qa-selector="target_group_dropdown_item"
+ :data-qa-group-name="ns.full_path"
@click="$emit('update-target-namespace', ns.full_path)"
>
{{ ns.full_path }}
@@ -157,22 +192,23 @@ export default {
<template v-if="!isNameValid">
{{ __('Please choose a group URL with no special characters.') }}
</template>
- <template v-else-if="existingGroup">
- {{ s__('BulkImport|Name already exists.') }}
+ <template v-else-if="invalidNameValidationMessage">
+ {{ invalidNameValidationMessage }}
</template>
</p>
</div>
</div>
</td>
- <td class="gl-p-4 gl-white-space-nowrap">
- <import-status :status="group.status" />
+ <td class="gl-p-4 gl-white-space-nowrap" data-qa-selector="import_status_indicator">
+ <import-status :status="group.progress.status" class="gl-mt-2" />
</td>
<td class="gl-p-4">
<gl-button
v-if="!isAlreadyImported"
:disabled="isInvalid"
- variant="success"
+ variant="confirm"
category="secondary"
+ data-qa-selector="import_group_button"
@click="$emit('import-group')"
>{{ __('Import') }}</gl-button
>
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
index d444cc77aa7..2cde3781a6a 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -4,40 +4,83 @@ import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { STATUSES } from '../../constants';
+import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
+import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql';
+import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
+import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql';
import { SourceGroupsManager } from './services/source_groups_manager';
import { StatusPoller } from './services/status_poller';
+import typeDefs from './typedefs.graphql';
export const clientTypenames = {
BulkImportSourceGroupConnection: 'ClientBulkImportSourceGroupConnection',
BulkImportSourceGroup: 'ClientBulkImportSourceGroup',
AvailableNamespace: 'ClientAvailableNamespace',
BulkImportPageInfo: 'ClientBulkImportPageInfo',
+ BulkImportTarget: 'ClientBulkImportTarget',
+ BulkImportProgress: 'ClientBulkImportProgress',
+ BulkImportValidationError: 'ClientBulkImportValidationError',
};
-export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
- let statusPoller;
+function makeGroup(data) {
+ const result = {
+ __typename: clientTypenames.BulkImportSourceGroup,
+ ...data,
+ };
+ const NESTED_OBJECT_FIELDS = {
+ import_target: clientTypenames.BulkImportTarget,
+ progress: clientTypenames.BulkImportProgress,
+ };
- let sourceGroupManager;
- const getGroupsManager = (client) => {
- if (!sourceGroupManager) {
- sourceGroupManager = new GroupsManager({ client, sourceUrl });
+ Object.entries(NESTED_OBJECT_FIELDS).forEach(([field, type]) => {
+ if (!data[field]) {
+ return;
}
- return sourceGroupManager;
- };
+ result[field] = {
+ __typename: type,
+ ...data[field],
+ };
+ });
+
+ return result;
+}
+
+const localProgressId = (id) => `not-started-${id}`;
+
+export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
+ const groupsManager = new GroupsManager({
+ sourceUrl,
+ });
+
+ let statusPoller;
return {
Query: {
+ async bulkImportSourceGroup(_, { id }, { client, getCacheKey }) {
+ return client.readFragment({
+ fragment: bulkImportSourceGroupItemFragment,
+ fragmentName: 'BulkImportSourceGroupItem',
+ id: getCacheKey({
+ __typename: clientTypenames.BulkImportSourceGroup,
+ id,
+ }),
+ });
+ },
+
async bulkImportSourceGroups(_, vars, { client }) {
if (!statusPoller) {
statusPoller = new StatusPoller({
- groupManager: getGroupsManager(client),
+ updateImportStatus: ({ id, status_name: status }) =>
+ client.mutate({
+ mutation: updateImportStatusMutation,
+ variables: { id, status },
+ }),
pollPath: endpoints.jobs,
});
statusPoller.startPolling();
}
- const groupsManager = getGroupsManager(client);
return Promise.all([
axios.get(endpoints.status, {
params: {
@@ -59,19 +102,21 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
return {
__typename: clientTypenames.BulkImportSourceGroupConnection,
nodes: data.importable_data.map((group) => {
- const cachedImportState = groupsManager.getImportStateFromStorageByGroupId(
- group.id,
- );
+ const { jobId, importState: cachedImportState } =
+ groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {};
- return {
- __typename: clientTypenames.BulkImportSourceGroup,
+ return makeGroup({
...group,
- status: cachedImportState?.status ?? STATUSES.NONE,
+ validation_errors: [],
+ progress: {
+ id: jobId ?? localProgressId(group.id),
+ status: cachedImportState?.status ?? STATUSES.NONE,
+ },
import_target: cachedImportState?.importTarget ?? {
new_name: group.full_path,
target_namespace: availableNamespaces[0]?.full_path ?? '',
},
- };
+ });
}),
pageInfo: {
__typename: clientTypenames.BulkImportPageInfo,
@@ -91,46 +136,149 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
),
},
Mutation: {
- setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) {
- getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
- // eslint-disable-next-line no-param-reassign
- sourceGroup.import_target.target_namespace = targetNamespace;
+ setTargetNamespace: (_, { targetNamespace, sourceGroupId }) =>
+ makeGroup({
+ id: sourceGroupId,
+ import_target: {
+ target_namespace: targetNamespace,
+ },
+ }),
+
+ setNewName: (_, { newName, sourceGroupId }) =>
+ makeGroup({
+ id: sourceGroupId,
+ import_target: {
+ new_name: newName,
+ },
+ }),
+
+ async setImportProgress(_, { sourceGroupId, status, jobId }) {
+ if (jobId) {
+ groupsManager.updateImportProgress(jobId, status);
+ }
+
+ return makeGroup({
+ id: sourceGroupId,
+ progress: {
+ id: jobId ?? localProgressId(sourceGroupId),
+ status,
+ },
});
},
- setNewName(_, { newName, sourceGroupId }, { client }) {
- getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
- // eslint-disable-next-line no-param-reassign
- sourceGroup.import_target.new_name = newName;
+ async updateImportStatus(_, { id, status }) {
+ groupsManager.updateImportProgress(id, status);
+
+ return {
+ __typename: clientTypenames.BulkImportProgress,
+ id,
+ status,
+ };
+ },
+
+ async addValidationError(_, { sourceGroupId, field, message }, { client }) {
+ const {
+ data: {
+ bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
+ },
+ } = await client.query({
+ query: bulkImportSourceGroupQuery,
+ variables: { id: sourceGroupId },
});
+
+ return {
+ ...group,
+ validation_errors: [
+ ...validationErrors.filter(({ field: f }) => f !== field),
+ {
+ __typename: clientTypenames.BulkImportValidationError,
+ field,
+ message,
+ },
+ ],
+ };
},
- async importGroup(_, { sourceGroupId }, { client }) {
- const groupManager = getGroupsManager(client);
- const group = groupManager.findById(sourceGroupId);
- groupManager.setImportStatus(group, STATUSES.SCHEDULING);
- try {
- const response = await axios.post(endpoints.createBulkImport, {
- bulk_import: [
- {
- source_type: 'group_entity',
- source_full_path: group.full_path,
- destination_namespace: group.import_target.target_namespace,
- destination_name: group.import_target.new_name,
- },
- ],
- });
- groupManager.startImport({ group, importId: response.data.id });
- } catch (e) {
- const message = e?.response?.data?.error ?? s__('BulkImport|Importing the group failed');
- createFlash({ message });
- groupManager.setImportStatus(group, STATUSES.NONE);
- throw e;
- }
+ async removeValidationError(_, { sourceGroupId, field }, { client }) {
+ const {
+ data: {
+ bulkImportSourceGroup: { validation_errors: validationErrors, ...group },
+ },
+ } = await client.query({
+ query: bulkImportSourceGroupQuery,
+ variables: { id: sourceGroupId },
+ });
+
+ return {
+ ...group,
+ validation_errors: validationErrors.filter(({ field: f }) => f !== field),
+ };
+ },
+
+ async importGroups(_, { sourceGroupIds }, { client }) {
+ const groups = await Promise.all(
+ sourceGroupIds.map((id) =>
+ client
+ .query({
+ query: bulkImportSourceGroupQuery,
+ variables: { id },
+ })
+ .then(({ data }) => data.bulkImportSourceGroup),
+ ),
+ );
+
+ const GROUPS_BEING_SCHEDULED = sourceGroupIds.map((sourceGroupId) =>
+ makeGroup({
+ id: sourceGroupId,
+ progress: {
+ id: localProgressId(sourceGroupId),
+ status: STATUSES.SCHEDULING,
+ },
+ }),
+ );
+
+ const defaultErrorMessage = s__('BulkImport|Importing the group failed');
+ axios
+ .post(endpoints.createBulkImport, {
+ bulk_import: groups.map((group) => ({
+ source_type: 'group_entity',
+ source_full_path: group.full_path,
+ destination_namespace: group.import_target.target_namespace,
+ destination_name: group.import_target.new_name,
+ })),
+ })
+ .then(({ data: { id: jobId } }) => {
+ groupsManager.createImportState(jobId, {
+ status: STATUSES.CREATED,
+ groups,
+ });
+
+ return { status: STATUSES.CREATED, jobId };
+ })
+ .catch((e) => {
+ const message = e?.response?.data?.error ?? defaultErrorMessage;
+ createFlash({ message });
+ return { status: STATUSES.NONE };
+ })
+ .then((newStatus) =>
+ sourceGroupIds.forEach((sourceGroupId) =>
+ client.mutate({
+ mutation: setImportProgressMutation,
+ variables: { sourceGroupId, ...newStatus },
+ }),
+ ),
+ )
+ .catch(() => createFlash({ message: defaultErrorMessage }));
+
+ return GROUPS_BEING_SCHEDULED;
},
},
};
}
export const createApolloClient = ({ sourceUrl, endpoints }) =>
- createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true });
+ createDefaultClient(
+ createResolvers({ sourceUrl, endpoints }),
+ { assumeImmutableResults: true },
+ typeDefs,
+ );
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
index 50774e36599..47675cd1bd0 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
@@ -1,8 +1,19 @@
+#import "./bulk_import_source_group_progress.fragment.graphql"
+
fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
id
web_url
full_path
full_name
- status
- import_target
+ progress {
+ ...BulkImportSourceGroupProgress
+ }
+ import_target {
+ target_namespace
+ new_name
+ }
+ validation_errors {
+ field
+ message
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql
new file mode 100644
index 00000000000..2d60bf82d65
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_progress.fragment.graphql
@@ -0,0 +1,4 @@
+fragment BulkImportSourceGroupProgress on ClientBulkImportProgress {
+ id
+ status
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql
new file mode 100644
index 00000000000..d95c460c046
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/add_validation_error.mutation.graphql
@@ -0,0 +1,9 @@
+mutation addValidationError($sourceGroupId: String!, $field: String!, $message: String!) {
+ addValidationError(sourceGroupId: $sourceGroupId, field: $field, message: $message) @client {
+ id
+ validation_errors {
+ field
+ message
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql
deleted file mode 100644
index 412608d3faf..00000000000
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-mutation importGroup($sourceGroupId: String!) {
- importGroup(sourceGroupId: $sourceGroupId) @client
-}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
new file mode 100644
index 00000000000..d8e46329e38
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql
@@ -0,0 +1,9 @@
+mutation importGroups($sourceGroupIds: [String!]!) {
+ importGroups(sourceGroupIds: $sourceGroupIds) @client {
+ id
+ progress {
+ id
+ status
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql
new file mode 100644
index 00000000000..940bf4dfaac
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/remove_validation_error.mutation.graphql
@@ -0,0 +1,9 @@
+mutation removeValidationError($sourceGroupId: String!, $field: String!) {
+ removeValidationError(sourceGroupId: $sourceGroupId, field: $field) @client {
+ id
+ validation_errors {
+ field
+ message
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
new file mode 100644
index 00000000000..2ec1269932a
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
@@ -0,0 +1,9 @@
+mutation setImportProgress($status: String!, $sourceGroupId: String!, $jobId: String) {
+ setImportProgress(status: $status, sourceGroupId: $sourceGroupId, jobId: $jobId) @client {
+ id
+ progress {
+ id
+ status
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql
index 2bc19891401..354bf2a5815 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql
@@ -1,3 +1,8 @@
mutation setNewName($newName: String!, $sourceGroupId: String!) {
- setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client
+ setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client {
+ id
+ import_target {
+ new_name
+ }
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql
index fc98a1652c1..a0ef407f135 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql
@@ -1,3 +1,8 @@
mutation setTargetNamespace($targetNamespace: String!, $sourceGroupId: String!) {
- setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client
+ setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client {
+ id
+ import_target {
+ target_namespace
+ }
+ }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql
new file mode 100644
index 00000000000..8c0233b2939
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql
@@ -0,0 +1,6 @@
+mutation updateImportStatus($status: String!, $id: String!) {
+ updateImportStatus(status: $status, id: $id) @client {
+ id
+ status
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql
new file mode 100644
index 00000000000..0aff23af96d
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_group.query.graphql
@@ -0,0 +1,7 @@
+#import "../fragments/bulk_import_source_group_item.fragment.graphql"
+
+query bulkImportSourceGroup($id: ID!) {
+ bulkImportSourceGroup(id: $id) @client {
+ ...BulkImportSourceGroupItem
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
index 2c88d25358f..97dbdbf518a 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
@@ -1,26 +1,10 @@
-import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
-import produce from 'immer';
import { debounce, merge } from 'lodash';
-import { STATUSES } from '../../../constants';
-import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql';
-
-function extractTypeConditionFromFragment(fragment) {
- return fragment.definitions[0]?.typeCondition.name.value;
-}
-
-function generateGroupId(id) {
- return defaultDataIdFromObject({
- __typename: extractTypeConditionFromFragment(ImportSourceGroupFragment),
- id,
- });
-}
export const KEY = 'gl-bulk-imports-import-state';
export const DEBOUNCE_INTERVAL = 200;
export class SourceGroupsManager {
- constructor({ client, sourceUrl, storage = window.localStorage }) {
- this.client = client;
+ constructor({ sourceUrl, storage = window.localStorage }) {
this.sourceUrl = sourceUrl;
this.storage = storage;
@@ -29,51 +13,58 @@ export class SourceGroupsManager {
loadImportStatesFromStorage() {
try {
- return JSON.parse(this.storage.getItem(KEY)) ?? {};
+ return Object.fromEntries(
+ Object.entries(JSON.parse(this.storage.getItem(KEY)) ?? {}).map(([jobId, config]) => {
+ // new format of storage
+ if (config.groups) {
+ return [jobId, config];
+ }
+
+ return [
+ jobId,
+ {
+ status: config.status,
+ groups: [{ id: config.id, importTarget: config.importTarget }],
+ },
+ ];
+ }),
+ );
} catch {
return {};
}
}
- findById(id) {
- const cacheId = generateGroupId(id);
- return this.client.readFragment({ fragment: ImportSourceGroupFragment, id: cacheId });
- }
-
- update(group, fn) {
- this.client.writeFragment({
- fragment: ImportSourceGroupFragment,
- id: generateGroupId(group.id),
- data: produce(group, fn),
- });
- }
-
- updateById(id, fn) {
- const group = this.findById(id);
- this.update(group, fn);
- }
-
- saveImportState(importId, group) {
+ createImportState(importId, jobConfig) {
this.importStates[this.getStorageKey(importId)] = {
- id: group.id,
- importTarget: group.import_target,
- status: group.status,
+ status: jobConfig.status,
+ groups: jobConfig.groups.map((g) => ({ importTarget: g.import_target, id: g.id })),
};
this.saveImportStatesToStorage();
}
- getImportStateFromStorage(importId) {
- return this.importStates[this.getStorageKey(importId)];
+ updateImportProgress(importId, status) {
+ const currentState = this.importStates[this.getStorageKey(importId)];
+ if (!currentState) {
+ return;
+ }
+
+ currentState.status = status;
+ this.saveImportStatesToStorage();
}
getImportStateFromStorageByGroupId(groupId) {
const PREFIX = this.getStorageKey('');
- const [, importState] =
+ const [jobId, importState] =
Object.entries(this.importStates).find(
- ([key, group]) => key.startsWith(PREFIX) && group.id === groupId,
+ ([key, state]) => key.startsWith(PREFIX) && state.groups.some((g) => g.id === groupId),
) ?? [];
- return importState;
+ if (!jobId) {
+ return null;
+ }
+
+ const group = importState.groups.find((g) => g.id === groupId);
+ return { jobId, importState: { ...group, status: importState.status } };
}
getStorageKey(importId) {
@@ -91,34 +82,4 @@ export class SourceGroupsManager {
// empty catch intentional: storage might be unavailable or full
}
}, DEBOUNCE_INTERVAL);
-
- startImport({ group, importId }) {
- this.setImportStatus(group, STATUSES.CREATED);
- this.saveImportState(importId, group);
- }
-
- setImportStatus(group, status) {
- this.update(group, (sourceGroup) => {
- // eslint-disable-next-line no-param-reassign
- sourceGroup.status = status;
- });
- }
-
- setImportStatusByImportId(importId, status) {
- const importState = this.getImportStateFromStorage(importId);
- if (!importState) {
- return;
- }
-
- if (importState.status !== status) {
- importState.status = status;
- }
-
- const group = this.findById(importState.id);
- if (group?.id) {
- this.setImportStatus(group, status);
- }
-
- this.saveImportStatesToStorage();
- }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
index b80a575afce..0297b3d3428 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
@@ -5,13 +5,15 @@ import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
export class StatusPoller {
- constructor({ groupManager, pollPath }) {
+ constructor({ updateImportStatus, pollPath }) {
this.eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(pollPath),
},
method: 'fetchJobs',
- successCallback: ({ data }) => this.updateImportsStatuses(data),
+ successCallback: ({ data: statuses }) => {
+ statuses.forEach((status) => updateImportStatus(status));
+ },
errorCallback: () =>
createFlash({
message: s__('BulkImport|Update of import statuses with realtime changes failed'),
@@ -25,17 +27,9 @@ export class StatusPoller {
this.eTagPoll.stop();
}
});
-
- this.groupManager = groupManager;
}
startPolling() {
this.eTagPoll.makeRequest();
}
-
- async updateImportsStatuses(importStatuses) {
- importStatuses.forEach(({ id, status_name: statusName }) => {
- this.groupManager.setImportStatusByImportId(id, statusName);
- });
- }
}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
new file mode 100644
index 00000000000..c830aaa75e6
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
@@ -0,0 +1,65 @@
+type ClientBulkImportAvailableNamespace {
+ id: ID!
+ full_path: String!
+}
+
+type ClientBulkImportTarget {
+ target_namespace: String!
+ new_name: String!
+}
+
+type ClientBulkImportSourceGroupConnection {
+ nodes: [ClientBulkImportSourceGroup!]!
+ pageInfo: ClientBulkImportPageInfo!
+}
+
+type ClientBulkImportProgress {
+ id: ID
+ status: String!
+}
+
+type ClientBulkImportValidationError {
+ field: String!
+ message: String!
+}
+
+type ClientBulkImportSourceGroup {
+ id: ID!
+ web_url: String!
+ full_path: String!
+ full_name: String!
+ progress: ClientBulkImportProgress!
+ import_target: ClientBulkImportTarget!
+ validation_errors: [ClientBulkImportValidationError!]!
+}
+
+type ClientBulkImportPageInfo {
+ page: Int!
+ perPage: Int!
+ total: Int!
+ totalPages: Int!
+}
+
+extend type Query {
+ bulkImportSourceGroup(id: ID!): ClientBulkImportSourceGroup
+ bulkImportSourceGroups(
+ page: Int!
+ perPage: Int!
+ filter: String!
+ ): ClientBulkImportSourceGroupConnection!
+ availableNamespaces: [ClientBulkImportAvailableNamespace!]!
+}
+
+extend type Mutation {
+ setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
+ setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
+ importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
+ setImportProgress(id: ID, status: String!): ClientBulkImportSourceGroup!
+ updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
+ addValidationError(
+ sourceGroupId: ID!
+ field: String!
+ message: String!
+ ): ClientBulkImportSourceGroup!
+ removeValidationError(sourceGroupId: ID!, field: String!): ClientBulkImportSourceGroup!
+}
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 5638dc064d1..af99341b11f 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -10,7 +10,6 @@ import {
GlIcon,
GlEmptyState,
} from '@gitlab/ui';
-import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
@@ -49,6 +48,7 @@ export default {
label: s__('IncidentManagement|Severity'),
thClass: `${thClass} w-15p`,
tdClass: `${tdClass} sortable-cell`,
+ actualSortKey: 'SEVERITY',
sortable: true,
thAttr: TH_SEVERITY_TEST_ID,
},
@@ -63,6 +63,7 @@ export default {
label: s__('IncidentManagement|Date created'),
thClass: `${thClass} gl-w-eighth`,
tdClass: `${tdClass} sortable-cell`,
+ actualSortKey: 'CREATED',
sortable: true,
thAttr: TH_CREATED_AT_TEST_ID,
},
@@ -72,7 +73,7 @@ export default {
thClass: `gl-text-right gl-w-eighth`,
tdClass: `${tdClass} gl-text-right`,
thAttr: TH_INCIDENT_SLA_TEST_ID,
- sortKey: 'SLA_DUE_AT',
+ actualSortKey: 'SLA_DUE_AT',
sortable: true,
sortDirection: 'asc',
},
@@ -87,6 +88,7 @@ export default {
label: s__('IncidentManagement|Published'),
thClass: `${thClass} w-15p`,
tdClass: `${tdClass} sortable-cell`,
+ actualSortKey: 'PUBLISHED',
sortable: true,
thAttr: TH_PUBLISHED_TEST_ID,
},
@@ -174,8 +176,7 @@ export default {
redirecting: false,
incidents: {},
incidentsCount: {},
- sort: 'created_desc',
- sortBy: 'createdAt',
+ sort: 'CREATED_DESC',
sortDesc: true,
statusFilter: '',
filteredByStatus: '',
@@ -256,20 +257,17 @@ export default {
this.redirecting = true;
},
fetchSortedData({ sortBy, sortDesc }) {
- let sortKey;
- // In bootstrap-vue v2.17.0, sortKey becomes natively supported and we can eliminate this function
const field = this.availableFields.find(({ key }) => key === sortBy);
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
- // Use `sortKey` if provided, otherwise fall back to existing algorithm
- if (field?.sortKey) {
- sortKey = field.sortKey;
- } else {
- sortKey = convertToSnakeCase(sortBy).replace(/_.*/, '').toUpperCase();
- }
-
this.pagination = initialPaginationState;
- this.sort = `${sortKey}_${sortingDirection}`;
+
+ // BootstapVue natively supports a `sortKey` parameter, but using it results in the sorting
+ // icons not being updated properly in the header. We decided to fallback on `actualSortKey`
+ // to bypass BootstrapVue's behavior until the bug is addressed upstream.
+ // Related discussion: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60926/diffs#note_568020482
+ // Upstream issue: https://github.com/bootstrap-vue/bootstrap-vue/issues/6602
+ this.sort = `${field.actualSortKey}_${sortingDirection}`;
},
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
@@ -334,14 +332,14 @@ export default {
<gl-table
:items="incidents.list || []"
:fields="availableFields"
- :show-empty="true"
:busy="loading"
stacked="md"
:tbody-tr-class="tbodyTrClass"
- :no-local-sorting="true"
- :sort-direction="'desc'"
+ sort-direction="desc"
:sort-desc.sync="sortDesc"
- :sort-by.sync="sortBy"
+ sort-by="createdAt"
+ show-empty
+ no-local-sorting
sort-icon-left
fixed
@row-clicked="navigateToIncidentDetails"
diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js
index 577d8ecb777..d479838b491 100644
--- a/app/assets/javascripts/incidents_settings/constants.js
+++ b/app/assets/javascripts/incidents_settings/constants.js
@@ -43,7 +43,9 @@ export const I18N_ALERT_SETTINGS_FORM = {
label: __('Send a single email notification to Owners and Maintainers for new alerts.'),
},
autoCloseIncidents: {
- label: __('Automatically close incidents when the associated Prometheus alert resolves.'),
+ label: __(
+ 'Automatically close associated incident when a recovery alert notification resolves an alert',
+ ),
},
};
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 59038b3d9fb..17c73fdf1c3 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -1,7 +1,6 @@
/* eslint-disable no-new */
import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar';
-import DueDateSelectors from './due_date_select';
import IssuableContext from './issuable_context';
import LabelsSelect from './labels_select';
import MilestoneSelect from './milestone_select';
@@ -19,7 +18,6 @@ export default () => {
});
new LabelsSelect();
new IssuableContext(sidebarOptions.currentUser);
- new DueDateSelectors();
Sidebar.initialize();
mountSidebarLabels();
diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
index 1e33ceb7835..f7d7f4aa010 100644
--- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue
@@ -37,7 +37,6 @@ export default {
<input name="service[active]" type="hidden" :value="activated || false" />
<gl-form-checkbox
v-model="activated"
- name="service[active]"
class="gl-display-block"
:disabled="isInheriting"
@change="onChange"
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 aea4a8b1c0b..9bc01cdd9fc 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue
@@ -1,6 +1,5 @@
<script>
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';
@@ -16,7 +15,6 @@ export default {
JiraIssueCreationVulnerabilities: () =>
import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'),
},
- mixins: [glFeatureFlagsMixin()],
props: {
showJiraIssuesIntegration: {
type: Boolean,
@@ -76,7 +74,7 @@ export default {
return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated;
},
showJiraVulnerabilitiesOptions() {
- return this.showJiraVulnerabilitiesIntegration && this.glFeatures.jiraForVulnerabilities;
+ return this.showJiraVulnerabilitiesIntegration;
},
showUltimateUpgrade() {
return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration;
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 b0f19e5b585..93d8bcc4c19 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -16,13 +16,13 @@ const commentDetailOptions = [
{
value: 'standard',
label: s__('Integrations|Standard'),
- help: s__('Integrations|Includes commit title and branch'),
+ help: s__('Integrations|Includes commit title and branch.'),
},
{
value: 'all_details',
label: s__('Integrations|All details'),
help: s__(
- 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs',
+ 'Integrations|Includes Standard, plus the entire commit message, commit hash, and issue IDs',
),
},
];
@@ -144,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) is created.',
+ 'Integrations|When you mention a Jira issue in a commit or merge request, GitLab creates a remote link and comment (if enabled).',
)
"
>
diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue
deleted file mode 100644
index ec77e49ae53..00000000000
--- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue
+++ /dev/null
@@ -1,67 +0,0 @@
-<script>
-import { GlModal, GlLink } from '@gitlab/ui';
-import { BV_SHOW_MODAL } from '~/lib/utils/constants';
-import { s__, __ } from '~/locale';
-import { OPEN_MODAL, MODAL_ID } from '../constants';
-import eventHub from '../event_hub';
-
-export default {
- cancelProps: {
- text: __('Got it'),
- attributes: [
- {
- variant: 'info',
- },
- ],
- },
- modalId: MODAL_ID,
- components: {
- GlLink,
- GlModal,
- },
- props: {
- membersPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- i18n: {
- modalTitle: s__("InviteMember|Oops, this feature isn't ready yet"),
- bodyTopMessage: s__(
- "InviteMember|We're working to allow everyone to invite new members, making it easier for teams to get started with GitLab",
- ),
- bodyMiddleMessage: s__(
- 'InviteMember|Until then, ask an owner to invite new project members for you',
- ),
- linkText: s__('InviteMember|See who can invite members for you'),
- },
- mounted() {
- eventHub.$on(OPEN_MODAL, this.openModal);
- },
- methods: {
- openModal() {
- this.$root.$emit(BV_SHOW_MODAL, MODAL_ID);
- },
- },
-};
-</script>
-<template>
- <gl-modal :modal-id="$options.modalId" size="sm" :action-cancel="$options.cancelProps">
- <template #modal-title>
- {{ $options.i18n.modalTitle }}
- <gl-emoji
- class="gl-vertical-align-baseline font-size-inherit gl-mr-1"
- data-name="sweat_smile"
- />
- </template>
- <p>{{ $options.i18n.bodyTopMessage }}</p>
- <p>{{ $options.i18n.bodyMiddleMessage }}</p>
- <gl-link
- :href="membersPath"
- data-track-event="click_who_can_invite_link"
- data-track-label="invite_members_message"
- >{{ $options.i18n.linkText }}</gl-link
- >
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
deleted file mode 100644
index ee89e0bbf71..00000000000
--- a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-<script>
-import { GlLink } from '@gitlab/ui';
-import { OPEN_MODAL } from '../constants';
-import eventHub from '../event_hub';
-
-export default {
- components: {
- GlLink,
- },
- props: {
- displayText: {
- type: String,
- required: false,
- default: '',
- },
- event: {
- type: String,
- required: false,
- default: '',
- },
- label: {
- type: String,
- required: false,
- default: '',
- },
- },
- methods: {
- openModal() {
- eventHub.$emit(OPEN_MODAL);
- },
- },
-};
-</script>
-
-<template>
- <gl-link
- data-is-link="true"
- :data-track-event="event"
- :data-track-label="label"
- @click="openModal"
- >{{ displayText }}
- </gl-link>
-</template>
diff --git a/app/assets/javascripts/invite_member/constants.js b/app/assets/javascripts/invite_member/constants.js
deleted file mode 100644
index fee6e7a260a..00000000000
--- a/app/assets/javascripts/invite_member/constants.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const OPEN_MODAL = 'openModal';
-export const MODAL_ID = 'invite-member-modal';
diff --git a/app/assets/javascripts/invite_member/event_hub.js b/app/assets/javascripts/invite_member/event_hub.js
deleted file mode 100644
index e31806ad199..00000000000
--- a/app/assets/javascripts/invite_member/event_hub.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-export default createEventHub();
diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js
deleted file mode 100644
index a50d31c9e7a..00000000000
--- a/app/assets/javascripts/invite_member/init_invite_member_modal.js
+++ /dev/null
@@ -1,27 +0,0 @@
-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 || isAssigneesWidgetShown) {
- return false;
- }
-
- const { membersPath } = el.dataset;
-
- return new Vue({
- el,
- 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
deleted file mode 100644
index eb765ae83b0..00000000000
--- a/app/assets/javascripts/invite_member/init_invite_member_trigger.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import Vue from 'vue';
-import InviteMemberTrigger from './components/invite_member_trigger.vue';
-
-export default function initInviteMembersTrigger() {
- const el = document.querySelector('.js-invite-member-trigger');
-
- if (!el) {
- return false;
- }
-
- return new Vue({
- el,
- render: (createElement) =>
- createElement(InviteMemberTrigger, {
- props: { ...el.dataset },
- }),
- });
-}
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 7bdd55ddda3..f17440a4a14 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -21,13 +21,11 @@ export default {
props: {
exportCsvPath: {
type: String,
- required: false,
- default: '',
+ required: true,
},
issuableCount: {
type: Number,
- required: false,
- default: 0,
+ required: true,
},
modalId: {
type: String,
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
new file mode 100644
index 00000000000..cb768f2bc5b
--- /dev/null
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import Vue from 'vue';
+import { fetchPolicies } from '~/lib/graphql';
+import { __ } from '~/locale';
+
+export const statusBoxState = Vue.observable({
+ state: '',
+ updateStatus: null,
+});
+
+const CLASSES = {
+ opened: 'status-box-open',
+ locked: 'status-box-open',
+ closed: 'status-box-mr-closed',
+ merged: 'status-box-mr-merged',
+};
+
+const STATUS = {
+ opened: [__('Open'), 'issue-open-m'],
+ locked: [__('Open'), 'issue-open-m'],
+ closed: [__('Closed'), 'issue-close'],
+ merged: [__('Merged'), 'git-merge'],
+};
+
+export default {
+ components: {
+ GlIcon,
+ },
+ inject: {
+ query: { default: null },
+ projectPath: { default: null },
+ iid: { default: null },
+ },
+ props: {
+ initialState: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ if (this.initialState) {
+ statusBoxState.state = this.initialState;
+ }
+
+ return statusBoxState;
+ },
+ computed: {
+ statusBoxClass() {
+ return CLASSES[`${this.issuableType}_${this.state}`] || CLASSES[this.state];
+ },
+ statusHumanName() {
+ return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[0];
+ },
+ statusIconName() {
+ return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[1];
+ },
+ },
+ created() {
+ if (!statusBoxState.updateStatus) {
+ statusBoxState.updateStatus = this.fetchState;
+ }
+ },
+ beforeDestroy() {
+ if (statusBoxState.updateStatus && this.query) {
+ statusBoxState.updateStatus = null;
+ }
+ },
+ methods: {
+ async fetchState() {
+ const { data } = await this.$apollo.query({
+ query: this.query,
+ variables: {
+ projectPath: this.projectPath,
+ iid: this.iid,
+ },
+ fetchPolicy: fetchPolicies.NO_CACHE,
+ });
+
+ statusBoxState.state = data?.workspace?.issuable?.state;
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="statusBoxClass" class="issuable-status-box status-box">
+ <gl-icon
+ :name="statusIconName"
+ class="gl-display-block gl-sm-display-none!"
+ data-testid="status-icon"
+ />
+ <span class="gl-display-none gl-sm-display-block">
+ {{ statusHumanName }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 153123a005f..9a1ab23e366 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -54,9 +54,9 @@ export default class IssuableForm {
this.wipRegex = new RegExp(
'^\\s*(' + // Line start, then any amount of leading whitespace
'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace
- '|\\[(draft|wip)\\]\\s*' + // [Draft] or [WIP] and any following whitespace
- '|(draft|wip):\\s*' + // Draft: or WIP: and any following whitespace
- '|(draft|wip)\\s+' + // Draft_ or WIP_ where "_" is at least one whitespace
+ '|\\[draft\\]\\s*' + // [Draft] or [WIP] and any following whitespace
+ '|draft:\\s*' + // Draft: or WIP: and any following whitespace
+ '|draft\\s+' + // Draft_ or WIP_ where "_" is at least one whitespace
'|\\(draft\\)\\s*' + // (Draft) and any following whitespace
')+' + // At least one repeated match of the preceding parenthetical
'\\s*', // Any amount of trailing whitespace
@@ -146,18 +146,12 @@ export default class IssuableForm {
workInProgress() {
return this.wipRegex.test(this.titleField.val());
}
- titlePrefixContainsDraft() {
- const prefix = this.titleField.val().match(this.wipRegex);
-
- return prefix && prefix[0].match(/draft/i);
- }
renderWipExplanation() {
if (this.workInProgress()) {
// These strings are not "translatable" (the code is hard-coded to look for them)
- this.$wipExplanation.find('code')[0].textContent = this.titlePrefixContainsDraft()
- ? 'Draft' /* eslint-disable-line @gitlab/require-i18n-strings */
- : 'WIP';
+ this.$wipExplanation.find('code')[0].textContent =
+ 'Draft'; /* eslint-disable-line @gitlab/require-i18n-strings */
this.$wipExplanation.show();
return this.$noWipExplanation.hide();
}
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 5d497369f5a..7635536c54f 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -3,7 +3,7 @@ import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gi
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import { getTimeago } from '~/lib/utils/datetime_utility';
+import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/datetime_utility';
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
@@ -50,6 +50,10 @@ export default {
},
},
computed: {
+ createdInPastDay() {
+ const createdSecondsAgo = differenceInSeconds(new Date(this.issuable.createdAt), new Date());
+ return createdSecondsAgo < SECONDS_IN_DAY;
+ },
author() {
return this.issuable.author;
},
@@ -152,7 +156,12 @@ export default {
</script>
<template>
- <li :id="`issuable_${issuable.id}`" class="issue gl-px-5!" :data-labels="labelIdsString">
+ <li
+ :id="`issuable_${issuable.id}`"
+ class="issue gl-px-5!"
+ :class="{ closed: issuable.closedAt, today: createdInPastDay }"
+ :data-labels="labelIdsString"
+ >
<div class="issuable-info-container">
<div v-if="showCheckbox" class="issue-check">
<gl-form-checkbox
@@ -160,7 +169,9 @@ export default {
:checked="checked"
:data-id="issuable.id"
@input="$emit('checked-input', $event)"
- />
+ >
+ <span class="gl-sr-only">{{ issuable.title }}</span>
+ </gl-form-checkbox>
</div>
<div class="issuable-main-info">
<div data-testid="issuable-title" class="issue-title title">
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 6b95c3a578e..45584205be0 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue
@@ -274,44 +274,47 @@ export default {
<gl-skeleton-loading />
</li>
</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"
- :label-filter-param="labelFilterParam"
- :show-checkbox="showBulkEditSidebar"
- :checked="issuableChecked(issuable)"
- @checked-input="handleIssuableCheckedInput(issuable, $event)"
+ <template v-else>
+ <component
+ :is="issuablesWrapper"
+ v-if="issuables.length > 0"
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ v-bind="$options.vueDraggableAttributes"
+ @update="handleVueDraggableUpdate"
>
- <template #reference>
- <slot name="reference" :issuable="issuable"></slot>
- </template>
- <template #author>
- <slot name="author" :author="issuable.author"></slot>
- </template>
- <template #timeframe>
- <slot name="timeframe" :issuable="issuable"></slot>
- </template>
- <template #status>
- <slot name="status" :issuable="issuable"></slot>
- </template>
- <template #statistics>
- <slot name="statistics" :issuable="issuable"></slot>
- </template>
- </issuable-item>
- </component>
- <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot>
+ <issuable-item
+ v-for="issuable in issuables"
+ :key="issuableId(issuable)"
+ :class="{ 'gl-cursor-grab': isManualOrdering }"
+ :issuable-symbol="issuableSymbol"
+ :issuable="issuable"
+ :enable-label-permalinks="enableLabelPermalinks"
+ :label-filter-param="labelFilterParam"
+ :show-checkbox="showBulkEditSidebar"
+ :checked="issuableChecked(issuable)"
+ @checked-input="handleIssuableCheckedInput(issuable, $event)"
+ >
+ <template #reference>
+ <slot name="reference" :issuable="issuable"></slot>
+ </template>
+ <template #author>
+ <slot name="author" :author="issuable.author"></slot>
+ </template>
+ <template #timeframe>
+ <slot name="timeframe" :issuable="issuable"></slot>
+ </template>
+ <template #status>
+ <slot name="status" :issuable="issuable"></slot>
+ </template>
+ <template #statistics>
+ <slot name="statistics" :issuable="issuable"></slot>
+ </template>
+ </issuable-item>
+ </component>
+ <slot v-else name="empty-state"></slot>
+ </template>
+
<gl-pagination
v-if="showPaginationControls"
:per-page="defaultPageSize"
diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
index 6bc621b52e6..dfe158ae2b0 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue
@@ -48,12 +48,13 @@ export default {
<template #title>
<span :title="tab.titleTooltip">{{ tab.title }}</span>
<gl-badge
- v-if="isTabCountNumeric(tab)"
+ v-if="tabCounts && isTabCountNumeric(tab)"
variant="neutral"
size="sm"
class="gl-tab-counter-badge"
- >{{ tabCounts[tab.name] }}</gl-badge
>
+ {{ tabCounts[tab.name] }}
+ </gl-badge>
</template>
</gl-tab>
</gl-tabs>
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index d153ff21a35..01b4e81a11a 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -418,6 +418,7 @@ export default {
<div v-if="canUpdate && showForm">
<form-component
:form-state="formState"
+ :initial-description-text="initialDescriptionText"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index 570bc7df3cf..14df87e486b 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -53,6 +53,7 @@ export default {
</script>
<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues">
<button
ref="toggle"
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 76ea489fb86..b37a911a669 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -1,4 +1,5 @@
<script>
+import { GlAlert } from '@gitlab/ui';
import $ from 'jquery';
import Autosave from '~/autosave';
import eventHub from '../event_hub';
@@ -15,6 +16,7 @@ export default {
descriptionField,
descriptionTemplate,
editActions,
+ GlAlert,
},
props: {
canDestroy: {
@@ -69,6 +71,16 @@ export default {
required: false,
default: true,
},
+ initialDescriptionText: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showOutdatedDescriptionWarning: false,
+ };
},
computed: {
hasIssuableTemplates() {
@@ -102,11 +114,17 @@ export default {
},
} = this.$refs;
- this.autosaveDescription = new Autosave($(textarea), [
- document.location.pathname,
- document.location.search,
- 'description',
- ]);
+ this.autosaveDescription = new Autosave(
+ $(textarea),
+ [document.location.pathname, document.location.search, 'description'],
+ null,
+ this.formState.lock_version,
+ );
+
+ const savedLockVersion = this.autosaveDescription.getSavedLockVersion();
+
+ this.showOutdatedDescriptionWarning =
+ savedLockVersion && String(this.formState.lock_version) !== savedLockVersion;
this.autosaveTitle = new Autosave($(input), [
document.location.pathname,
@@ -118,6 +136,27 @@ export default {
this.autosaveDescription.reset();
this.autosaveTitle.reset();
},
+ keepAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ } = this.$refs;
+
+ textarea.focus();
+ this.showOutdatedDescriptionWarning = false;
+ },
+ discardAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ } = this.$refs;
+
+ textarea.value = this.initialDescriptionText;
+ textarea.focus();
+ this.showOutdatedDescriptionWarning = false;
+ },
},
};
</script>
@@ -125,6 +164,21 @@ export default {
<template>
<form>
<locked-warning v-if="showLockedWarning" />
+ <gl-alert
+ v-if="showOutdatedDescriptionWarning"
+ class="gl-mb-5"
+ variant="warning"
+ :primary-button-text="__('Keep')"
+ :secondary-button-text="__('Discard')"
+ :dismissible="false"
+ @primaryAction="keepAutosave"
+ @secondaryAction="discardAutosave"
+ >{{
+ __(
+ 'The comment you are editing has been changed by another user. Would you like to keep your changes and overwrite the new description or discard your changes?',
+ )
+ }}</gl-alert
+ >
<div class="row">
<div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3">
<description-template
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index 57c5107fcbb..93ba338a6b3 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -1,56 +1,72 @@
<script>
-import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlEmptyState,
+ GlFilteredSearchToken,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
+import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
+ API_PARAM,
+ apiSortParams,
CREATED_DESC,
+ i18n,
+ MAX_LIST_SIZE,
PAGE_SIZE,
- RELATIVE_POSITION_ASC,
- sortOptions,
- sortParams,
+ PARAM_DUE_DATE,
+ PARAM_PAGE,
+ PARAM_SORT,
+ PARAM_STATE,
+ RELATIVE_POSITION_DESC,
+ UPDATED_DESC,
+ URL_PARAM,
+ urlSortParams,
} from '~/issues_list/constants';
+import {
+ convertToParams,
+ convertToSearchQuery,
+ getDueDateValue,
+ getFilterTokens,
+ getSortKey,
+ getSortOptions,
+} from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
-import { __, s__ } from '~/locale';
+import {
+ DEFAULT_NONE_ANY,
+ OPERATOR_IS_ONLY,
+ TOKEN_TITLE_ASSIGNEE,
+ TOKEN_TITLE_AUTHOR,
+ TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_EPIC,
+ TOKEN_TITLE_ITERATION,
+ TOKEN_TITLE_LABEL,
+ TOKEN_TITLE_MILESTONE,
+ TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_WEIGHT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
+import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
+import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
+import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
+import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
+import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
+import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
+import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue';
export default {
- CREATED_DESC,
+ i18n,
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,
@@ -58,6 +74,7 @@ export default {
GlIcon,
GlLink,
GlSprintf,
+ IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
@@ -66,6 +83,12 @@ export default {
GlTooltip: GlTooltipDirective,
},
inject: {
+ autocompleteAwardEmojisPath: {
+ default: '',
+ },
+ autocompleteUsersPath: {
+ default: '',
+ },
calendarPath: {
default: '',
},
@@ -81,12 +104,24 @@ export default {
exportCsvPath: {
default: '',
},
- fullPath: {
+ groupEpicsPath: {
default: '',
},
+ hasBlockedIssuesFeature: {
+ default: false,
+ },
hasIssues: {
default: false,
},
+ hasIssueWeightsFeature: {
+ default: false,
+ },
+ hasMultipleIssueAssigneesFeature: {
+ default: false,
+ },
+ initialEmail: {
+ default: '',
+ },
isSignedIn: {
default: false,
},
@@ -99,6 +134,18 @@ export default {
newIssuePath: {
default: '',
},
+ projectIterationsPath: {
+ default: '',
+ },
+ projectLabelsPath: {
+ default: '',
+ },
+ projectMilestonesPath: {
+ default: '',
+ },
+ projectPath: {
+ default: '',
+ },
rssPath: {
default: '',
},
@@ -110,51 +157,143 @@ export 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,
- },
- }));
+ const state = getParameterByName(PARAM_STATE);
+ const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
return {
+ dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
- filters: sortParams[sortKey] || {},
- filterTokens: tokens,
+ filterTokens: getFilterTokens(window.location.search),
isLoading: false,
issues: [],
- page: toNumber(getParameterByName('page')) || 1,
+ page: toNumber(getParameterByName(PARAM_PAGE)) || 1,
showBulkEditSidebar: false,
- sortKey: sortKey || CREATED_DESC,
- state: getParameterByName('state') || IssuableStates.Opened,
+ sortKey: getSortKey(getParameterByName(PARAM_SORT)) || defaultSortKey,
+ state: state || IssuableStates.Opened,
totalIssues: 0,
};
},
computed: {
+ isBulkEditButtonDisabled() {
+ return this.showBulkEditSidebar || !this.issues.length;
+ },
isManualOrdering() {
- return this.sortKey === RELATIVE_POSITION_ASC;
+ return this.sortKey === RELATIVE_POSITION_DESC;
},
isOpenTab() {
return this.state === IssuableStates.Opened;
},
+ apiFilterParams() {
+ return convertToParams(this.filterTokens, API_PARAM);
+ },
+ urlFilterParams() {
+ return convertToParams(this.filterTokens, URL_PARAM);
+ },
searchQuery() {
- return (
- this.filterTokens
- .map((searchTerm) => searchTerm.value.data)
- .filter((searchWord) => Boolean(searchWord))
- .join(' ') || undefined
- );
+ return convertToSearchQuery(this.filterTokens) || undefined;
+ },
+ searchTokens() {
+ const tokens = [
+ {
+ type: 'author_username',
+ title: TOKEN_TITLE_AUTHOR,
+ icon: 'pencil',
+ token: AuthorToken,
+ dataType: 'user',
+ unique: true,
+ defaultAuthors: [],
+ fetchAuthors: this.fetchUsers,
+ },
+ {
+ type: 'assignee_username',
+ title: TOKEN_TITLE_ASSIGNEE,
+ icon: 'user',
+ token: AuthorToken,
+ dataType: 'user',
+ unique: !this.hasMultipleIssueAssigneesFeature,
+ defaultAuthors: DEFAULT_NONE_ANY,
+ fetchAuthors: this.fetchUsers,
+ },
+ {
+ type: 'milestone',
+ title: TOKEN_TITLE_MILESTONE,
+ icon: 'clock',
+ token: MilestoneToken,
+ unique: true,
+ defaultMilestones: [],
+ fetchMilestones: this.fetchMilestones,
+ },
+ {
+ type: 'labels',
+ title: TOKEN_TITLE_LABEL,
+ icon: 'labels',
+ token: LabelToken,
+ defaultLabels: [],
+ fetchLabels: this.fetchLabels,
+ },
+ {
+ type: 'my_reaction_emoji',
+ title: TOKEN_TITLE_MY_REACTION,
+ icon: 'thumb-up',
+ token: EmojiToken,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ fetchEmojis: this.fetchEmojis,
+ },
+ {
+ type: 'confidential',
+ title: TOKEN_TITLE_CONFIDENTIAL,
+ icon: 'eye-slash',
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ options: [
+ { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes },
+ { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo },
+ ],
+ },
+ ];
+
+ if (this.projectIterationsPath) {
+ tokens.push({
+ type: 'iteration',
+ title: TOKEN_TITLE_ITERATION,
+ icon: 'iteration',
+ token: IterationToken,
+ unique: true,
+ fetchIterations: this.fetchIterations,
+ });
+ }
+
+ if (this.groupEpicsPath) {
+ tokens.push({
+ type: 'epic_id',
+ title: TOKEN_TITLE_EPIC,
+ icon: 'epic',
+ token: EpicToken,
+ unique: true,
+ fetchEpics: this.fetchEpics,
+ });
+ }
+
+ if (this.hasIssueWeightsFeature) {
+ tokens.push({
+ type: 'weight',
+ title: TOKEN_TITLE_WEIGHT,
+ icon: 'weight',
+ token: WeightToken,
+ unique: true,
+ });
+ }
+
+ return tokens;
},
showPaginationControls() {
return this.issues.length > 0;
},
+ sortOptions() {
+ return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
+ },
tabCounts() {
return Object.values(IssuableStates).reduce(
(acc, state) => ({
@@ -166,24 +305,65 @@ export default {
},
urlParams() {
return {
+ due_date: this.dueDateFilter,
page: this.page,
search: this.searchQuery,
state: this.state,
- ...this.filters,
+ ...urlSortParams[this.sortKey],
+ ...this.urlFilterParams,
};
},
},
+ created() {
+ this.cache = {};
+ },
mounted() {
- eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => {
- this.showBulkEditSidebar = showBulkEditSidebar;
- });
+ eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
this.fetchIssues();
},
beforeDestroy() {
- // eslint-disable-next-line @gitlab/no-global-event-off
- eventHub.$off('issuables:toggleBulkEdit');
+ eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
},
methods: {
+ fetchWithCache(path, cacheName, searchKey, search, wrapData = false) {
+ if (this.cache[cacheName]) {
+ const data = search
+ ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey })
+ : this.cache[cacheName].slice(0, MAX_LIST_SIZE);
+ return wrapData ? Promise.resolve({ data }) : Promise.resolve(data);
+ }
+
+ return axios.get(path).then(({ data }) => {
+ this.cache[cacheName] = data;
+ const result = data.slice(0, MAX_LIST_SIZE);
+ return wrapData ? { data: result } : result;
+ });
+ },
+ fetchEmojis(search) {
+ return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
+ },
+ async fetchEpics(search) {
+ const epics = await this.fetchWithCache(this.groupEpicsPath, 'epics');
+ if (!search) {
+ return epics.slice(0, MAX_LIST_SIZE);
+ }
+ const number = Number(search);
+ return Number.isNaN(number)
+ ? fuzzaldrinPlus.filter(epics, search, { key: 'title' })
+ : epics.filter((epic) => epic.id === number);
+ },
+ fetchLabels(search) {
+ return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search);
+ },
+ fetchMilestones(search) {
+ return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true);
+ },
+ fetchIterations(search) {
+ return axios.get(this.projectIterationsPath, { params: { search } });
+ },
+ fetchUsers(search) {
+ return axios.get(this.autocompleteUsersPath, { params: { search } });
+ },
fetchIssues() {
if (!this.hasIssues) {
return undefined;
@@ -194,12 +374,14 @@ export default {
return axios
.get(this.endpoint, {
params: {
+ due_date: this.dueDateFilter,
page: this.page,
- per_page: this.$options.PAGE_SIZE,
+ per_page: PAGE_SIZE,
search: this.searchQuery,
state: this.state,
with_labels_details: true,
- ...this.filters,
+ ...apiSortParams[this.sortKey],
+ ...this.apiFilterParams,
},
})
.then(({ data, headers }) => {
@@ -209,7 +391,7 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
})
.catch(() => {
- createFlash({ message: __('An error occurred while loading issues') });
+ createFlash({ message: this.$options.i18n.errorFetchingIssues });
})
.finally(() => {
this.isLoading = false;
@@ -218,6 +400,15 @@ export default {
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
},
+ getStatus(issue) {
+ if (issue.closedAt && issue.movedToId) {
+ return this.$options.i18n.closedMoved;
+ }
+ if (issue.closedAt) {
+ return this.$options.i18n.closed;
+ }
+ return undefined;
+ },
handleUpdateLegacyBulkEdit() {
// If "select all" checkbox was checked, wait for all checkboxes
// to be checked before updating IssuableBulkUpdateSidebar class
@@ -225,7 +416,18 @@ export default {
eventHub.$emit('issuables:updateBulkEdit');
});
},
- handleBulkUpdateClick() {
+ async handleBulkUpdateClick() {
+ if (!this.hasInitBulkEdit) {
+ const initBulkUpdateSidebar = await import('~/issuable_init_bulk_update_sidebar');
+ initBulkUpdateSidebar.default.init('issuable_');
+
+ const usersSelect = await import('~/users_select');
+ const UsersSelect = usersSelect.default;
+ new UsersSelect(); // eslint-disable-line no-new
+
+ this.hasInitBulkEdit = true;
+ }
+
eventHub.$emit('issuables:enableBulkEdit');
},
handleClickTab(state) {
@@ -278,151 +480,161 @@ export default {
},
handleSort(value) {
this.sortKey = value;
- this.filters = sortParams[value];
this.fetchIssues();
},
+ toggleBulkEditSidebar(showBulkEditSidebar) {
+ this.showBulkEditSidebar = showBulkEditSidebar;
+ },
},
};
</script>
<template>
- <issuable-list
- v-if="hasIssues"
- :namespace="fullPath"
- recent-searches-storage-key="issues"
- :search-input-placeholder="__('Search or filter results…')"
- :search-tokens="[]"
- :initial-filter-value="filterTokens"
- :sort-options="$options.sortOptions"
- :initial-sort-by="sortKey"
- :issuables="issues"
- :tabs="$options.IssuableListTabs"
- :current-tab="state"
- :tab-counts="tabCounts"
- :issuables-loading="isLoading"
- :is-manual-ordering="isManualOrdering"
- :show-bulk-edit-sidebar="showBulkEditSidebar"
- :show-pagination-controls="showPaginationControls"
- :total-items="totalIssues"
- :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>
+ <div v-if="hasIssues">
+ <issuable-list
+ :namespace="projectPath"
+ recent-searches-storage-key="issues"
+ :search-input-placeholder="$options.i18n.searchPlaceholder"
+ :search-tokens="searchTokens"
+ :initial-filter-value="filterTokens"
+ :sort-options="sortOptions"
+ :initial-sort-by="sortKey"
+ :issuables="issues"
+ :tabs="$options.IssuableListTabs"
+ :current-tab="state"
+ :tab-counts="tabCounts"
+ :issuables-loading="isLoading"
+ :is-manual-ordering="isManualOrdering"
+ :show-bulk-edit-sidebar="showBulkEditSidebar"
+ :show-pagination-controls="showPaginationControls"
+ :total-items="totalIssues"
+ :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
+ v-if="isSignedIn"
+ class="gl-mr-3"
+ :export-csv-path="exportCsvPathWithQuery"
+ :issuable-count="totalIssues"
+ />
+ <gl-button
+ v-if="canBulkUpdate"
+ :disabled="isBulkEditButtonDisabled"
+ @click="handleBulkUpdateClick"
+ >
+ {{ $options.i18n.editIssues }}
+ </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 #timeframe="{ issuable = {} }">
+ <issue-card-time-info :issue="issuable" />
+ </template>
- <template #statistics="{ issuable = {} }">
- <li
- v-if="issuable.mergeRequestsCount"
- v-gl-tooltip
- class="gl-display-none gl-sm-display-block"
- :title="__('Related merge requests')"
- data-testid="issuable-mr"
- >
- <gl-icon name="merge-request" />
- {{ issuable.mergeRequestsCount }}
- </li>
- <li
- v-if="issuable.upvotes"
- v-gl-tooltip
- class="gl-display-none gl-sm-display-block"
- :title="__('Upvotes')"
- data-testid="issuable-upvotes"
- >
- <gl-icon name="thumb-up" />
- {{ issuable.upvotes }}
- </li>
- <li
- v-if="issuable.downvotes"
- v-gl-tooltip
- class="gl-display-none gl-sm-display-block"
- :title="__('Downvotes')"
- data-testid="issuable-downvotes"
- >
- <gl-icon name="thumb-down" />
- {{ issuable.downvotes }}
- </li>
- <blocking-issues-count
- class="gl-display-none gl-sm-display-block"
- :blocking-issues-count="issuable.blockingIssuesCount"
- :is-list-item="true"
- />
- </template>
+ <template #status="{ issuable = {} }">
+ {{ getStatus(issuable) }}
+ </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>
+ <template #statistics="{ issuable = {} }">
+ <li
+ v-if="issuable.mergeRequestsCount"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="$options.i18n.relatedMergeRequests"
+ data-testid="issuable-mr"
+ >
+ <gl-icon name="merge-request" />
+ {{ issuable.mergeRequestsCount }}
+ </li>
+ <li
+ v-if="issuable.upvotes"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="$options.i18n.upvotes"
+ data-testid="issuable-upvotes"
+ >
+ <gl-icon name="thumb-up" />
+ {{ issuable.upvotes }}
+ </li>
+ <li
+ v-if="issuable.downvotes"
+ v-gl-tooltip
+ class="gl-display-none gl-sm-display-block"
+ :title="$options.i18n.downvotes"
+ data-testid="issuable-downvotes"
+ >
+ <gl-icon name="thumb-down" />
+ {{ issuable.downvotes }}
+ </li>
+ <blocking-issues-count
+ class="gl-display-none gl-sm-display-block"
+ :blocking-issues-count="issuable.blockingIssuesCount"
+ :is-list-item="true"
+ />
+ </template>
- <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>
+ <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
- :title="$options.i18n.noClosedIssuesTitle"
- :svg-path="emptyStateSvgPath"
- />
- </template>
- </issuable-list>
+ <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>
+
+ <issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" />
+ </div>
<div v-else-if="isSignedIn">
<gl-empty-state
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index f6f23af80ba..54e9668d300 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -1,4 +1,11 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+import {
+ FILTER_ANY,
+ FILTER_CURRENT,
+ FILTER_NONE,
+ OPERATOR_IS,
+ OPERATOR_IS_NOT,
+} from '~/vue_shared/components/filtered_search_bar/constants';
// Maps sort order as it appears in the URL query to API `order_by` and `sort` params.
const PRIORITY = 'priority';
@@ -53,23 +60,78 @@ export const availableSortOptionsJira = [
},
];
+export const i18n = {
+ calendarLabel: __('Subscribe to calendar'),
+ closed: __('CLOSED'),
+ closedMoved: __('CLOSED (MOVED)'),
+ confidentialNo: __('No'),
+ confidentialYes: __('Yes'),
+ downvotes: __('Downvotes'),
+ editIssues: __('Edit issues'),
+ errorFetchingIssues: __('An error occurred while loading issues'),
+ 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'),
+ relatedMergeRequests: __('Related merge requests'),
+ reorderError: __('An error occurred while reordering issues.'),
+ rssLabel: __('Subscribe to RSS feed'),
+ searchPlaceholder: __('Search or filter results…'),
+ upvotes: __('Upvotes'),
+};
+
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 PARAM_DUE_DATE = 'due_date';
+export const PARAM_PAGE = 'page';
+export const PARAM_SORT = 'sort';
+export const PARAM_STATE = 'state';
+
+export const DUE_DATE_NONE = '0';
+export const DUE_DATE_ANY = '';
+export const DUE_DATE_OVERDUE = 'overdue';
+export const DUE_DATE_WEEK = 'week';
+export const DUE_DATE_MONTH = 'month';
+export const DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS = 'next_month_and_previous_two_weeks';
+export const DUE_DATE_VALUES = [
+ DUE_DATE_NONE,
+ DUE_DATE_ANY,
+ DUE_DATE_OVERDUE,
+ DUE_DATE_WEEK,
+ DUE_DATE_MONTH,
+ DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS,
+];
+
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 RELATIVE_POSITION_DESC = 'RELATIVE_POSITION_DESC';
export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
@@ -78,13 +140,19 @@ export const WEIGHT_DESC = 'WEIGHT_DESC';
const SORT_ASC = 'asc';
const SORT_DESC = 'desc';
+const CREATED_DATE_SORT = 'created_date';
+const CREATED_ASC_SORT = 'created_asc';
+const UPDATED_DESC_SORT = 'updated_desc';
+const UPDATED_ASC_SORT = 'updated_asc';
+const MILESTONE_SORT = 'milestone';
+const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc';
+const DUE_DATE_DESC_SORT = 'due_date_desc';
+const POPULARITY_ASC_SORT = 'popularity_asc';
+const WEIGHT_DESC_SORT = 'weight_desc';
+const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
const BLOCKING_ISSUES = 'blocking_issues';
-export const sortParams = {
- [PRIORITY_ASC]: {
- order_by: PRIORITY,
- sort: SORT_ASC,
- },
+export const apiSortParams = {
[PRIORITY_DESC]: {
order_by: PRIORITY,
sort: SORT_DESC,
@@ -129,15 +197,11 @@ export const sortParams = {
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]: {
+ [RELATIVE_POSITION_DESC]: {
order_by: RELATIVE_POSITION,
per_page: 100,
sort: SORT_ASC,
@@ -150,95 +214,233 @@ export const sortParams = {
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,
- },
+export const urlSortParams = {
+ [PRIORITY_DESC]: {
+ sort: PRIORITY,
},
- {
- id: 2,
- title: __('Created date'),
- sortDirection: {
- ascending: CREATED_ASC,
- descending: CREATED_DESC,
+ [CREATED_ASC]: {
+ sort: CREATED_ASC_SORT,
+ },
+ [CREATED_DESC]: {
+ sort: CREATED_DATE_SORT,
+ },
+ [UPDATED_ASC]: {
+ sort: UPDATED_ASC_SORT,
+ },
+ [UPDATED_DESC]: {
+ sort: UPDATED_DESC_SORT,
+ },
+ [MILESTONE_DUE_ASC]: {
+ sort: MILESTONE_SORT,
+ },
+ [MILESTONE_DUE_DESC]: {
+ sort: MILESTONE_DUE_DESC_SORT,
+ },
+ [DUE_DATE_ASC]: {
+ sort: DUE_DATE,
+ },
+ [DUE_DATE_DESC]: {
+ sort: DUE_DATE_DESC_SORT,
+ },
+ [POPULARITY_ASC]: {
+ sort: POPULARITY_ASC_SORT,
+ },
+ [POPULARITY_DESC]: {
+ sort: POPULARITY,
+ },
+ [LABEL_PRIORITY_DESC]: {
+ sort: LABEL_PRIORITY,
+ },
+ [RELATIVE_POSITION_DESC]: {
+ sort: RELATIVE_POSITION,
+ per_page: 100,
+ },
+ [WEIGHT_ASC]: {
+ sort: WEIGHT,
+ },
+ [WEIGHT_DESC]: {
+ sort: WEIGHT_DESC_SORT,
+ },
+ [BLOCKING_ISSUES_DESC]: {
+ sort: BLOCKING_ISSUES_DESC_SORT,
+ },
+};
+
+export const MAX_LIST_SIZE = 10;
+
+export const API_PARAM = 'apiParam';
+export const URL_PARAM = 'urlParam';
+export const NORMAL_FILTER = 'normalFilter';
+export const SPECIAL_FILTER = 'specialFilter';
+export const ALTERNATIVE_FILTER = 'alternativeFilter';
+export const SPECIAL_FILTER_VALUES = [FILTER_NONE, FILTER_ANY, FILTER_CURRENT];
+
+export const filters = {
+ author_username: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'author_username',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[author_username]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'author_username',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[author_username]',
+ },
},
},
- {
- id: 3,
- title: __('Last updated'),
- sortDirection: {
- ascending: UPDATED_ASC,
- descending: UPDATED_DESC,
+ assignee_username: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'assignee_username',
+ [SPECIAL_FILTER]: 'assignee_id',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[assignee_username]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'assignee_username[]',
+ [SPECIAL_FILTER]: 'assignee_id',
+ [ALTERNATIVE_FILTER]: 'assignee_username',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[assignee_username][]',
+ },
},
},
- {
- id: 4,
- title: __('Milestone due date'),
- sortDirection: {
- ascending: MILESTONE_DUE_ASC,
- descending: MILESTONE_DUE_DESC,
+ milestone: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'milestone',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[milestone]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'milestone_title',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[milestone_title]',
+ },
},
},
- {
- id: 5,
- title: __('Due date'),
- sortDirection: {
- ascending: DUE_DATE_ASC,
- descending: DUE_DATE_DESC,
+ labels: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'labels',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[labels]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'label_name[]',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[label_name][]',
+ },
},
},
- {
- id: 6,
- title: __('Popularity'),
- sortDirection: {
- ascending: POPULARITY_ASC,
- descending: POPULARITY_DESC,
+ my_reaction_emoji: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'my_reaction_emoji',
+ [SPECIAL_FILTER]: 'my_reaction_emoji',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'my_reaction_emoji',
+ [SPECIAL_FILTER]: 'my_reaction_emoji',
+ },
},
},
- {
- id: 7,
- title: __('Label priority'),
- sortDirection: {
- ascending: LABEL_PRIORITY_ASC,
- descending: LABEL_PRIORITY_DESC,
+ confidential: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'confidential',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'confidential',
+ },
},
},
- {
- id: 8,
- title: __('Manual'),
- sortDirection: {
- ascending: RELATIVE_POSITION_ASC,
- descending: RELATIVE_POSITION_ASC,
+ iteration: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'iteration_title',
+ [SPECIAL_FILTER]: 'iteration_id',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[iteration_title]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'iteration_title',
+ [SPECIAL_FILTER]: 'iteration_id',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[iteration_title]',
+ },
},
},
- {
- id: 9,
- title: __('Weight'),
- sortDirection: {
- ascending: WEIGHT_ASC,
- descending: WEIGHT_DESC,
+ epic_id: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'epic_id',
+ [SPECIAL_FILTER]: 'epic_id',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[epic_id]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'epic_id',
+ [SPECIAL_FILTER]: 'epic_id',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[epic_id]',
+ },
},
},
- {
- id: 10,
- title: __('Blocking'),
- sortDirection: {
- ascending: BLOCKING_ISSUES_ASC,
- descending: BLOCKING_ISSUES_DESC,
+ weight: {
+ [API_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'weight',
+ [SPECIAL_FILTER]: 'weight',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[weight]',
+ },
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'weight',
+ [SPECIAL_FILTER]: 'weight',
+ },
+ [OPERATOR_IS_NOT]: {
+ [NORMAL_FILTER]: 'not[weight]',
+ },
},
},
-];
+};
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index 0b64df50691..55719f6449b 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -1,12 +1,13 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { IssuableType } from '~/issue_show/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import IssuablesListApp from './components/issuables_list_app.vue';
import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue';
-function mountJiraIssuesListApp() {
+export function mountJiraIssuesListApp() {
const el = document.querySelector('.js-jira-issues-import-status');
if (!el) {
@@ -36,7 +37,7 @@ function mountJiraIssuesListApp() {
});
}
-function mountIssuablesListApp() {
+export function mountIssuablesListApp() {
if (!gon.features?.vueIssuablesList) {
return;
}
@@ -65,7 +66,7 @@ function mountIssuablesListApp() {
});
}
-export function initIssuesListApp() {
+export function mountIssuesListApp() {
const el = document.querySelector('.js-issues-list');
if (!el) {
@@ -73,26 +74,38 @@ export function initIssuesListApp() {
}
const {
+ autocompleteAwardEmojisPath,
+ autocompleteUsersPath,
calendarPath,
canBulkUpdate,
canEdit,
canImportIssues,
email,
+ emailsHelpPagePath,
emptyStateSvgPath,
endpoint,
exportCsvPath,
- fullPath,
+ groupEpicsPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssues,
hasIssueWeightsFeature,
+ hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
+ initialEmail,
isSignedIn,
issuesPath,
jiraIntegrationPath,
+ markdownHelpPath,
maxAttachmentSize,
newIssuePath,
projectImportJiraPath,
+ projectIterationsPath,
+ projectLabelsPath,
+ projectMilestonesPath,
+ projectPath,
+ quickActionsHelpPath,
+ resetPath,
rssPath,
showNewIssueLink,
signInPath,
@@ -104,19 +117,26 @@ export function initIssuesListApp() {
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
+ autocompleteAwardEmojisPath,
+ autocompleteUsersPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
endpoint,
- fullPath,
+ groupEpicsPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssues: parseBoolean(hasIssues),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
+ hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
isSignedIn: parseBoolean(isSignedIn),
issuesPath,
jiraIntegrationPath,
newIssuePath,
+ projectIterationsPath,
+ projectLabelsPath,
+ projectMilestonesPath,
+ projectPath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
signInPath,
@@ -130,12 +150,14 @@ export function initIssuesListApp() {
showExportButton: parseBoolean(hasIssues),
showImportButton: parseBoolean(canImportIssues),
showLabel: !parseBoolean(hasIssues),
+ // For IssuableByEmail component
+ emailsHelpPagePath,
+ initialEmail,
+ issuableType: IssuableType.Issue,
+ markdownHelpPath,
+ quickActionsHelpPath,
+ resetPath,
},
render: (createComponent) => createComponent(IssuesListApp),
});
}
-
-export default function initIssuablesList() {
- mountJiraIssuesListApp();
- mountIssuablesListApp();
-}
diff --git a/app/assets/javascripts/issues_list/utils.js b/app/assets/javascripts/issues_list/utils.js
new file mode 100644
index 00000000000..234fd59ca8d
--- /dev/null
+++ b/app/assets/javascripts/issues_list/utils.js
@@ -0,0 +1,195 @@
+import {
+ BLOCKING_ISSUES_DESC,
+ CREATED_ASC,
+ CREATED_DESC,
+ DUE_DATE_ASC,
+ DUE_DATE_DESC,
+ DUE_DATE_VALUES,
+ filters,
+ LABEL_PRIORITY_DESC,
+ MILESTONE_DUE_ASC,
+ MILESTONE_DUE_DESC,
+ NORMAL_FILTER,
+ POPULARITY_ASC,
+ POPULARITY_DESC,
+ PRIORITY_DESC,
+ RELATIVE_POSITION_DESC,
+ SPECIAL_FILTER,
+ SPECIAL_FILTER_VALUES,
+ UPDATED_ASC,
+ UPDATED_DESC,
+ urlSortParams,
+ WEIGHT_ASC,
+ WEIGHT_DESC,
+} from '~/issues_list/constants';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
+import { __ } from '~/locale';
+import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
+
+export const getSortKey = (sort) =>
+ Object.keys(urlSortParams).find((key) => urlSortParams[key].sort === sort);
+
+export const getDueDateValue = (value) => (DUE_DATE_VALUES.includes(value) ? value : undefined);
+
+export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => {
+ const sortOptions = [
+ {
+ id: 1,
+ title: __('Priority'),
+ sortDirection: {
+ ascending: PRIORITY_DESC,
+ 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_DESC,
+ descending: LABEL_PRIORITY_DESC,
+ },
+ },
+ {
+ id: 8,
+ title: __('Manual'),
+ sortDirection: {
+ ascending: RELATIVE_POSITION_DESC,
+ descending: RELATIVE_POSITION_DESC,
+ },
+ },
+ ];
+
+ if (hasIssueWeightsFeature) {
+ sortOptions.push({
+ id: 9,
+ title: __('Weight'),
+ sortDirection: {
+ ascending: WEIGHT_ASC,
+ descending: WEIGHT_DESC,
+ },
+ });
+ }
+
+ if (hasBlockedIssuesFeature) {
+ sortOptions.push({
+ id: 10,
+ title: __('Blocking'),
+ sortDirection: {
+ ascending: BLOCKING_ISSUES_DESC,
+ descending: BLOCKING_ISSUES_DESC,
+ },
+ });
+ }
+
+ return sortOptions;
+};
+
+const tokenTypes = Object.keys(filters);
+
+const getUrlParams = (tokenType) =>
+ Object.values(filters[tokenType].urlParam).flatMap((filterObj) => Object.values(filterObj));
+
+const urlParamKeys = tokenTypes.flatMap(getUrlParams);
+
+const getTokenTypeFromUrlParamKey = (urlParamKey) =>
+ tokenTypes.find((tokenType) => getUrlParams(tokenType).includes(urlParamKey));
+
+const getOperatorFromUrlParamKey = (tokenType, urlParamKey) =>
+ Object.entries(filters[tokenType].urlParam).find(([, filterObj]) =>
+ Object.values(filterObj).includes(urlParamKey),
+ )[0];
+
+const convertToFilteredTokens = (locationSearch) =>
+ Array.from(new URLSearchParams(locationSearch).entries())
+ .filter(([key]) => urlParamKeys.includes(key))
+ .map(([key, data]) => {
+ const type = getTokenTypeFromUrlParamKey(key);
+ const operator = getOperatorFromUrlParamKey(type, key);
+ return {
+ type,
+ value: { data, operator },
+ };
+ });
+
+const convertToFilteredSearchTerms = (locationSearch) =>
+ new URLSearchParams(locationSearch)
+ .get('search')
+ ?.split(' ')
+ .map((word) => ({
+ type: FILTERED_SEARCH_TERM,
+ value: {
+ data: word,
+ },
+ })) || [];
+
+export const getFilterTokens = (locationSearch) => {
+ if (!locationSearch) {
+ return [];
+ }
+ const filterTokens = convertToFilteredTokens(locationSearch);
+ const searchTokens = convertToFilteredSearchTerms(locationSearch);
+ return filterTokens.concat(searchTokens);
+};
+
+const getFilterType = (data, tokenType = '') =>
+ SPECIAL_FILTER_VALUES.includes(data) ||
+ (tokenType === 'assignee_username' && isPositiveInteger(data))
+ ? SPECIAL_FILTER
+ : NORMAL_FILTER;
+
+export const convertToParams = (filterTokens, paramType) =>
+ filterTokens
+ .filter((token) => token.type !== FILTERED_SEARCH_TERM)
+ .reduce((acc, token) => {
+ const filterType = getFilterType(token.value.data, token.type);
+ const param = filters[token.type][paramType][token.value.operator]?.[filterType];
+ return Object.assign(acc, {
+ [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data,
+ });
+ }, {});
+
+export const convertToSearchQuery = (filterTokens) =>
+ filterTokens
+ .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data)
+ .map((token) => token.value.data)
+ .join(' ');
diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue
index 275ff820419..d764f778a9d 100644
--- a/app/assets/javascripts/jira_connect/components/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/components/groups_list.vue
@@ -1,7 +1,7 @@
<script>
import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui';
import { fetchGroups } from '~/jira_connect/api';
-import { defaultPerPage } from '~/jira_connect/constants';
+import { DEFAULT_GROUPS_PER_PAGE, MINIMUM_SEARCH_TERM_LENGTH } from '~/jira_connect/constants';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import GroupsListItem from './groups_list_item.vue';
@@ -25,24 +25,33 @@ export default {
isLoadingInitial: true,
isLoadingMore: false,
page: 1,
- perPage: defaultPerPage,
totalItems: 0,
errorMessage: null,
+ searchTerm: '',
};
},
+ computed: {
+ showPagination() {
+ return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0;
+ },
+ },
mounted() {
return this.loadGroups().finally(() => {
this.isLoadingInitial = false;
});
},
methods: {
- loadGroups({ searchTerm } = {}) {
- this.isLoadingMore = true;
+ loadGroups() {
+ // fetchGroups returns no results for search terms 0 < {length} < 3.
+ // The desired UX is to return the unfiltered results for searches {length} < 3.
+ // Here, we set the search to an empty string if {length} < 3
+ const search = this.searchTerm?.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.searchTerm;
+ this.isLoadingMore = true;
return fetchGroups(this.groupsPath, {
page: this.page,
- perPage: this.perPage,
- search: searchTerm,
+ perPage: this.$options.DEFAULT_GROUPS_PER_PAGE,
+ search,
})
.then((response) => {
const { page, total } = parseIntPagination(normalizeHeaders(response.headers));
@@ -58,9 +67,14 @@ export default {
});
},
onGroupSearch(searchTerm) {
- return this.loadGroups({ searchTerm });
+ // keep a copy of the search term for pagination
+ this.searchTerm = searchTerm;
+ // reset the current page
+ this.page = 1;
+ return this.loadGroups();
},
},
+ DEFAULT_GROUPS_PER_PAGE,
};
</script>
@@ -102,10 +116,10 @@ export default {
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-pagination
- v-if="totalItems > perPage && groups.length > 0"
+ v-if="showPagination"
v-model="page"
class="gl-mb-0"
- :per-page="perPage"
+ :per-page="$options.DEFAULT_GROUPS_PER_PAGE"
:total-items="totalItems"
@input="loadGroups"
/>
diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/constants.js
index 63b79581a1b..8dff83eabb5 100644
--- a/app/assets/javascripts/jira_connect/constants.js
+++ b/app/assets/javascripts/jira_connect/constants.js
@@ -1,2 +1,3 @@
-export const defaultPerPage = 10;
+export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
+export const MINIMUM_SEARCH_TERM_LENGTH = 3;
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 91ab68d5f39..be95001a396 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -3,6 +3,7 @@ import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { throttle, isEmpty } from 'lodash';
import { mapGetters, mapState, mapActions } from 'vuex';
+import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
@@ -32,6 +33,7 @@ export default {
GlLoadingIcon,
SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
GlAlert,
+ CodeQualityWalkthrough,
},
directives: {
SafeHtml,
@@ -72,6 +74,11 @@ export default {
required: false,
default: null,
},
+ codeQualityHelpUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
...mapState([
@@ -120,6 +127,10 @@ export default {
shouldRenderHeaderCallout() {
return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure;
},
+
+ shouldRenderCodeQualityWalkthrough() {
+ return this.job.status.group === 'failed-with-warnings';
+ },
},
watch: {
// Once the job log is loaded,
@@ -190,7 +201,7 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation prepend-top-20" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation gl-mt-6" />
<template v-else-if="shouldRenderContent">
<div class="build-page" data-testid="job-content">
@@ -216,6 +227,11 @@ export default {
>
<div v-safe-html="job.callout_message"></div>
</gl-alert>
+ <code-quality-walkthrough
+ v-if="shouldRenderCodeQualityWalkthrough"
+ step="troubleshoot_job"
+ :link="codeQualityHelpUrl"
+ />
</header>
<!-- EO Header Section -->
@@ -256,17 +272,17 @@ export default {
<div
v-if="job.archived"
- class="gl-mt-3 archived-job"
- :class="{ 'sticky-top border-bottom-0': hasTrace }"
+ class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job"
+ :class="{ 'sticky-top gl-border-bottom-0': hasTrace }"
data-testid="archived-job"
>
- <gl-icon name="lock" class="align-text-bottom" />
+ <gl-icon name="lock" class="gl-vertical-align-bottom" />
{{ __('This job is archived. Only the complete pipeline can be retried.') }}
</div>
<!-- job log -->
<div
v-if="hasTrace"
- class="build-trace-container position-relative"
+ class="build-trace-container gl-relative"
:class="{ 'gl-mt-3': !job.archived }"
>
<log-top-bar
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index 00a570fe2f8..c08ac0317b8 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -35,33 +35,40 @@ export default {
return text;
},
+ jobName() {
+ return this.job.name ? this.job.name : this.job.id;
+ },
+ classes() {
+ return {
+ retried: this.job.retried,
+ 'gl-font-weight-bold': this.isActive,
+ };
+ },
+ dataTestId() {
+ return this.isActive ? 'active-job' : null;
+ },
},
};
</script>
<template>
- <div
- class="build-job position-relative"
- :class="{
- retried: job.retried,
- active: isActive,
- }"
- >
+ <div class="build-job gl-relative" :class="classes">
<gl-link
v-gl-tooltip:tooltip-container.left
:href="job.status.details_path"
:title="tooltipText"
- class="js-job-link gl-display-flex gl-align-items-center"
+ class="gl-display-flex gl-align-items-center"
+ :data-testid="dataTestId"
>
<gl-icon
v-if="isActive"
name="arrow-right"
- class="js-arrow-right icon-arrow-right position-absolute d-block"
+ class="icon-arrow-right gl-absolute gl-display-block"
/>
<ci-icon :status="job.status" />
- <span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span>
+ <span class="gl-text-truncate gl-w-full">{{ jobName }}</span>
<gl-icon v-if="job.retried" name="retry" />
</gl-link>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index ea50a11bed6..957e8243f33 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -69,7 +69,10 @@ export default {
<template>
<div class="top-bar">
<!-- truncate information -->
- <div class="truncated-info d-none d-sm-block float-left" data-testid="log-truncated-info">
+ <div
+ class="truncated-info gl-display-none gl-sm-display-block gl-float-left"
+ data-testid="log-truncated-info"
+ >
<template v-if="isTraceSizeVisible">
{{ jobLogSize }}
<gl-link
@@ -83,7 +86,7 @@ export default {
</div>
<!-- eo truncate information -->
- <div class="controllers float-right">
+ <div class="controllers gl-float-right">
<!-- links -->
<gl-button
v-if="rawPath"
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
new file mode 100644
index 00000000000..376482b0319
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -0,0 +1,14 @@
+<script>
+export default {
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
new file mode 100644
index 00000000000..ba5732d3d43
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/cells/duration_cell.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ iconSize: 12,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ finishedTime() {
+ return this.job?.finishedAt;
+ },
+ duration() {
+ return this.job?.duration;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="duration" data-testid="job-duration">
+ <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" />
+ {{ durationTimeFormatted(duration) }}
+ </div>
+ <div v-if="finishedTime" data-testid="job-finished-time">
+ <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" />
+ <time
+ v-gl-tooltip
+ :title="tooltipTitle(finishedTime)"
+ data-placement="top"
+ data-container="body"
+ >
+ {{ timeFormatted(finishedTime) }}
+ </time>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/cells/job_cell.vue b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
new file mode 100644
index 00000000000..88a9f73258f
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/cells/job_cell.vue
@@ -0,0 +1,163 @@
+<script>
+import { GlBadge, GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { s__ } from '~/locale';
+import { SUCCESS_STATUS } from '../../../constants';
+
+export default {
+ iconSize: 12,
+ badgeSize: 'sm',
+ i18n: {
+ stuckText: s__('Jobs|Job is stuck. Check runners.'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlBadge,
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ jobId() {
+ const id = getIdFromGraphQLId(this.job.id);
+ return `#${id}`;
+ },
+ jobPath() {
+ return this.job.detailedStatus?.detailsPath;
+ },
+ jobRef() {
+ return this.job?.refName;
+ },
+ jobRefPath() {
+ return this.job?.refPath;
+ },
+ jobTags() {
+ return this.job.tags;
+ },
+ createdByTag() {
+ return this.job.createdByTag;
+ },
+ triggered() {
+ return this.job.triggered;
+ },
+ isManualJob() {
+ return this.job.manualJob;
+ },
+ successfulJob() {
+ return this.job.status === SUCCESS_STATUS;
+ },
+ showAllowedToFailBadge() {
+ return this.job.allowFailure && !this.successfulJob;
+ },
+ isScheduledJob() {
+ return Boolean(this.job.scheduledAt);
+ },
+ canReadJob() {
+ return this.job?.userPermissions?.readBuild;
+ },
+ jobStuck() {
+ return this.job?.stuck;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-text-truncate">
+ <gl-link
+ v-if="canReadJob"
+ class="gl-text-gray-500!"
+ :href="jobPath"
+ data-testid="job-id-link"
+ >
+ {{ jobId }}
+ </gl-link>
+
+ <span v-else data-testid="job-id-limited-access">{{ jobId }}</span>
+
+ <gl-icon
+ v-if="jobStuck"
+ v-gl-tooltip="$options.i18n.stuckText"
+ name="warning"
+ :size="$options.iconSize"
+ data-testid="stuck-icon"
+ />
+
+ <div
+ class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end"
+ >
+ <div v-if="jobRef" class="gl-max-w-15 gl-text-truncate">
+ <gl-icon
+ v-if="createdByTag"
+ name="label"
+ :size="$options.iconSize"
+ data-testid="label-icon"
+ />
+ <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" />
+ <gl-link
+ class="gl-font-weight-bold gl-text-gray-500!"
+ :href="job.refPath"
+ data-testid="job-ref"
+ >{{ job.refName }}</gl-link
+ >
+ </div>
+
+ <span v-else>{{ __('none') }}</span>
+
+ <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" />
+
+ <gl-link :href="job.commitPath" data-testid="job-sha">{{ job.shortSha }}</gl-link>
+ </div>
+ </div>
+
+ <div>
+ <gl-badge
+ v-for="tag in jobTags"
+ :key="tag"
+ variant="info"
+ :size="$options.badgeSize"
+ data-testid="job-tag-badge"
+ >
+ {{ tag }}
+ </gl-badge>
+
+ <gl-badge
+ v-if="triggered"
+ variant="info"
+ :size="$options.badgeSize"
+ data-testid="triggered-job-badge"
+ >{{ s__('Job|triggered') }}
+ </gl-badge>
+ <gl-badge
+ v-if="showAllowedToFailBadge"
+ variant="warning"
+ :size="$options.badgeSize"
+ data-testid="fail-job-badge"
+ >{{ s__('Job|allowed to fail') }}
+ </gl-badge>
+ <gl-badge
+ v-if="isScheduledJob"
+ variant="info"
+ :size="$options.badgeSize"
+ data-testid="delayed-job-badge"
+ >{{ s__('Job|delayed') }}
+ </gl-badge>
+ <gl-badge
+ v-if="isManualJob"
+ variant="info"
+ :size="$options.badgeSize"
+ data-testid="manual-job-badge"
+ >
+ {{ s__('Job|manual') }}
+ </gl-badge>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue
new file mode 100644
index 00000000000..71f9397f5f5
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/cells/pipeline_cell.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlAvatar, GlLink } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
+export default {
+ components: {
+ GlAvatar,
+ GlLink,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ pipelineId() {
+ const id = getIdFromGraphQLId(this.job.pipeline.id);
+ return `#${id}`;
+ },
+ pipelinePath() {
+ return this.job.pipeline?.path;
+ },
+ pipelineUserAvatar() {
+ return this.job.pipeline?.user?.avatarUrl;
+ },
+ userPath() {
+ return this.job.pipeline?.user?.webPath;
+ },
+ showAvatar() {
+ return this.job.pipeline?.user;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-text-truncate">
+ <gl-link class="gl-text-gray-500!" :href="pipelinePath" data-testid="pipeline-id">
+ {{ pipelineId }}
+ </gl-link>
+ <div>
+ <span>{{ __('created by') }}</span>
+ <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link">
+ <gl-avatar :src="pipelineUserAvatar" :size="16" />
+ </gl-link>
+ <span v-else>{{ __('API') }}</span>
+ </div>
+ </div>
+</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
index d9e51b0345a..c2104754bad 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -8,7 +8,20 @@ query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
startCursor
}
nodes {
+ artifacts {
+ nodes {
+ downloadPath
+ }
+ }
+ allowFailure
+ status
+ scheduledAt
+ manualJob
+ triggered
+ createdByTag
detailedStatus {
+ detailsPath
+ group
icon
label
text
@@ -46,6 +59,10 @@ query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) {
playable
cancelable
active
+ stuck
+ userPermissions {
+ readBuild
+ }
}
}
}
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
index b6b3bb6d379..05d6ebfd6d6 100644
--- a/app/assets/javascripts/jobs/components/table/index.js
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -16,13 +16,21 @@ export default (containerId = 'js-jobs-table') => {
return false;
}
- const { fullPath, jobCounts, jobStatuses } = containerEl.dataset;
+ const {
+ fullPath,
+ jobCounts,
+ jobStatuses,
+ pipelineEditorPath,
+ emptyStateSvgPath,
+ } = containerEl.dataset;
return new Vue({
el: containerEl,
apolloProvider,
provide: {
+ emptyStateSvgPath,
fullPath,
+ pipelineEditorPath,
jobStatuses: JSON.parse(jobStatuses),
jobCounts: JSON.parse(jobCounts),
},
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index 32b26d45dfe..4fe5bbf79cd 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -1,57 +1,81 @@
<script>
import { GlTable } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__, __ } from '~/locale';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import ActionsCell from './cells/actions_cell.vue';
+import DurationCell from './cells/duration_cell.vue';
+import JobCell from './cells/job_cell.vue';
+import PipelineCell from './cells/pipeline_cell.vue';
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!',
};
+// eslint-disable-next-line @gitlab/require-i18n-strings
+const coverageTdClasses = `${defaultTableClasses.tdClass} gl-display-none! gl-lg-display-table-cell!`;
export default {
+ i18n: {
+ emptyText: s__('Jobs|No jobs to show'),
+ },
fields: [
{
key: 'status',
label: __('Status'),
...defaultTableClasses,
+ columnClass: 'gl-w-10p',
},
{
key: 'job',
label: __('Job'),
...defaultTableClasses,
+ columnClass: 'gl-w-20p',
},
{
key: 'pipeline',
label: __('Pipeline'),
...defaultTableClasses,
+ columnClass: 'gl-w-10p',
},
{
key: 'stage',
label: __('Stage'),
...defaultTableClasses,
+ columnClass: 'gl-w-10p',
},
{
key: 'name',
label: __('Name'),
...defaultTableClasses,
+ columnClass: 'gl-w-15p',
},
{
key: 'duration',
label: __('Duration'),
...defaultTableClasses,
+ columnClass: 'gl-w-15p',
},
{
key: 'coverage',
label: __('Coverage'),
- ...defaultTableClasses,
+ tdClass: coverageTdClasses,
+ thClass: defaultTableClasses.thClass,
+ columnClass: 'gl-w-10p',
},
{
key: 'actions',
label: '',
...defaultTableClasses,
+ columnClass: 'gl-w-10p',
},
],
components: {
+ ActionsCell,
+ CiBadge,
+ DurationCell,
GlTable,
+ JobCell,
+ PipelineCell,
},
props: {
jobs: {
@@ -59,9 +83,64 @@ export default {
required: true,
},
},
+ methods: {
+ formatCoverage(coverage) {
+ return coverage ? `${coverage}%` : '';
+ },
+ },
};
</script>
<template>
- <gl-table :items="jobs" :fields="$options.fields" />
+ <gl-table
+ :items="jobs"
+ :fields="$options.fields"
+ :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }"
+ :empty-text="$options.i18n.emptyText"
+ show-empty
+ stacked="lg"
+ fixed
+ >
+ <template #table-colgroup="{ fields }">
+ <col v-for="field in fields" :key="field.key" :class="field.columnClass" />
+ </template>
+
+ <template #cell(status)="{ item }">
+ <ci-badge :status="item.detailedStatus" />
+ </template>
+
+ <template #cell(job)="{ item }">
+ <job-cell :job="item" />
+ </template>
+
+ <template #cell(pipeline)="{ item }">
+ <pipeline-cell :job="item" />
+ </template>
+
+ <template #cell(stage)="{ item }">
+ <div class="gl-text-truncate">
+ <span data-testid="job-stage-name">{{ item.stage.name }}</span>
+ </div>
+ </template>
+
+ <template #cell(name)="{ item }">
+ <div class="gl-text-truncate">
+ <span data-testid="job-name">{{ item.name }}</span>
+ </div>
+ </template>
+
+ <template #cell(duration)="{ item }">
+ <duration-cell :job="item" />
+ </template>
+
+ <template #cell(coverage)="{ item }">
+ <span v-if="item.coverage" data-testid="job-coverage">{{
+ formatCoverage(item.coverage)
+ }}</span>
+ </template>
+
+ <template #cell(actions)="{ item }">
+ <actions-cell :job="item" />
+ </template>
+ </gl-table>
</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
index 55954e31654..cf7970f41b1 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -3,6 +3,7 @@ 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 JobsTableEmptyState from './jobs_table_empty_state.vue';
import JobsTableTabs from './jobs_table_tabs.vue';
export default {
@@ -13,6 +14,7 @@ export default {
GlAlert,
GlSkeletonLoader,
JobsTable,
+ JobsTableEmptyState,
JobsTableTabs,
},
inject: {
@@ -29,7 +31,7 @@ export default {
};
},
update({ project }) {
- return project?.jobs;
+ return project?.jobs?.nodes || [];
},
error() {
this.hasError = true;
@@ -41,15 +43,21 @@ export default {
jobs: null,
hasError: false,
isAlertDismissed: false,
+ scope: null,
};
},
computed: {
shouldShowAlert() {
return this.hasError && !this.isAlertDismissed;
},
+ showEmptyState() {
+ return this.jobs.length === 0 && !this.scope;
+ },
},
methods: {
fetchJobsByStatus(scope) {
+ this.scope = scope;
+
this.$apollo.queries.jobs.refetch({ statuses: scope });
},
},
@@ -80,6 +88,8 @@ export default {
/>
</div>
- <jobs-table v-else :jobs="jobs.nodes" />
+ <jobs-table-empty-state v-else-if="showEmptyState" />
+
+ <jobs-table v-else :jobs="jobs" />
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue b/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue
new file mode 100644
index 00000000000..fcdd52b719c
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_empty_state.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('Jobs|Use jobs to automate your tasks'),
+ description: s__(
+ 'Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.',
+ ),
+ buttonText: s__('Jobs|Create CI/CD configuration file'),
+ },
+ components: {
+ GlEmptyState,
+ },
+ inject: {
+ pipelineEditorPath: {
+ default: '',
+ },
+ emptyStateSvgPath: {
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="$options.i18n.title"
+ :description="$options.i18n.description"
+ :svg-path="emptyStateSvgPath"
+ :primary-button-link="pipelineEditorPath"
+ :primary-button-text="$options.i18n.buttonText"
+ />
+</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
index 95d265fce60..26791e4284d 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue
@@ -50,7 +50,7 @@ export default {
</script>
<template>
- <gl-tabs>
+ <gl-tabs content-class="gl-pb-0">
<gl-tab
v-for="tab in tabs"
:key="tab.text"
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index d0d625d794d..3040d4e2379 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -22,3 +22,5 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = {
primaryText: __('Retry job'),
title: s__('Jobs|Are you sure you want to retry this job?'),
};
+
+export const SUCCESS_STATUS = 'SUCCESS';
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 3e00056ee81..260190f5043 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -13,6 +13,7 @@ export default () => {
const {
artifactHelpUrl,
deploymentHelpUrl,
+ codeQualityHelpUrl,
runnerSettingsUrl,
variablesSettingsUrl,
subscriptionsMoreMinutesUrl,
@@ -38,6 +39,7 @@ export default () => {
props: {
artifactHelpUrl,
deploymentHelpUrl,
+ codeQualityHelpUrl,
runnerSettingsUrl,
variablesSettingsUrl,
subscriptionsMoreMinutesUrl,
diff --git a/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js b/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js
new file mode 100644
index 00000000000..305d130f10c
--- /dev/null
+++ b/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js
@@ -0,0 +1,10 @@
+import Tracking from '~/tracking';
+
+export default function trackLearnGitlab(learnGitlabA) {
+ Tracking.event('projects:learn_gitlab:index', 'page_init', {
+ label: 'learn_gitlab',
+ property: learnGitlabA
+ ? 'Growth::Conversion::Experiment::LearnGitLabA'
+ : 'Growth::Activation::Experiment::LearnGitLabB',
+ });
+}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index c720476f3bf..cec689a44ca 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 ActionCableLink from '~/actioncable_link';
import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link';
import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
@@ -18,11 +19,21 @@ export const fetchPolicies = {
};
export default (resolvers = {}, config = {}) => {
- let uri = `${gon.relative_url_root || ''}/api/graphql`;
+ const {
+ assumeImmutableResults,
+ baseUrl,
+ batchMax = 10,
+ cacheConfig,
+ fetchPolicy = fetchPolicies.CACHE_FIRST,
+ typeDefs,
+ path = '/api/graphql',
+ useGet = false,
+ } = config;
+ let uri = `${gon.relative_url_root || ''}${path}`;
- if (config.baseUrl) {
+ if (baseUrl) {
// Prepend baseUrl and ensure that `///` are replaced with `/`
- uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/');
+ uri = `${baseUrl}${uri}`.replace(/\/{3,}/g, '/');
}
const httpOptions = {
@@ -34,7 +45,7 @@ export default (resolvers = {}, config = {}) => {
// We set to `same-origin` which is default value in modern browsers.
// See https://github.com/whatwg/fetch/pull/585 for more information.
credentials: 'same-origin',
- batchMax: config.batchMax || 10,
+ batchMax,
};
const requestCounterLink = new ApolloLink((operation, forward) => {
@@ -50,7 +61,7 @@ export default (resolvers = {}, config = {}) => {
const uploadsLink = ApolloLink.split(
(operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
- config.useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
+ useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
);
const performanceBarLink = new ApolloLink((operation, forward) => {
@@ -73,24 +84,36 @@ export default (resolvers = {}, config = {}) => {
});
});
- return new ApolloClient({
- typeDefs: config.typeDefs,
- link: ApolloLink.from([
+ const hasSubscriptionOperation = ({ query: { definitions } }) => {
+ return definitions.some(
+ ({ kind, operation }) => kind === 'OperationDefinition' && operation === 'subscription',
+ );
+ };
+
+ const appLink = ApolloLink.split(
+ hasSubscriptionOperation,
+ new ActionCableLink(),
+ ApolloLink.from([
requestCounterLink,
performanceBarLink,
new StartupJSLink(),
apolloCaptchaLink,
uploadsLink,
]),
+ );
+
+ return new ApolloClient({
+ typeDefs,
+ link: appLink,
cache: new InMemoryCache({
- ...config.cacheConfig,
- freezeResults: config.assumeImmutableResults,
+ ...cacheConfig,
+ freezeResults: assumeImmutableResults,
}),
resolvers,
- assumeImmutableResults: config.assumeImmutableResults,
+ assumeImmutableResults,
defaultOptions: {
query: {
- fetchPolicy: config.fetchPolicy || fetchPolicies.CACHE_FIRST,
+ fetchPolicy,
},
},
});
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index fb257228597..8666d325c1b 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -645,9 +645,6 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) =>
export const convertObjectPropsToSnakeCase = (obj = {}, options = {}) =>
convertObjectProps(convertToSnakeCase, obj, options);
-export const imagePath = (imgUrl) =>
- `${gon.asset_host || ''}${gon.relative_url_root || ''}/assets/${imgUrl}`;
-
export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
// Click a .js-select-on-focus field, select the contents
// Prevent a mouseup event from deselecting the input
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index a509828815a..0a038febb9f 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -4,6 +4,8 @@ import { isString, mapValues, isNumber, reduce } from 'lodash';
import * as timeago from 'timeago.js';
import { languageCode, s__, __, n__ } from '../../locale';
+export const SECONDS_IN_DAY = 86400;
+
const DAYS_IN_WEEK = 7;
window.timeago = timeago;
diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js
index 2a8b1759e54..bd47f10b3ac 100644
--- a/app/assets/javascripts/lib/utils/keys.js
+++ b/app/assets/javascripts/lib/utils/keys.js
@@ -1,2 +1,3 @@
export const ESC_KEY = 'Escape';
export const ENTER_KEY = 'Enter';
+export const BACKSPACE_KEY = 'Backspace';
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 63feb6f9b1d..e3500d02a79 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -171,3 +171,13 @@ export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-'
export const isNumeric = (value) => {
return !Number.isNaN(parseInt(value, 10));
};
+
+const numberRegex = /^[0-9]+$/;
+
+/**
+ * Checks whether the value is a positive number or 0, or a string with equivalent value
+ *
+ * @param value
+ * @return {boolean}
+ */
+export const isPositiveInteger = (value) => numberRegex.test(value);
diff --git a/app/assets/javascripts/lib/utils/recurrence.js b/app/assets/javascripts/lib/utils/recurrence.js
new file mode 100644
index 00000000000..8fd26f3e393
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/recurrence.js
@@ -0,0 +1,154 @@
+import { uuids } from './uuids';
+
+/**
+ * @module recurrence
+ */
+
+const instances = {};
+
+/**
+ * Create a new unique {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {module:recurrence.RecurInstance} The newly created {@link module:recurrence~RecurInstance|RecurInstance}
+ */
+export function create() {
+ const id = uuids()[0];
+ let handlers = {};
+ let count = 0;
+
+ /**
+ * @namespace RecurInstance
+ * @description A RecurInstance tracks the count of any occurrence as registered by calls to <code>occur</code>.
+ * <br /><br />
+ * It maintains an internal counter and a registry of handlers that can be arbitrarily assigned by a user.
+ * <br /><br />
+ * While a RecurInstance isn't specific to any particular use-case, it may be useful for:
+ * <br />
+ * <ul>
+ * <li>Tracking repeated errors across multiple - but not linked - network requests</li>
+ * <li>Tracking repeated user interactions (e.g. multiple clicks)</li>
+ * </ul>
+ * @summary A closure to track repeated occurrences of any arbitrary event.
+ * */
+ const instance = {
+ /**
+ * @type {module:uuids~UUIDv4}
+ * @description A randomly generated {@link module:uuids~UUIDv4|UUID} for this particular recurrence instance
+ * @memberof module:recurrence~RecurInstance
+ * @readonly
+ * @inner
+ */
+ get id() {
+ return id;
+ },
+ /**
+ * @type {Number}
+ * @description The number of times this particular instance of recurrence has been triggered
+ * @memberof module:recurrence~RecurInstance
+ * @readonly
+ * @inner
+ */
+ get count() {
+ return count;
+ },
+ /**
+ * @type {Object}
+ * @description The handlers assigned to this recurrence tracker
+ * @example
+ * myRecurrence.handle( 4, () => console.log( "four" ) );
+ * console.log( myRecurrence.handlers ); // {"4": () => console.log( "four" )}
+ * @memberof module:recurrence~RecurInstance
+ * @readonly
+ * @inner
+ */
+ get handlers() {
+ return handlers;
+ },
+ /**
+ * @type {Boolean}
+ * @description Delete any internal reference to the instance.
+ * <br />
+ * Keep in mind that this will only attempt to remove the <strong>internal</strong> reference.
+ * <br />
+ * If your code maintains a reference to the instance, the regular garbage collector will not free the memory.
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ free() {
+ return delete instances[id];
+ },
+ /**
+ * @description Register a handler to be called when this occurrence is seen <code>onCount</code> number of times.
+ * @param {Number} onCount - The number of times the occurrence has been seen to respond to
+ * @param {Function} behavior - A callback function to run when the occurrence has been seen <code>onCount</code> times
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ handle(onCount, behavior) {
+ if (onCount && behavior) {
+ handlers[onCount] = behavior;
+ }
+ },
+ /**
+ * @description Remove the behavior callback handler that would be run when the occurrence is seen <code>onCount</code> times
+ * @param {Number} onCount - The count identifier for which to eject the callback handler
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ eject(onCount) {
+ if (onCount) {
+ delete handlers[onCount];
+ }
+ },
+ /**
+ * @description Register that this occurrence has been seen and trigger any appropriate handlers
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ occur() {
+ count += 1;
+
+ if (typeof handlers[count] === 'function') {
+ handlers[count](count);
+ }
+ },
+ /**
+ * @description Reset this recurrence instance without destroying it entirely
+ * @param {Object} [options]
+ * @param {Boolean} [options.currentCount = true] - Whether to reset the count
+ * @param {Boolean} [options.handlersList = false] - Whether to reset the list of attached handlers back to an empty state
+ * @memberof module:recurrence~RecurInstance
+ * @inner
+ */
+ reset({ currentCount = true, handlersList = false } = {}) {
+ if (currentCount) {
+ count = 0;
+ }
+
+ if (handlersList) {
+ handlers = {};
+ }
+ },
+ };
+
+ instances[id] = instance;
+
+ return instance;
+}
+
+/**
+ * Retrieve a stored {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
+ * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {(module:recurrence~RecurInstance|undefined)} The {@link module:recurrence~RecurInstance|RecurInstance}, or undefined if the UUID doesn't refer to a known Instance
+ */
+export function recall(id) {
+ return instances[id];
+}
+
+/**
+ * Release the memory space for a given {@link module:recurrence~RecurInstance|RecurInstance} by {@link module:uuids~UUIDv4|UUID}
+ * @param {module:uuids~UUIDv4} id - The {@link module:uuids~UUIDv4|UUID} of a previously created {@link module:recurrence~RecurInstance|RecurInstance}
+ * @returns {Boolean} Whether the reference to the stored {@link module:recurrence~RecurInstance|RecurInstance} was released
+ */
+export function free(id) {
+ return recall(id)?.free() || false;
+}
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 1593a363dd1..6ff2af47dd8 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.replace(/\\n/g, '\n'));
+ textToInsert = tag.replace(textPlaceholder, () => selected.replace(/\\n/g, '\n'));
} else {
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
diff --git a/app/assets/javascripts/diffs/utils/uuids.js b/app/assets/javascripts/lib/utils/uuids.js
index 98fe4bf9664..98fe4bf9664 100644
--- a/app/assets/javascripts/diffs/utils/uuids.js
+++ b/app/assets/javascripts/lib/utils/uuids.js
diff --git a/app/assets/javascripts/lib/utils/vuex_module_mappers.js b/app/assets/javascripts/lib/utils/vuex_module_mappers.js
new file mode 100644
index 00000000000..95a794dd268
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/vuex_module_mappers.js
@@ -0,0 +1,91 @@
+import { mapValues, isString } from 'lodash';
+import { mapState, mapActions } from 'vuex';
+
+export const REQUIRE_STRING_ERROR_MESSAGE =
+ '`vuex_module_mappers` can only be used with an array of strings, or an object with string values. Consider using the regular `vuex` map helpers instead.';
+
+const normalizeFieldsToObject = (fields) => {
+ return Array.isArray(fields)
+ ? fields.reduce((acc, key) => Object.assign(acc, { [key]: key }), {})
+ : fields;
+};
+
+const mapVuexModuleFields = ({ namespaceSelector, fields, vuexHelper, selector } = {}) => {
+ // The `vuexHelper` needs an object which maps keys to field selector functions.
+ const map = mapValues(normalizeFieldsToObject(fields), (value) => {
+ if (!isString(value)) {
+ throw new Error(REQUIRE_STRING_ERROR_MESSAGE);
+ }
+
+ // We need to use a good ol' function to capture the right "this".
+ return function mappedFieldSelector(...args) {
+ const namespace = namespaceSelector(this);
+
+ return selector(namespace, value, ...args);
+ };
+ });
+
+ return vuexHelper(map);
+};
+
+/**
+ * Like `mapState`, but takes a function in the first param for selecting a namespace.
+ *
+ * ```
+ * computed: {
+ * ...mapVuexModuleState(vm => vm.vuexModule, ['foo']),
+ * }
+ * ```
+ *
+ * @param {Function} namespaceSelector
+ * @param {Array|Object} fields
+ */
+export const mapVuexModuleState = (namespaceSelector, fields) =>
+ mapVuexModuleFields({
+ namespaceSelector,
+ fields,
+ vuexHelper: mapState,
+ selector: (namespace, value, state) => state[namespace][value],
+ });
+
+/**
+ * Like `mapActions`, but takes a function in the first param for selecting a namespace.
+ *
+ * ```
+ * methods: {
+ * ...mapVuexModuleActions(vm => vm.vuexModule, ['fetchFoos']),
+ * }
+ * ```
+ *
+ * @param {Function} namespaceSelector
+ * @param {Array|Object} fields
+ */
+export const mapVuexModuleActions = (namespaceSelector, fields) =>
+ mapVuexModuleFields({
+ namespaceSelector,
+ fields,
+ vuexHelper: mapActions,
+ selector: (namespace, value, dispatch, ...args) => dispatch(`${namespace}/${value}`, ...args),
+ });
+
+/**
+ * Like `mapGetters`, but takes a function in the first param for selecting a namespace.
+ *
+ * ```
+ * computed: {
+ * ...mapGetters(vm => vm.vuexModule, ['hasSearchInfo']),
+ * }
+ * ```
+ *
+ * @param {Function} namespaceSelector
+ * @param {Array|Object} fields
+ */
+export const mapVuexModuleGetters = (namespaceSelector, fields) =>
+ mapVuexModuleFields({
+ namespaceSelector,
+ fields,
+ // `mapGetters` does not let us pass an object which maps to functions. Thankfully `mapState` does
+ // and gives us access to the getters.
+ vuexHelper: mapState,
+ selector: (namespace, value, state, getters) => getters[`${namespace}/${value}`],
+ });
diff --git a/app/assets/javascripts/logs/components/log_advanced_filters.vue b/app/assets/javascripts/logs/components/log_advanced_filters.vue
index 9159ca5b9dc..c6d7c9ad1dc 100644
--- a/app/assets/javascripts/logs/components/log_advanced_filters.vue
+++ b/app/assets/javascripts/logs/components/log_advanced_filters.vue
@@ -1,8 +1,9 @@
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { timeRanges } from '~/vue_shared/constants';
import { TOKEN_TYPE_POD_NAME } from '../constants';
import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
@@ -54,7 +55,7 @@ export default {
type: TOKEN_TYPE_POD_NAME,
title: s__('Environments|Pod name'),
token: TokenWithLoadingState,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
unique: true,
options: this.podOptions,
loading: this.logs.isLoading,
diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js
index e813f91d2fa..c3dc9f4bc12 100644
--- a/app/assets/javascripts/logs/stores/actions.js
+++ b/app/assets/javascripts/logs/stores/actions.js
@@ -127,7 +127,7 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
return axios
.get(environmentsPath)
.then(({ data }) => {
- commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
+ commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
})
.catch(() => {
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 3f22bd36a4a..6200ade3595 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -35,6 +35,7 @@ import initUsagePingConsent from './usage_ping_consent';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
+import { initTopNav } from './nav';
import 'ee_else_ce/main_ee';
@@ -80,6 +81,7 @@ initRails();
function deferredInitialisation() {
const $body = $('body');
+ initTopNav();
initBreadcrumbs();
initTodoToggle();
initLogoAnimation();
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 cc97d235a9c..cc0533391df 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
@@ -1,10 +1,11 @@
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import { mapState } from 'vuex';
-import { getParameterByName } from '~/lib/utils/common_utils';
-import { setUrlParams, queryToObject } from '~/lib/utils/url_utility';
+import { getParameterByName, urlParamsToObject } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
export default {
@@ -17,7 +18,7 @@ export default {
title: s__('Members|2FA'),
token: GlFilteredSearchToken,
unique: true,
- operators: [{ value: '=', description: 'is' }],
+ operators: OPERATOR_IS_ONLY,
options: [
{ value: 'enabled', title: s__('Members|Enabled') },
{ value: 'disabled', title: s__('Members|Disabled') },
@@ -30,7 +31,7 @@ export default {
title: s__('Members|Membership'),
token: GlFilteredSearchToken,
unique: true,
- operators: [{ value: '=', description: 'is' }],
+ operators: OPERATOR_IS_ONLY,
options: [
{ value: 'exclude', title: s__('Members|Direct') },
{ value: 'only', title: s__('Members|Inherited') },
@@ -63,7 +64,7 @@ export default {
},
},
created() {
- const query = queryToObject(window.location.search);
+ const query = urlParamsToObject(window.location.search);
const tokens = this.tokens
.filter((token) => query[token.type])
@@ -97,9 +98,12 @@ export default {
if (type === SEARCH_TOKEN_TYPE) {
if (value.data !== '') {
+ const { searchParam } = this.filteredSearchBar;
+ const { [searchParam]: searchParamValue } = accumulator;
+
return {
...accumulator,
- [this.filteredSearchBar.searchParam]: value.data,
+ [searchParam]: searchParamValue ? `${searchParamValue} ${value.data}` : value.data,
};
}
} else {
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
new file mode 100644
index 00000000000..37b9135126d
--- /dev/null
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlTabs, GlTab, GlBadge } from '@gitlab/ui';
+import { mapState } from 'vuex';
+import { urlParamsToObject } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import { MEMBER_TYPES } from '../constants';
+import MembersApp from './app.vue';
+
+const countComputed = (state, namespace) => state[namespace]?.pagination?.totalItems || 0;
+
+export default {
+ name: 'MembersTabs',
+ tabs: [
+ {
+ namespace: MEMBER_TYPES.user,
+ title: __('Members'),
+ },
+ {
+ namespace: MEMBER_TYPES.group,
+ title: __('Groups'),
+ attrs: { 'data-qa-selector': 'groups_list_tab' },
+ },
+ {
+ namespace: MEMBER_TYPES.invite,
+ title: __('Invited'),
+ canManageMembersPermissionsRequired: true,
+ },
+ {
+ namespace: MEMBER_TYPES.accessRequest,
+ title: __('Access requests'),
+ canManageMembersPermissionsRequired: true,
+ },
+ ],
+ urlParams: [],
+ components: { MembersApp, GlTabs, GlTab, GlBadge },
+ inject: ['canManageMembers'],
+ data() {
+ return {
+ selectedTabIndex: 0,
+ };
+ },
+ computed: {
+ ...mapState({
+ userCount(state) {
+ return countComputed(state, MEMBER_TYPES.user);
+ },
+ groupCount(state) {
+ return countComputed(state, MEMBER_TYPES.group);
+ },
+ inviteCount(state) {
+ return countComputed(state, MEMBER_TYPES.invite);
+ },
+ accessRequestCount(state) {
+ return countComputed(state, MEMBER_TYPES.accessRequest);
+ },
+ }),
+ urlParams() {
+ return Object.keys(urlParamsToObject(window.location.search));
+ },
+ activeTabIndexCalculatedFromUrlParams() {
+ return this.$options.tabs.findIndex(({ namespace }) => {
+ return this.getTabUrlParams(namespace).some((urlParam) =>
+ this.urlParams.includes(urlParam),
+ );
+ });
+ },
+ },
+ created() {
+ if (this.activeTabIndexCalculatedFromUrlParams === -1) {
+ return;
+ }
+
+ this.selectedTabIndex = this.activeTabIndexCalculatedFromUrlParams;
+ },
+ methods: {
+ getTabUrlParams(namespace) {
+ const state = this.$store.state[namespace];
+ const urlParams = [];
+
+ if (state?.pagination?.paramName) {
+ urlParams.push(state.pagination.paramName);
+ }
+
+ if (state?.filteredSearchBar?.searchParam) {
+ urlParams.push(state.filteredSearchBar.searchParam);
+ }
+
+ return urlParams;
+ },
+ getTabCount({ namespace }) {
+ return this[`${namespace}Count`];
+ },
+ showTab(tab, index) {
+ if (tab.namespace === MEMBER_TYPES.user) {
+ return true;
+ }
+
+ const { canManageMembersPermissionsRequired = false } = tab;
+ const tabCanBeShown =
+ this.getTabCount(tab) > 0 || this.activeTabIndexCalculatedFromUrlParams === index;
+
+ if (canManageMembersPermissionsRequired) {
+ return this.canManageMembers && tabCanBeShown;
+ }
+
+ return tabCanBeShown;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tabs v-model="selectedTabIndex">
+ <template v-for="(tab, index) in $options.tabs">
+ <gl-tab v-if="showTab(tab, index)" :key="tab.namespace" :title-link-attributes="tab.attrs">
+ <template slot="title">
+ <span>{{ tab.title }}</span>
+ <gl-badge size="sm" class="gl-tab-counter-badge">{{ getTabCount(tab) }}</gl-badge>
+ </template>
+ <members-app :namespace="tab.namespace" />
+ </gl-tab>
+ </template>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 236aeaef418..09ef98ec411 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -1,8 +1,9 @@
<script>
-import { GlTable, GlBadge } from '@gitlab/ui';
+import { GlTable, GlBadge, GlPagination } from '@gitlab/ui';
import { mapState } from 'vuex';
import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import initUserPopovers from '~/user_popovers';
import { FIELDS } from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
@@ -19,6 +20,7 @@ export default {
components: {
GlTable,
GlBadge,
+ GlPagination,
MemberAvatar,
CreatedAt,
ExpiresAt,
@@ -43,6 +45,9 @@ export default {
tableAttrs(state) {
return state[this.namespace].tableAttrs;
},
+ pagination(state) {
+ return state[this.namespace].pagination;
+ },
}),
filteredFields() {
return FIELDS.filter(
@@ -59,6 +64,11 @@ export default {
userIsLoggedIn() {
return this.currentUserId !== null;
},
+ showPagination() {
+ const { paramName, currentPage, perPage, totalItems } = this.pagination;
+
+ return paramName && currentPage && perPage && totalItems;
+ },
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
@@ -99,6 +109,11 @@ export default {
...(member?.id && { 'data-testid': `members-table-row-${member.id}` }),
};
},
+ paginationLinkGenerator(page) {
+ const { params = {}, paramName } = this.pagination;
+
+ return mergeUrlParams({ ...params, [paramName]: page }, window.location.href);
+ },
},
};
</script>
@@ -119,6 +134,9 @@ export default {
show-empty
:tbody-tr-attr="tbodyTrAttr"
>
+ <template #head()="{ label }">
+ {{ label }}
+ </template>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member">
<member-avatar
@@ -179,6 +197,18 @@ export default {
<span data-testid="col-actions" class="gl-sr-only">{{ label }}</span>
</template>
</gl-table>
+ <gl-pagination
+ v-if="showPagination"
+ :value="pagination.currentPage"
+ :per-page="pagination.perPage"
+ :total-items="pagination.totalItems"
+ :link-gen="paginationLinkGenerator"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ :label-next-page="__('Go to next page')"
+ :label-prev-page="__('Go to previous page')"
+ align="center"
+ />
<remove-group-link-modal />
<ldap-override-confirmation-modal />
</div>
diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js
index 6376b3fa75a..6c913af8a0f 100644
--- a/app/assets/javascripts/members/index.js
+++ b/app/assets/javascripts/members/index.js
@@ -1,7 +1,7 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
-import { parseDataAttributes } from 'ee_else_ce/members/utils';
+import { parseDataAttributes } from '~/members/utils';
import App from './components/app.vue';
import membersStore from './store';
diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js
index 4006b4b501d..5415b1c5f25 100644
--- a/app/assets/javascripts/members/store/state.js
+++ b/app/assets/javascripts/members/store/state.js
@@ -1,5 +1,6 @@
export default ({
members,
+ pagination,
tableFields,
tableAttrs,
tableSortableFields,
@@ -8,6 +9,7 @@ export default ({
filteredSearchBar,
}) => ({
members,
+ pagination,
tableFields,
tableAttrs,
tableSortableFields,
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index 2bf30dd7b6e..be549b40885 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -1,9 +1,5 @@
import { isUndefined } from 'lodash';
-import {
- getParameterByName,
- convertObjectPropsToCamelCase,
- parseBoolean,
-} from '~/lib/utils/common_utils';
+import { getParameterByName, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
@@ -105,14 +101,12 @@ export const buildSortHref = ({
export const canOverride = () => false;
export const parseDataAttributes = (el) => {
- const { members, sourceId, memberPath, canManageMembers } = el.dataset;
+ const { membersData } = el.dataset;
- return {
- members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
- sourceId: parseInt(sourceId, 10),
- memberPath,
- canManageMembers: parseBoolean(canManageMembers),
- };
+ return convertObjectPropsToCamelCase(JSON.parse(membersData), {
+ deep: true,
+ ignoreKeyNames: ['params'],
+ });
};
export const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({
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 7649c363daa..04e493712ec 100644
--- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
+++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue
@@ -1,4 +1,5 @@
<script>
+import { GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapActions } from 'vuex';
import { deprecatedCreateFlash as flash } from '~/flash';
@@ -7,6 +8,9 @@ import { __ } from '~/locale';
import { INTERACTIVE_RESOLVE_MODE } from '../constants';
export default {
+ components: {
+ GlButton,
+ },
props: {
file: {
type: Object,
@@ -100,21 +104,21 @@ export default {
};
</script>
<template>
- <div v-show="file.showEditor" class="diff-editor-wrap">
- <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert-wrap">
- <div class="discard-changes-alert">
- {{ __('Are you sure you want to discard your changes?') }}
- <div class="discard-actions">
- <button
- class="btn btn-sm btn-danger-secondary gl-button"
- @click="acceptDiscardConfirmation(file)"
- >
- {{ __('Discard changes') }}
- </button>
- <button class="btn btn-default btn-sm gl-button" @click="cancelDiscardConfirmation(file)">
- {{ __('Cancel') }}
- </button>
- </div>
+ <div v-show="file.showEditor">
+ <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert">
+ {{ __('Are you sure you want to discard your changes?') }}
+ <div class="gl-ml-3 gl-display-inline-block">
+ <gl-button
+ size="small"
+ variant="danger"
+ category="secondary"
+ @click="acceptDiscardConfirmation(file)"
+ >
+ {{ __('Discard changes') }}
+ </gl-button>
+ <gl-button size="small" @click="cancelDiscardConfirmation(file)">
+ {{ __('Cancel') }}
+ </gl-button>
</div>
</div>
<div :class="classObject" class="editor-wrap">
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 9721481e6be..a856d38c089 100644
--- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue
@@ -35,7 +35,7 @@ export default {
<td :class="lineCssClass(line)" class="diff-line-num header"></td>
<td :class="lineCssClass(line)" class="line_content header">
<strong>{{ line.richText }}</strong>
- <button class="btn" @click="handleSelected({ file, line })">
+ <button type="button" @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 7b1d947ccff..2c89b614c87 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue
@@ -35,7 +35,7 @@ export default {
<td class="diff-line-num header" :class="lineCssClass(line)"></td>
<td class="line_content header" :class="lineCssClass(line)">
<strong>{{ line.richText }}</strong>
- <button class="btn" @click="handleSelected({ file, line })">
+ <button type="button" @click="handleSelected({ file, line })">
{{ line.buttonTitle }}
</button>
</td>
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 0509cf0afa1..3e31e2e93ae 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlButton, GlButtonGroup } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -20,6 +20,8 @@ import { INTERACTIVE_RESOLVE_MODE } from './constants';
*/
export default {
components: {
+ GlButton,
+ GlButtonGroup,
GlSprintf,
FileIcon,
DiffFileEditor,
@@ -77,28 +79,23 @@ export default {
{{ conflictsData.errorMessage }}
</div>
<template v-if="!isLoading && !hasError">
- <div class="content-block oneline-block files-changed">
- <div v-if="fileTextTypePresent" class="inline-parallel-buttons">
- <div class="btn-group">
- <button
- :class="{ active: !isParallel }"
- class="btn gl-button"
- @click="setViewType('inline')"
- >
+ <div class="gl-border-b-0 gl-py-5 gl-line-height-32">
+ <div v-if="fileTextTypePresent" class="gl-float-right">
+ <gl-button-group>
+ <gl-button :selected="!isParallel" @click="setViewType('inline')">
{{ __('Inline') }}
- </button>
- <button
- :class="{ active: isParallel }"
- class="btn gl-button"
+ </gl-button>
+ <gl-button
+ :selected="isParallel"
data-testid="side-by-side"
@click="setViewType('parallel')"
>
{{ __('Side-by-side') }}
- </button>
- </div>
+ </gl-button>
+ </gl-button-group>
</div>
<div class="js-toggle-container">
- <div class="commit-stat-summary" data-testid="conflicts-count">
+ <div data-testid="conflicts-count">
<gl-sprintf :message="$options.i18n.commitStatSummary">
<template #conflict>
<strong class="cred">{{ getConflictsCountText }}</strong>
@@ -127,47 +124,43 @@ export default {
<strong class="file-title-name">{{ file.filePath }}</strong>
</div>
<div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start">
- <div v-if="file.type === 'text'" class="btn-group gl-mr-3">
- <button
- :class="{ active: file.resolveMode === 'interactive' }"
- class="btn gl-button"
- type="button"
+ <gl-button-group v-if="file.type === 'text'" class="gl-mr-3">
+ <gl-button
+ :selected="file.resolveMode === 'interactive'"
data-testid="interactive-button"
@click="onClickResolveModeButton(file, 'interactive')"
>
{{ __('Interactive mode') }}
- </button>
- <button
- :class="{ active: file.resolveMode === 'edit' }"
- class="btn gl-button"
- type="button"
+ </gl-button>
+ <gl-button
+ :selected="file.resolveMode === 'edit'"
data-testid="inline-button"
@click="onClickResolveModeButton(file, 'edit')"
>
{{ __('Edit inline') }}
- </button>
- </div>
- <a :href="file.blobPath" class="btn gl-button view-file">
+ </gl-button>
+ </gl-button-group>
+ <gl-button :href="file.blobPath">
<gl-sprintf :message="__('View file @ %{commitSha}')">
<template #commitSha>
{{ conflictsData.shortCommitSha }}
</template>
</gl-sprintf>
- </a>
+ </gl-button>
</div>
</div>
<div class="diff-content diff-wrap-lines">
- <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
+ v-if="file.resolveMode === 'interactive' && file.type === 'text'"
+ class="file-content"
+ >
+ <parallel-conflict-lines v-if="isParallel" :file="file" />
+ <inline-conflict-lines v-else :file="file" />
</div>
+ <diff-file-editor
+ v-if="file.resolveMode === 'edit' || file.type === 'text-editor'"
+ :file="file"
+ />
</div>
</div>
</div>
@@ -176,10 +169,10 @@ export default {
<div class="resolve-conflicts-form">
<div class="form-group row">
<div class="col-md-4">
- <h4>
+ <h4 class="gl-mt-0">
{{ __('Resolve conflicts on source branch') }}
</h4>
- <div class="resolve-info">
+ <div class="gl-mb-5" data-testid="resolve-info">
<gl-sprintf :message="$options.i18n.resolveInfo">
<template #use_ours>
<code>{{ s__('MergeConflict|Use ours') }}</code>
@@ -199,7 +192,7 @@ export default {
<label class="label-bold" for="commit-message">
{{ __('Commit message') }}
</label>
- <div class="commit-message-container">
+ <div class="commit-message-container gl-mb-4">
<div class="max-width-marker"></div>
<textarea
id="commit-message"
@@ -209,27 +202,17 @@ export default {
rows="5"
></textarea>
</div>
- </div>
- </div>
- <div class="form-group row">
- <div class="offset-md-4 col-md-8">
- <div class="row">
- <div class="col-6">
- <button
- :disabled="!isReadyToCommit"
- class="btn gl-button btn-success js-submit-button"
- type="button"
- @click="submitResolvedConflicts(resolveConflictsPath)"
- >
- <span>{{ getCommitButtonText }}</span>
- </button>
- </div>
- <div class="col-6 text-right">
- <a :href="mergeRequestPath" class="gl-button btn btn-default">
- {{ __('Cancel') }}
- </a>
- </div>
- </div>
+ <gl-button
+ :disabled="!isReadyToCommit"
+ variant="confirm"
+ class="js-submit-button"
+ @click="submitResolvedConflicts(resolveConflictsPath)"
+ >
+ {{ getCommitButtonText }}
+ </gl-button>
+ <gl-button :href="mergeRequestPath">
+ {{ __('Cancel') }}
+ </gl-button>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/merge_request/components/status_box.vue b/app/assets/javascripts/merge_request/components/status_box.vue
deleted file mode 100644
index 526aafc1def..00000000000
--- a/app/assets/javascripts/merge_request/components/status_box.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-import mrEventHub from '../eventhub';
-
-const CLASSES = {
- opened: 'status-box-open',
- locked: 'status-box-open',
- closed: 'status-box-mr-closed',
- merged: 'status-box-mr-merged',
-};
-
-const STATUS = {
- opened: [__('Open'), 'issue-open-m'],
- locked: [__('Open'), 'issue-open-m'],
- closed: [__('Closed'), 'issue-close'],
- merged: [__('Merged'), 'git-merge'],
-};
-
-export default {
- components: {
- GlIcon,
- },
- props: {
- initialState: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- state: this.initialState,
- };
- },
- computed: {
- statusBoxClass() {
- return CLASSES[this.state];
- },
- statusHumanName() {
- return STATUS[this.state][0];
- },
- statusIconName() {
- return STATUS[this.state][1];
- },
- },
- created() {
- mrEventHub.$on('mr.state.updated', this.updateState);
- },
- beforeDestroy() {
- mrEventHub.$off('mr.state.updated', this.updateState);
- },
- methods: {
- updateState({ state }) {
- this.state = state;
- },
- },
-};
-</script>
-
-<template>
- <div :class="statusBoxClass" class="issuable-status-box status-box">
- <gl-icon
- :name="statusIconName"
- class="gl-display-block gl-sm-display-none!"
- data-testid="status-icon"
- />
- <span class="gl-display-none gl-sm-display-block">
- {{ statusHumanName }}
- </span>
- </div>
-</template>
diff --git a/app/assets/javascripts/merge_request/eventhub.js b/app/assets/javascripts/merge_request/eventhub.js
deleted file mode 100644
index e31806ad199..00000000000
--- a/app/assets/javascripts/merge_request/eventhub.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import createEventHub from '~/helpers/event_hub_factory';
-
-export default createEventHub();
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 67b24793a65..d5db9f43d09 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -355,6 +355,8 @@ export default class MergeRequestTabs {
this.commitPipelinesTable = new Vue({
provide: {
+ artifactsEndpoint: pipelineTableViewEl.dataset.artifactsEndpoint,
+ artifactsEndpointPlaceholder: pipelineTableViewEl.dataset.artifactsEndpointPlaceholder,
targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
},
render(createElement) {
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index bfb18206b62..05e7fb7a3e9 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -8,6 +8,7 @@ import invalidUrl from '~/lib/utils/invalid_url';
import { ESC_KEY } from '~/lib/utils/keys';
import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
+import AlertsDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue';
import { defaultTimeRange } from '~/vue_shared/constants';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { metricStates, keyboardShortcutKeys } from '../constants';
@@ -28,6 +29,7 @@ import VariablesSection from './variables_section.vue';
export default {
components: {
+ AlertsDeprecationWarning,
VueDraggable,
DashboardHeader,
DashboardPanel,
@@ -394,6 +396,8 @@ export default {
<template>
<div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
+ <alerts-deprecation-warning />
+
<dashboard-header
v-if="showHeader"
ref="prometheusGraphsHeader"
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
index ee67e5dd827..cf79e71b9e0 100644
--- a/app/assets/javascripts/monitoring/monitoring_app.js
+++ b/app/assets/javascripts/monitoring/monitoring_app.js
@@ -12,7 +12,10 @@ export default (props = {}) => {
if (el && el.dataset) {
const { metricsDashboardBasePath, ...dataset } = el.dataset;
- const { initState, dataProps } = stateAndPropsFromDataset(dataset);
+ const {
+ initState,
+ dataProps: { hasManagedPrometheus, ...dataProps },
+ } = stateAndPropsFromDataset(dataset);
const store = createStore(initState);
const router = createRouter(metricsDashboardBasePath);
@@ -21,6 +24,7 @@ export default (props = {}) => {
el,
store,
router,
+ provide: { hasManagedPrometheus },
data() {
return {
dashboardProps: { ...dataProps, ...props },
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 6306415a8b9..8adf1862af2 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -41,6 +41,7 @@ export const stateAndPropsFromDataset = (dataset = {}) => {
dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
+ dataProps.hasManagedPrometheus = parseBoolean(dataProps.hasManagedPrometheus);
return {
initState: {
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue
new file mode 100644
index 00000000000..f8f3ba26536
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_app.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlNav, GlNavItemDropdown, GlDropdownForm, GlTooltip } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
+
+const TOOLTIP = s__('TopNav|Switch to...');
+
+export default {
+ components: {
+ GlNav,
+ GlNavItemDropdown,
+ GlDropdownForm,
+ GlTooltip,
+ TopNavDropdownMenu,
+ },
+ props: {
+ navData: {
+ type: Object,
+ required: true,
+ },
+ },
+ methods: {
+ findTooltipTarget() {
+ // ### Why use a target function instead of `v-gl-tooltip`?
+ // To get the tooltip to align correctly, we need it to target the actual
+ // toggle button which we don't directly render.
+ return this.$el.querySelector('.js-top-nav-dropdown-toggle');
+ },
+ },
+ TOOLTIP,
+};
+</script>
+
+<template>
+ <gl-nav class="navbar-sub-nav">
+ <gl-nav-item-dropdown
+ :text="navData.activeTitle"
+ icon="dot-grid"
+ menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto!"
+ toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
+ no-flip
+ >
+ <gl-dropdown-form>
+ <top-nav-dropdown-menu
+ :primary="navData.primary"
+ :secondary="navData.secondary"
+ :views="navData.views"
+ />
+ </gl-dropdown-form>
+ </gl-nav-item-dropdown>
+ <gl-tooltip
+ boundary="window"
+ :boundary-padding="0"
+ :target="findTooltipTarget"
+ placement="right"
+ :title="$options.TOOLTIP"
+ />
+ </gl-nav>
+</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_container_view.vue b/app/assets/javascripts/nav/components/top_nav_container_view.vue
new file mode 100644
index 00000000000..21ff3ebcd7d
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_container_view.vue
@@ -0,0 +1,74 @@
+<script>
+import FrequentItemsApp from '~/frequent_items/components/app.vue';
+import eventHub from '~/frequent_items/event_hub';
+import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
+import TopNavMenuItem from './top_nav_menu_item.vue';
+
+export default {
+ components: {
+ FrequentItemsApp,
+ TopNavMenuItem,
+ VuexModuleProvider,
+ },
+ props: {
+ frequentItemsVuexModule: {
+ type: String,
+ required: true,
+ },
+ frequentItemsDropdownType: {
+ type: String,
+ required: true,
+ },
+ linksPrimary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ linksSecondary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ linkGroups() {
+ return [
+ { key: 'primary', links: this.linksPrimary },
+ { key: 'secondary', links: this.linksSecondary },
+ ].filter((x) => x.links?.length);
+ },
+ },
+ mounted() {
+ // For historic reasons, the frequent-items-app component requires this too start up.
+ this.$nextTick(() => {
+ eventHub.$emit(`${this.frequentItemsDropdownType}-dropdownOpen`);
+ });
+ },
+};
+</script>
+
+<template>
+ <div class="top-nav-container-view gl-display-flex gl-flex-direction-column">
+ <div class="frequent-items-dropdown-container gl-w-auto">
+ <div class="frequent-items-dropdown-content gl-w-full! gl-pt-0!">
+ <vuex-module-provider :vuex-module="frequentItemsVuexModule">
+ <frequent-items-app v-bind="$attrs" />
+ </vuex-module-provider>
+ </div>
+ </div>
+ <div
+ v-for="({ key, links }, groupIndex) in linkGroups"
+ :key="key"
+ :class="{ 'gl-mt-3': groupIndex !== 0 }"
+ class="gl-mt-auto gl-pt-3 gl-border-1 gl-border-t-solid gl-border-gray-100"
+ data-testid="menu-item-group"
+ >
+ <top-nav-menu-item
+ v-for="(link, linkIndex) in links"
+ :key="link.title"
+ :menu-item="link"
+ :class="{ 'gl-mt-1': linkIndex !== 0 }"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
new file mode 100644
index 00000000000..1cbd64b501d
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_dropdown_menu.vue
@@ -0,0 +1,144 @@
+<script>
+import { FREQUENT_ITEMS_PROJECTS, FREQUENT_ITEMS_GROUPS } from '~/frequent_items/constants';
+import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
+import TopNavContainerView from './top_nav_container_view.vue';
+import TopNavMenuItem from './top_nav_menu_item.vue';
+
+const ACTIVE_CLASS = 'gl-shadow-none! gl-font-weight-bold! active';
+const SECONDARY_GROUP_CLASS = 'gl-pt-3 gl-mt-3 gl-border-1 gl-border-t-solid gl-border-gray-100';
+
+export default {
+ components: {
+ KeepAliveSlots,
+ TopNavContainerView,
+ TopNavMenuItem,
+ },
+ props: {
+ primary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ secondary: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ views: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ activeId: '',
+ };
+ },
+ computed: {
+ menuItemGroups() {
+ return [
+ { key: 'primary', items: this.primary, classes: '' },
+ {
+ key: 'secondary',
+ items: this.secondary,
+ classes: SECONDARY_GROUP_CLASS,
+ },
+ ].filter((x) => x.items?.length);
+ },
+ allMenuItems() {
+ return this.menuItemGroups.flatMap((x) => x.items);
+ },
+ activeMenuItem() {
+ return this.allMenuItems.find((x) => x.id === this.activeId);
+ },
+ activeView() {
+ return this.activeMenuItem?.view;
+ },
+ menuClass() {
+ if (!this.activeView) {
+ return 'gl-w-full';
+ }
+
+ return '';
+ },
+ },
+ created() {
+ // Initialize activeId based on initialization prop
+ this.activeId = this.allMenuItems.find((x) => x.active)?.id;
+ },
+ methods: {
+ onClick({ id, href }) {
+ // If we're a link, let's just do the default behavior so the view won't change
+ if (href) {
+ return;
+ }
+
+ this.activeId = id;
+ },
+ menuItemClasses(menuItem) {
+ if (menuItem.id === this.activeId) {
+ return ACTIVE_CLASS;
+ }
+
+ return '';
+ },
+ },
+ FREQUENT_ITEMS_PROJECTS,
+ FREQUENT_ITEMS_GROUPS,
+ // expose for unit tests
+ ACTIVE_CLASS,
+ SECONDARY_GROUP_CLASS,
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-stretch">
+ <div
+ class="gl-w-grid-size-30 gl-flex-shrink-0 gl-bg-gray-10"
+ :class="menuClass"
+ data-testid="menu-sidebar"
+ >
+ <div
+ class="gl-py-3 gl-px-5 gl-h-full gl-display-flex gl-align-items-stretch gl-flex-direction-column"
+ >
+ <div
+ v-for="group in menuItemGroups"
+ :key="group.key"
+ :class="group.classes"
+ data-testid="menu-item-group"
+ >
+ <top-nav-menu-item
+ v-for="(menu, index) in group.items"
+ :key="menu.id"
+ data-testid="menu-item"
+ :class="[{ 'gl-mt-1': index !== 0 }, menuItemClasses(menu)]"
+ :menu-item="menu"
+ @click="onClick(menu)"
+ />
+ </div>
+ </div>
+ </div>
+ <keep-alive-slots
+ v-show="activeView"
+ :slot-key="activeView"
+ class="gl-w-grid-size-40 gl-overflow-hidden gl-py-3 gl-px-5"
+ data-testid="menu-subview"
+ >
+ <template #projects>
+ <top-nav-container-view
+ :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_PROJECTS.namespace"
+ :frequent-items-vuex-module="$options.FREQUENT_ITEMS_PROJECTS.vuexModule"
+ v-bind="views.projects"
+ />
+ </template>
+ <template #groups>
+ <top-nav-container-view
+ :frequent-items-dropdown-type="$options.FREQUENT_ITEMS_GROUPS.namespace"
+ :frequent-items-vuex-module="$options.FREQUENT_ITEMS_GROUPS.vuexModule"
+ v-bind="views.groups"
+ />
+ </template>
+ </keep-alive-slots>
+ </div>
+</template>
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_item.vue b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
new file mode 100644
index 00000000000..a0d92811a6f
--- /dev/null
+++ b/app/assets/javascripts/nav/components/top_nav_menu_item.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ props: {
+ menuItem: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ category="tertiary"
+ :href="menuItem.href"
+ class="top-nav-menu-item gl-display-block"
+ v-on="$listeners"
+ >
+ <span class="gl-display-flex">
+ <gl-icon v-if="menuItem.icon" :name="menuItem.icon" class="gl-mr-2!" />
+ {{ menuItem.title }}
+ <gl-icon v-if="menuItem.view" name="chevron-right" class="gl-ml-auto" />
+ </span>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/nav/index.js b/app/assets/javascripts/nav/index.js
new file mode 100644
index 00000000000..646ce3f0ecf
--- /dev/null
+++ b/app/assets/javascripts/nav/index.js
@@ -0,0 +1,12 @@
+export const initTopNav = async () => {
+ const el = document.getElementById('js-top-nav');
+
+ if (!el) {
+ return;
+ }
+
+ // With combined_menu feature flag, there's a benefit to splitting up the import
+ const { mountTopNav } = await import(/* webpackChunkName: 'top_nav' */ './mount');
+
+ mountTopNav(el);
+};
diff --git a/app/assets/javascripts/nav/mount.js b/app/assets/javascripts/nav/mount.js
new file mode 100644
index 00000000000..0d46ff56249
--- /dev/null
+++ b/app/assets/javascripts/nav/mount.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import App from './components/top_nav_app.vue';
+import { createStore } from './stores';
+
+Vue.use(Vuex);
+
+export const mountTopNav = (el) => {
+ const viewModel = JSON.parse(el.dataset.viewModel);
+ const store = createStore();
+
+ return new Vue({
+ el,
+ store,
+ render(h) {
+ return h(App, {
+ props: {
+ navData: viewModel,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/nav/stores/index.js b/app/assets/javascripts/nav/stores/index.js
new file mode 100644
index 00000000000..527bbdd5c3f
--- /dev/null
+++ b/app/assets/javascripts/nav/stores/index.js
@@ -0,0 +1,4 @@
+import Vuex from 'vuex';
+import { createStoreOptions } from '~/frequent_items/store';
+
+export const createStore = () => new Vuex.Store(createStoreOptions());
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index c09db6851e5..9bf26e5a182 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -3,6 +3,7 @@
import katex from 'katex';
import marked from 'marked';
import { sanitize } from '~/lib/dompurify';
+import { hasContent } from '~/lib/utils/text_utility';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
@@ -88,6 +89,38 @@ renderer.listitem = (t) => {
const [text, inline] = renderKatex(t);
return `<li class="${inline ? 'inline-katex' : ''}">${text}</li>`;
};
+renderer.originalImage = renderer.image;
+
+renderer.image = function image(href, title, text) {
+ const attachmentHeader = `attachment:`; // eslint-disable-line @gitlab/require-i18n-strings
+
+ if (!this.attachments || !href.startsWith(attachmentHeader)) {
+ return this.originalImage(href, title, text);
+ }
+
+ let img = ``;
+ const filename = href.substring(attachmentHeader.length);
+
+ if (hasContent(filename)) {
+ const attachment = this.attachments[filename];
+
+ if (attachment) {
+ const imageType = Object.keys(attachment)[0];
+
+ if (hasContent(imageType)) {
+ const data = attachment[imageType];
+ const inlined = `data:${imageType};base64,${data}"`; // eslint-disable-line @gitlab/require-i18n-strings
+ img = this.originalImage(inlined, title, text);
+ }
+ }
+ }
+
+ if (!hasContent(img)) {
+ return this.originalImage(href, title, text);
+ }
+
+ return sanitize(img);
+};
marked.setOptions({
renderer,
@@ -105,6 +138,8 @@ export default {
},
computed: {
markdown() {
+ renderer.attachments = this.cell.attachments;
+
return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), {
// allowedTags from GitLab's inline HTML guidelines
// https://docs.gitlab.com/ee/user/markdown.html#inline-html
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index b5c59f34e87..c324c846f47 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1732,7 +1732,7 @@ export default class Notes {
// Submission failed, revert back to original note
$noteBodyText.html(escape(cachedNoteBodyText));
$editingNote.removeClass('being-posted fade-in');
- $editingNote.find('.spinner').remove();
+ $editingNote.find('.gl-spinner').remove();
// Show Flash message about failure
this.updateNoteError();
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 79d8ce78329..90be5b3e470 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -15,6 +15,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import { statusBoxState } from '~/issuable/components/status_box.vue';
import httpStatusCodes from '~/lib/utils/http_status';
import {
capitalizeFirstCharacter,
@@ -162,7 +163,7 @@ export default {
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
- this.getNoteableData.state !== constants.MERGED &&
+ this.openState !== constants.MERGED &&
!this.closedAndLocked
);
},
@@ -283,6 +284,7 @@ export default {
const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable;
toggleState()
+ .then(() => statusBoxState.updateStatus && statusBoxState.updateStatus())
.then(refreshUserMergeRequestCounts)
.catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState]));
},
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 24399e669a6..0cc818c6d0e 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -278,7 +278,6 @@ export default {
v-if="canResolve"
ref="resolveButton"
v-gl-tooltip
- size="small"
category="tertiary"
:variant="resolveVariant"
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
@@ -292,7 +291,7 @@ export default {
<template v-if="canAwardEmoji">
<emoji-picker
v-if="glFeatures.improvedEmojiPicker"
- toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!"
+ toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!"
@click="setAwardEmoji"
>
<template #button-content>
@@ -305,10 +304,9 @@ export default {
v-else
v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
- class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji"
+ class="note-action-button note-emoji-button add-reaction-button btn-icon js-add-award js-note-emoji"
category="tertiary"
variant="default"
- size="small"
:title="$options.i18n.addReactionLabel"
:aria-label="$options.i18n.addReactionLabel"
data-position="right"
@@ -336,7 +334,6 @@ export default {
:title="$options.i18n.editCommentLabel"
:aria-label="$options.i18n.editCommentLabel"
icon="pencil"
- size="small"
category="tertiary"
class="note-action-button js-note-edit"
data-qa-selector="note_edit_button"
@@ -347,24 +344,24 @@ export default {
v-gl-tooltip
:title="$options.i18n.deleteCommentLabel"
:aria-label="$options.i18n.deleteCommentLabel"
- size="small"
icon="remove"
category="tertiary"
class="note-action-button js-note-delete"
@click="onDelete"
/>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions">
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<gl-button
v-gl-tooltip
:title="$options.i18n.moreActionsLabel"
:aria-label="$options.i18n.moreActionsLabel"
icon="ellipsis_v"
- size="small"
category="tertiary"
class="note-action-button more-actions-toggle"
data-toggle="dropdown"
@click="closeTooltip"
/>
+ <!-- eslint-enable @gitlab/vue-no-data-toggle -->
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath">
{{ __('Report abuse to admin') }}
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index 5ce03091504..0cd2afcf8a0 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -22,7 +22,6 @@ export default {
data-track-event="click_button"
data-track-label="reply_comment_button"
category="tertiary"
- size="small"
icon="comment"
:title="$options.i18n.buttonText"
:aria-label="$options.i18n.buttonText"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index a70bac94b71..4ce81219f11 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -362,36 +362,26 @@ export default {
</template>
</markdown-field>
</comment-field-layout>
- <div class="note-form-actions clearfix">
+ <div class="note-form-actions">
<template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle">
<label>
<template v-if="discussionResolved">
- <input
- v-model="isUnresolving"
- type="checkbox"
- class="js-unresolve-checkbox"
- data-qa-selector="unresolve_review_discussion_checkbox"
- />
+ <input v-model="isUnresolving" type="checkbox" class="js-unresolve-checkbox" />
{{ __('Unresolve thread') }}
</template>
<template v-else>
- <input
- v-model="isResolving"
- type="checkbox"
- class="js-resolve-checkbox"
- data-qa-selector="resolve_review_discussion_checkbox"
- />
+ <input v-model="isResolving" type="checkbox" class="js-resolve-checkbox" />
{{ __('Resolve thread') }}
</template>
</label>
</p>
- <div class="gl-display-sm-flex gl-flex-wrap">
+ <div class="gl-display-flex gl-flex-wrap gl-mb-n3">
<gl-button
:disabled="isDisabled"
category="primary"
variant="confirm"
- class="gl-mr-3"
+ class="gl-sm-mr-3 gl-mb-3"
data-qa-selector="start_review_button"
@click="handleAddToReview"
>
@@ -401,15 +391,15 @@ export default {
<gl-button
:disabled="isDisabled"
category="secondary"
- variant="default"
+ variant="confirm"
data-qa-selector="comment_now_button"
- class="gl-mr-3 js-comment-button"
+ class="gl-sm-mr-3 gl-mb-3 js-comment-button"
@click="handleUpdate()"
>
{{ __('Add comment now') }}
</gl-button>
<gl-button
- class="note-edit-cancel js-close-discussion-note-form"
+ class="note-edit-cancel gl-mb-3 js-close-discussion-note-form"
category="secondary"
variant="default"
data-testid="cancelBatchCommentsEnabled"
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 1204d68159f..bdb85360be8 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -345,7 +345,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
// this is a temporary solution until we have confidentiality real-time updates
if (
confidentialWidget.setConfidentiality &&
- message.some((m) => m.includes('confidential'))
+ message.some((m) => m.includes('Made this issue confidential'))
) {
confidentialWidget.setConfidentiality();
}
@@ -468,15 +468,6 @@ const getFetchDataParams = (state) => {
return { endpoint, options };
};
-export const fetchData = ({ commit, state, getters, dispatch }) => {
- const { endpoint, options } = getFetchDataParams(state);
-
- axios
- .get(endpoint, options)
- .then(({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch))
- .catch(() => Flash(__('Something went wrong while fetching latest comments.')));
-};
-
export const poll = ({ commit, state, getters, dispatch }) => {
eTagPoll = new Poll({
resource: {
@@ -493,7 +484,7 @@ export const poll = ({ commit, state, getters, dispatch }) => {
if (!Visibility.hidden()) {
eTagPoll.makeDelayedRequest(2500);
} else {
- dispatch('fetchData');
+ eTagPoll.makeRequest();
}
Visibility.change(() => {
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 39f66063cfb..b04b1d28ffa 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -1,4 +1,6 @@
import { flattenDeep, clone } from 'lodash';
+import { statusBoxState } from '~/issuable/components/status_box.vue';
+import { isInMRPage } from '~/lib/utils/common_utils';
import * as constants from '../constants';
import { collapseSystemNotes } from './collapse_utils';
@@ -82,7 +84,8 @@ export const getBlockedByIssues = (state) => state.noteableData.blocked_by_issue
export const userCanReply = (state) => Boolean(state.noteableData.current_user.can_create_note);
-export const openState = (state) => state.noteableData.state;
+export const openState = (state) =>
+ isInMRPage() ? statusBoxState.state : state.noteableData.state;
export const getUserData = (state) => state.userData || {};
diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue
index b9532cb7e72..6974de99344 100644
--- a/app/assets/javascripts/packages/details/components/maven_installation.vue
+++ b/app/assets/javascripts/packages/details/components/maven_installation.vue
@@ -28,10 +28,15 @@ export default {
'mavenSetupXml',
'gradleGroovyInstalCommand',
'gradleGroovyAddSourceCommand',
+ 'gradleKotlinInstalCommand',
+ 'gradleKotlinAddSourceCommand',
]),
showMaven() {
return this.instructionType === 'maven';
},
+ showGroovy() {
+ return this.instructionType === 'groovy';
+ },
},
i18n: {
xmlText: s__(
@@ -47,8 +52,9 @@ export default {
trackingActions: { ...TrackingActions },
TrackingLabels,
installOptions: [
- { value: 'maven', label: s__('PackageRegistry|Show Maven commands') },
- { value: 'groovy', label: s__('PackageRegistry|Show Gradle Groovy DSL commands') },
+ { value: 'maven', label: s__('PackageRegistry|Maven XML') },
+ { value: 'groovy', label: s__('PackageRegistry|Gradle Groovy DSL') },
+ { value: 'kotlin', label: s__('PackageRegistry|Gradle Kotlin DSL') },
],
};
</script>
@@ -107,7 +113,7 @@ export default {
</template>
</gl-sprintf>
</template>
- <template v-else>
+ <template v-else-if="showGroovy">
<code-instruction
class="gl-mb-5"
:label="s__('PackageRegistry|Gradle Groovy DSL install command')"
@@ -125,5 +131,23 @@ export default {
multiline
/>
</template>
+ <template v-else>
+ <code-instruction
+ class="gl-mb-5"
+ :label="s__('PackageRegistry|Gradle Kotlin DSL install command')"
+ :instruction="gradleKotlinInstalCommand"
+ :copy-text="s__('PackageRegistry|Copy Gradle Kotlin DSL install command')"
+ :tracking-action="$options.trackingActions.COPY_KOTLIN_INSTALL_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ />
+ <code-instruction
+ :label="s__('PackageRegistry|Add Gradle Kotlin DSL repository command')"
+ :instruction="gradleKotlinAddSourceCommand"
+ :copy-text="s__('PackageRegistry|Copy add Gradle Kotlin DSL repository command')"
+ :tracking-action="$options.trackingActions.COPY_KOTLIN_ADD_TO_SOURCE_COMMAND"
+ :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
+ multiline
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue
index 18f15e2c63e..6b0fcf5e4fe 100644
--- a/app/assets/javascripts/packages/details/components/npm_installation.vue
+++ b/app/assets/javascripts/packages/details/components/npm_installation.vue
@@ -14,6 +14,11 @@ export default {
GlLink,
GlSprintf,
},
+ data() {
+ return {
+ instructionType: 'npm',
+ };
+ },
computed: {
...mapState(['npmHelpPath']),
...mapGetters(['npmInstallationCommand', 'npmSetupCommand']),
@@ -29,6 +34,9 @@ export default {
yarnSetupCommand() {
return this.npmSetupCommand(NpmManager.YARN);
},
+ showNpm() {
+ return this.instructionType === 'npm';
+ },
},
i18n: {
helpText: s__(
@@ -37,16 +45,23 @@ export default {
},
trackingActions: { ...TrackingActions },
TrackingLabels,
- installOptions: [{ value: 'npm', label: s__('PackageRegistry|Show NPM commands') }],
+ installOptions: [
+ { value: 'npm', label: s__('PackageRegistry|Show NPM commands') },
+ { value: 'yarn', label: s__('PackageRegistry|Show Yarn commands') },
+ ],
};
</script>
<template>
<div>
- <installation-title package-type="npm" :options="$options.installOptions" />
+ <installation-title
+ package-type="npm"
+ :options="$options.installOptions"
+ @change="instructionType = $event"
+ />
<code-instruction
- :label="s__('PackageRegistry|npm command')"
+ v-if="showNpm"
:instruction="npmCommand"
:copy-text="s__('PackageRegistry|Copy npm command')"
:tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND"
@@ -54,7 +69,7 @@ export default {
/>
<code-instruction
- :label="s__('PackageRegistry|yarn command')"
+ v-else
:instruction="yarnCommand"
:copy-text="s__('PackageRegistry|Copy yarn command')"
:tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND"
@@ -64,7 +79,7 @@ export default {
<h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
<code-instruction
- :label="s__('PackageRegistry|npm command')"
+ v-if="showNpm"
:instruction="npmSetup"
:copy-text="s__('PackageRegistry|Copy npm setup command')"
:tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND"
@@ -72,7 +87,7 @@ export default {
/>
<code-instruction
- :label="s__('PackageRegistry|yarn command')"
+ v-else
:instruction="yarnSetupCommand"
:copy-text="s__('PackageRegistry|Copy yarn setup command')"
:tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND"
diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages/details/components/package_files.vue
index c5e929fe2a4..103d1f489bd 100644
--- a/app/assets/javascripts/packages/details/components/package_files.vue
+++ b/app/assets/javascripts/packages/details/components/package_files.vue
@@ -92,6 +92,7 @@ export default {
<template #cell(commit)="{ item }">
<gl-link
+ v-if="item.pipeline && item.pipeline.project"
:href="item.pipeline.project.commit_url"
class="gl-text-gray-500"
data-testid="commit-link"
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
index f0300ee29b4..cd34b1ad45a 100644
--- a/app/assets/javascripts/packages/details/constants.js
+++ b/app/assets/javascripts/packages/details/constants.js
@@ -38,6 +38,9 @@ export const TrackingActions = {
COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command',
COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command',
+
+ COPY_KOTLIN_INSTALL_COMMAND: 'copy_kotlin_install_command',
+ COPY_KOTLIN_ADD_TO_SOURCE_COMMAND: 'copy_kotlin_add_to_source_command',
};
export const NpmManager = {
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
index fb9b7d61fd2..ae273e26d6a 100644
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -126,4 +126,15 @@ export const gradleGroovyAddSourceCommand = ({ mavenPath }) =>
url '${mavenPath}'
}`;
+export const gradleKotlinInstalCommand = ({ packageEntity }) => {
+ const {
+ app_group: group = '',
+ app_name: name = '',
+ app_version: version = '',
+ } = packageEntity.maven_metadatum;
+ return `implementation("${group}:${name}:${version}")`;
+};
+
+export const gradleKotlinAddSourceCommand = ({ mavenPath }) => `maven("${mavenPath}")`;
+
export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue
index 2e183b1b978..869a2c2f641 100644
--- a/app/assets/javascripts/packages/list/components/package_search.vue
+++ b/app/assets/javascripts/packages/list/components/package_search.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import { sortableFields } from '../utils';
@@ -14,7 +15,7 @@ export default {
title: s__('PackageRegistry|Type'),
unique: true,
token: PackageTypeToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
},
],
components: { RegistrySearch, UrlSync },
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index b4fe3c70dea..d871c2e4d24 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -93,3 +93,5 @@ export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry');
export const LIST_INTRO_TEXT = s__(
'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}',
);
+
+export const TERRAFORM_SEARCH_TYPE = Object.freeze({ value: { data: 'terraform_module' } });
diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages/list/stores/actions.js
index 195117b9ddb..8dfe3c82ab3 100644
--- a/app/assets/javascripts/packages/list/stores/actions.js
+++ b/app/assets/javascripts/packages/list/stores/actions.js
@@ -8,6 +8,7 @@ import {
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
MISSING_DELETE_PATH_ERROR,
+ TERRAFORM_SEARCH_TYPE,
} from '../constants';
import { getNewPaginationPage } from '../utils';
import * as types from './mutation_types';
@@ -27,8 +28,9 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => {
const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params;
const { sort, orderBy } = state.sorting;
-
- const type = state.filter.find((f) => f.type === 'type');
+ const type = state.config.forceTerraform
+ ? TERRAFORM_SEARCH_TYPE
+ : state.filter.find((f) => f.type === 'type');
const name = state.filter.find((f) => f.type === 'filtered-search-term');
const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data };
diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js
index 4ce13cfcb29..98165e581b0 100644
--- a/app/assets/javascripts/packages/list/stores/mutations.js
+++ b/app/assets/javascripts/packages/list/stores/mutations.js
@@ -4,9 +4,8 @@ import * as types from './mutation_types';
export default {
[types.SET_INITIAL_STATE](state, config) {
- const { comingSoonJson, ...rest } = config;
state.config = {
- ...rest,
+ ...config,
isGroupPage: config.pageType === GROUP_PAGE_TYPE,
};
},
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 4de4c191e51..eee0e470c7b 100644
--- a/app/assets/javascripts/packages/shared/components/package_list_row.vue
+++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue
@@ -1,7 +1,9 @@
<script>
import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
+import { s__ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { PACKAGE_ERROR_STATUS, PACKAGE_DEFAULT_STATUS } from '../constants';
import { getPackageTypeLabel } from '../utils';
import PackagePath from './package_path.vue';
import PackageTags from './package_tags.vue';
@@ -70,22 +72,45 @@ export default {
hasProjectLink() {
return Boolean(this.packageEntity.project_path);
},
+ showWarningIcon() {
+ return this.packageEntity.status === PACKAGE_ERROR_STATUS;
+ },
+ disabledRow() {
+ return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
+ },
+ disabledDeleteButton() {
+ return this.disabledRow || !this.packageEntity._links.delete_api_path;
+ },
+ },
+ i18n: {
+ erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
},
};
</script>
<template>
- <list-item data-qa-selector="package_row">
+ <list-item data-qa-selector="package_row" :disabled="disabledRow">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<gl-link
:href="packageLink"
class="gl-text-body gl-min-w-0"
data-qa-selector="package_link"
+ :disabled="disabledRow"
>
<gl-truncate :text="packageEntity.name" />
</gl-link>
+ <gl-button
+ v-if="showWarningIcon"
+ v-gl-tooltip="{ title: $options.i18n.erroredPackageText }"
+ class="gl-hover-bg-transparent!"
+ icon="warning"
+ category="tertiary"
+ data-testid="warning-icon"
+ :aria-label="__('Warning')"
+ />
+
<package-tags
v-if="packageEntity.tags && packageEntity.tags.length"
class="gl-ml-3"
@@ -109,7 +134,11 @@ export default {
{{ packageType }}
</component>
- <package-path v-if="hasProjectLink" :path="packageEntity.project_path" />
+ <package-path
+ v-if="hasProjectLink"
+ :path="packageEntity.project_path"
+ :disabled="disabledRow"
+ />
</div>
</template>
@@ -137,7 +166,7 @@ export default {
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
- :disabled="!packageEntity._links.delete_api_path"
+ :disabled="disabledDeleteButton"
@click="$emit('packageToDelete', packageEntity)"
/>
</template>
diff --git a/app/assets/javascripts/packages/shared/components/package_path.vue b/app/assets/javascripts/packages/shared/components/package_path.vue
index 9afe06ab497..6fb001e5e92 100644
--- a/app/assets/javascripts/packages/shared/components/package_path.vue
+++ b/app/assets/javascripts/packages/shared/components/package_path.vue
@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pathPieces() {
@@ -45,7 +50,12 @@ export default {
<div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center">
<gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" />
- <gl-link data-testid="root-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${rootLink}`">
+ <gl-link
+ data-testid="root-link"
+ class="gl-text-gray-500 gl-min-w-0"
+ :href="`/${rootLink}`"
+ :disabled="disabled"
+ >
{{ root }}
</gl-link>
@@ -63,7 +73,12 @@ export default {
<gl-icon data-testid="ellipsis-chevron" name="chevron-right" class="gl-mx-2 gl-min-w-0" />
</template>
- <gl-link data-testid="leaf-link" class="gl-text-gray-500 gl-min-w-0" :href="`/${path}`">
+ <gl-link
+ data-testid="leaf-link"
+ class="gl-text-gray-500 gl-min-w-0"
+ :href="`/${path}`"
+ :disabled="disabled"
+ >
{{ leaf }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index f7de31c2c86..b3df542e0ae 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -26,3 +26,8 @@ export const TrackingCategories = {
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.');
+
+export const PACKAGE_ERROR_STATUS = 'error';
+export const PACKAGE_DEFAULT_STATUS = 'default';
+export const PACKAGE_HIDDEN_STATUS = 'hidden';
+export const PACKAGE_PROCESSING_STATUS = 'processing';
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
index 88ee8a4200e..7e6e98f4fb5 100644
--- 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
@@ -9,7 +9,7 @@ Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-list');
const store = createStore();
- store.dispatch('setInitialState', el.dataset);
+ store.dispatch('setInitialState', { ...el.dataset, forceTerraform: true });
return new Vue({
el,
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
new file mode 100644
index 00000000000..d66a30e7e81
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
@@ -0,0 +1,118 @@
+<script>
+import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { isEqual } from 'lodash';
+
+import {
+ DUPLICATES_TOGGLE_LABEL,
+ DUPLICATES_ALLOWED_DISABLED,
+ DUPLICATES_ALLOWED_ENABLED,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'DuplicatesSettings',
+ i18n: {
+ DUPLICATES_TOGGLE_LABEL,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+ },
+ components: {
+ GlSprintf,
+ GlToggle,
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ duplicatesAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ duplicateExceptionRegex: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ duplicateExceptionRegexError: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ modelNames: {
+ type: Object,
+ required: true,
+ validator(value) {
+ return isEqual(Object.keys(value), ['allowed', 'exception']);
+ },
+ },
+ toggleQaSelector: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ labelQaSelector: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ enabledButtonLabel() {
+ return this.duplicatesAllowed ? DUPLICATES_ALLOWED_ENABLED : DUPLICATES_ALLOWED_DISABLED;
+ },
+ isExceptionRegexValid() {
+ return !this.duplicateExceptionRegexError;
+ },
+ },
+ methods: {
+ update(type, value) {
+ this.$emit('update', { [type]: value });
+ },
+ },
+};
+</script>
+
+<template>
+ <form>
+ <div class="gl-display-flex">
+ <gl-toggle
+ :data-qa-selector="toggleQaSelector"
+ :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
+ label-position="hidden"
+ :value="duplicatesAllowed"
+ @change="update(modelNames.allowed, $event)"
+ />
+ <div class="gl-ml-5">
+ <div data-testid="toggle-label" :data-qa-selector="labelQaSelector">
+ <gl-sprintf :message="enabledButtonLabel">
+ <template #bold="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <gl-form-group
+ v-if="!duplicatesAllowed"
+ class="gl-mt-4"
+ :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
+ label-size="sm"
+ :state="isExceptionRegexValid"
+ :invalid-feedback="duplicateExceptionRegexError"
+ :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
+ label-for="maven-duplicated-settings-regex-input"
+ >
+ <gl-form-input
+ id="maven-duplicated-settings-regex-input"
+ :value="duplicateExceptionRegex"
+ @change="update(modelNames.exception, $event)"
+ />
+ </gl-form-group>
+ </div>
+ </div>
+ </form>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
new file mode 100644
index 00000000000..e5f63fe8d0d
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
@@ -0,0 +1,26 @@
+<script>
+import { s__ } from '~/locale';
+import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
+
+export default {
+ name: 'GenericSettings',
+ components: {
+ SettingsTitles,
+ },
+ i18n: {
+ title: s__('PackageRegistry|Generic'),
+ subTitle: s__('PackageRegistry|Settings for Generic packages'),
+ },
+ modelNames: {
+ allowed: 'genericDuplicatesAllowed',
+ exception: 'genericDuplicateExceptionRegex',
+ },
+};
+</script>
+
+<template>
+ <div>
+ <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
+ <slot :model-names="$options.modelNames"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
index 4f5c53ed4a3..01d4861f5c2 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue
@@ -1,7 +1,8 @@
<script>
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
+import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
+import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
-
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
@@ -30,6 +31,8 @@ export default {
GlLink,
SettingsBlock,
MavenSettings,
+ GenericSettings,
+ DuplicatesSettings,
},
inject: ['defaultExpanded', 'groupPath'],
apollo: {
@@ -128,13 +131,32 @@ export default {
</span>
</template>
<template #default>
- <maven-settings
- :maven-duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
- :maven-duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
- :maven-duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
- :loading="isLoading"
- @update="updateSettings"
- />
+ <maven-settings data-testid="maven-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ toggle-qa-selector="allow_duplicates_toggle"
+ label-qa-selector="allow_duplicates_label"
+ @update="updateSettings"
+ />
+ </template>
+ </maven-settings>
+ <generic-settings class="gl-mt-6" data-testid="generic-settings">
+ <template #default="{ modelNames }">
+ <duplicates-settings
+ :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
+ :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
+ :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
+ :model-names="modelNames"
+ :loading="isLoading"
+ @update="updateSettings"
+ />
+ </template>
+ </generic-settings>
</template>
</settings-block>
</div>
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 faacabb44ce..a1cbd695f34 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
@@ -1,118 +1,26 @@
<script>
-import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
-
-import {
- MAVEN_TOGGLE_LABEL,
- MAVEN_TITLE,
- MAVEN_SETTINGS_SUBTITLE,
- MAVEN_DUPLICATES_ALLOWED_DISABLED,
- MAVEN_DUPLICATES_ALLOWED_ENABLED,
- MAVEN_SETTING_EXCEPTION_TITLE,
- MAVEN_SETTINGS_EXCEPTION_LEGEND,
- MAVEN_DUPLICATES_ALLOWED,
- MAVEN_DUPLICATE_EXCEPTION_REGEX,
-} from '~/packages_and_registries/settings/group/constants';
+import { s__ } from '~/locale';
+import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
export default {
name: 'MavenSettings',
- i18n: {
- MAVEN_TOGGLE_LABEL,
- MAVEN_TITLE,
- MAVEN_SETTINGS_SUBTITLE,
- MAVEN_SETTING_EXCEPTION_TITLE,
- MAVEN_SETTINGS_EXCEPTION_LEGEND,
- },
- modelNames: {
- MAVEN_DUPLICATES_ALLOWED,
- MAVEN_DUPLICATE_EXCEPTION_REGEX,
- },
components: {
- GlSprintf,
- GlToggle,
- GlFormGroup,
- GlFormInput,
- },
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- mavenDuplicatesAllowed: {
- type: Boolean,
- default: false,
- required: true,
- },
- mavenDuplicateExceptionRegex: {
- type: String,
- default: '',
- required: true,
- },
- mavenDuplicateExceptionRegexError: {
- type: String,
- default: '',
- required: false,
- },
+ SettingsTitles,
},
- computed: {
- enabledButtonLabel() {
- return this.mavenDuplicatesAllowed
- ? MAVEN_DUPLICATES_ALLOWED_ENABLED
- : MAVEN_DUPLICATES_ALLOWED_DISABLED;
- },
- isMavenDuplicateExceptionRegexValid() {
- return !this.mavenDuplicateExceptionRegexError;
- },
+ i18n: {
+ title: s__('PackageRegistry|Maven'),
+ subTitle: s__('PackageRegistry|Settings for Maven packages'),
},
- methods: {
- update(type, value) {
- this.$emit('update', { [type]: value });
- },
+ modelNames: {
+ allowed: 'mavenDuplicatesAllowed',
+ exception: 'mavenDuplicateExceptionRegex',
},
};
</script>
<template>
<div>
- <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200">
- {{ $options.i18n.MAVEN_TITLE }}
- </h5>
- <p>{{ $options.i18n.MAVEN_SETTINGS_SUBTITLE }}</p>
- <form>
- <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)"
- />
- <div class="gl-ml-5">
- <div data-testid="toggle-label" data-qa-selector="allow_duplicates_label">
- <gl-sprintf :message="enabledButtonLabel">
- <template #bold="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </div>
- <gl-form-group
- v-if="!mavenDuplicatesAllowed"
- class="gl-mt-4"
- :label="$options.i18n.MAVEN_SETTING_EXCEPTION_TITLE"
- label-size="sm"
- :state="isMavenDuplicateExceptionRegexValid"
- :invalid-feedback="mavenDuplicateExceptionRegexError"
- :description="$options.i18n.MAVEN_SETTINGS_EXCEPTION_LEGEND"
- label-for="maven-duplicated-settings-regex-input"
- >
- <gl-form-input
- id="maven-duplicated-settings-regex-input"
- :value="mavenDuplicateExceptionRegex"
- @change="update($options.modelNames.MAVEN_DUPLICATE_EXCEPTION_REGEX, $event)"
- />
- </gl-form-group>
- </div>
- </div>
- </form>
+ <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
+ <slot :model-names="$options.modelNames"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
new file mode 100644
index 00000000000..3f0ab7686e5
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ name: 'SettingsTitle',
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ subTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200">
+ {{ title }}
+ </h5>
+ <p>{{ subTitle }}</p>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index d52a6a626f9..a2256c5c371 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -6,17 +6,15 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|GitLab Packages allows organizations to utilize GitLab as a private repository for a variety of common package formats. %{linkStart}More Information%{linkEnd}',
);
-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__(
+export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
+export const DUPLICATES_ALLOWED_DISABLED = s__(
'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.',
);
-export const MAVEN_DUPLICATES_ALLOWED_ENABLED = s__(
+export const DUPLICATES_ALLOWED_ENABLED = s__(
'PackageRegistry|%{boldStart}Allow duplicates%{boldEnd} - Packages with the same name and version are accepted.',
);
-export const MAVEN_SETTING_EXCEPTION_TITLE = __('Exceptions');
-export const MAVEN_SETTINGS_EXCEPTION_LEGEND = s__(
+export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
+export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Packages can be published if their name or version matches this regex',
);
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
index 1fc59bd3496..5c245ff9453 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql
@@ -3,6 +3,8 @@ mutation updateNamespacePackageSettings($input: UpdateNamespacePackageSettingsIn
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
+ genericDuplicatesAllowed
+ genericDuplicateExceptionRegex
}
errors
}
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
index 2011659887d..a1c01300893 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql
@@ -3,6 +3,8 @@ query getGroupPackagesSettings($fullPath: ID!) {
packageSettings {
mavenDuplicatesAllowed
mavenDuplicateExceptionRegex
+ genericDuplicatesAllowed
+ genericDuplicateExceptionRegex
}
}
}
diff --git a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index d75fb31fd98..d75fb31fd98 100644
--- a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
diff --git a/app/assets/javascripts/registry/settings/components/expiration_input.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
index 42b7c7918a5..d6d85189792 100644
--- a/app/assets/javascripts/registry/settings/components/expiration_input.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
@@ -1,6 +1,9 @@
<script>
import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
-import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants';
+import {
+ NAME_REGEX_LENGTH,
+ TEXT_AREA_INVALID_FEEDBACK,
+} from '~/packages_and_registries/settings/project/constants';
export default {
components: {
diff --git a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue
index fd9ca6a54c5..0c595fa79b4 100644
--- a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_run_text.vue
@@ -1,6 +1,9 @@
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants';
+import {
+ NEXT_CLEANUP_LABEL,
+ NOT_SCHEDULED_POLICY_TEXT,
+} from '~/packages_and_registries/settings/project/constants';
export default {
components: {
diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
index 6aa78c69ba9..7a9ea7c0bf7 100644
--- a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue
@@ -1,7 +1,10 @@
<script>
import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants';
+import {
+ ENABLED_TOGGLE_DESCRIPTION,
+ DISABLED_TOGGLE_DESCRIPTION,
+} from '~/packages_and_registries/settings/project/constants';
export default {
i18n: {
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 480590ec71e..edbe9441e57 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -7,8 +7,8 @@ import {
UNAVAILABLE_FEATURE_INTRO_TEXT,
UNAVAILABLE_USER_FEATURE_TEXT,
UNAVAILABLE_ADMIN_FEATURE_TEXT,
-} from '../constants';
-import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.query.graphql';
+} from '~/packages_and_registries/settings/project/constants';
+import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
import SettingsForm from './settings_form.vue';
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
index 1360e09a75d..41be70a3ad5 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
@@ -17,10 +17,10 @@ import {
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
-} from '~/registry/settings/constants';
-import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql';
-import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update';
-import { formOptionsGenerator } from '~/registry/settings/utils';
+} from '~/packages_and_registries/settings/project/constants';
+import updateContainerExpirationPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql';
+import { updateContainerExpirationPolicy } from '~/packages_and_registries/settings/project/graphql/utils/cache_update';
+import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
import Tracking from '~/tracking';
import ExpirationDropdown from './expiration_dropdown.vue';
import ExpirationInput from './expiration_input.vue';
diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 165c4aae3cb..165c4aae3cb 100644
--- a/app/assets/javascripts/registry/settings/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql
index 1d6c89133af..1d6c89133af 100644
--- a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/fragments/container_expiration_policy.fragment.graphql
diff --git a/app/assets/javascripts/registry/settings/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
index 16152eb81f6..16152eb81f6 100644
--- a/app/assets/javascripts/registry/settings/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql
index c40cd115ab0..c40cd115ab0 100644
--- a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/mutations/update_container_expiration_policy.mutation.graphql
diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
index c171be0ad07..c171be0ad07 100644
--- a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
index c4b2af13862..c4b2af13862 100644
--- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/utils/cache_update.js
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
index 65af6f846aa..65af6f846aa 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
index 4a2d7c7d466..4a2d7c7d466 100644
--- a/app/assets/javascripts/registry/settings/utils.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/utils.js
diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
index cf06ee2c22a..d6fa1be29b0 100644
--- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js
+++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js
@@ -1,3 +1,5 @@
-import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state';
+import initDevOpsScore from '~/analytics/devops_report/devops_score';
+import initDevOpsScoreDisabledUsagePing from '~/analytics/devops_report/devops_score_disabled_usage_ping';
-initDevOpsScoreEmptyState();
+initDevOpsScoreDisabledUsagePing();
+initDevOpsScore();
diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js
index e5ab5d43bbf..17ee7c03ed6 100644
--- a/app/assets/javascripts/pages/admin/labels/index/index.js
+++ b/app/assets/javascripts/pages/admin/labels/index/index.js
@@ -1,3 +1,21 @@
-import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
+document.addEventListener('DOMContentLoaded', () => {
+ const pagination = document.querySelector('.labels .gl-pagination');
+ const emptyState = document.querySelector('.labels .nothing-here-block.hidden');
-document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior);
+ function removeLabelSuccessCallback() {
+ this.closest('li').classList.add('gl-display-none!');
+
+ const labelsCount = document.querySelectorAll(
+ 'ul.manage-labels-list li:not(.gl-display-none\\!)',
+ ).length;
+
+ // display the empty state if there are no more labels
+ if (labelsCount < 1 && !pagination && emptyState) {
+ emptyState.classList.remove('hidden');
+ }
+ }
+
+ document.querySelectorAll('.js-remove-label').forEach((row) => {
+ row.addEventListener('ajax:success', removeLabelSuccessCallback);
+ });
+});
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 20407334b3f..a3b78da6ef5 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
@@ -1,6 +1,8 @@
<script>
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { s__, sprintf } from '~/locale';
+import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue';
export default {
components: {
@@ -8,6 +10,7 @@ export default {
GlButton,
GlFormInput,
GlSprintf,
+ OncallSchedulesList,
},
props: {
title: {
@@ -42,6 +45,11 @@ export default {
type: String,
required: true,
},
+ oncallSchedules: {
+ type: String,
+ required: false,
+ default: '[]',
+ },
},
data() {
return {
@@ -58,6 +66,14 @@ export default {
canSubmit() {
return this.enteredUsername === this.username;
},
+ schedules() {
+ try {
+ return JSON.parse(this.oncallSchedules);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+ return [];
+ },
},
methods: {
show() {
@@ -96,6 +112,8 @@ export default {
</gl-sprintf>
</p>
+ <oncall-schedules-list v-if="schedules.length" :schedules="schedules" />
+
<p>
<gl-sprintf :message="s__('AdminUsers|To confirm, type %{username}')">
<template #username>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index b1079c3b068..9a8b0c9990f 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
-import { initAdminUsersApp, initCohortsEmptyState } from '~/admin/users';
-import initTabs from '~/admin/users/tabs';
+import { initAdminUsersApp } from '~/admin/users';
import initConfirmModal from '~/confirm_modal';
import csrf from '~/lib/utils/csrf';
import Translate from '~/vue_shared/translate';
@@ -62,6 +61,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
initConfirmModal();
- initCohortsEmptyState();
- initTabs();
});
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index b60607e8857..76db578f6f9 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,6 +1,6 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
-import initIssuablesList from '~/issues_list';
+import { mountIssuablesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
@@ -12,8 +12,6 @@ IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
-initIssuablesList();
-
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
@@ -22,3 +20,7 @@ initFilteredSearch({
});
projectSelect();
initManualOrdering();
+
+if (gon.features?.vueIssuablesList) {
+ mountIssuablesListApp();
+}
diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js
index af0264c7992..4f8514a9a1d 100644
--- a/app/assets/javascripts/pages/groups/milestones/edit/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '../../../../shared/milestones/form';
+import initForm from '~/shared/milestones/form';
-initForm(false);
+initForm();
diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js
index af0264c7992..4f8514a9a1d 100644
--- a/app/assets/javascripts/pages/groups/milestones/new/index.js
+++ b/app/assets/javascripts/pages/groups/milestones/new/index.js
@@ -1,3 +1,3 @@
-import initForm from '../../../../shared/milestones/form';
+import initForm from '~/shared/milestones/form';
-initForm(false);
+initForm();
diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/show/index.js
index 3b922622d2c..3b922622d2c 100644
--- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js
+++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/show/index.js
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 92405f205cb..f048955dadf 100644
--- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js
@@ -1,7 +1,8 @@
-import DueDateSelectors from '~/due_date_select';
+import initDatePicker from '~/behaviors/date_picker';
import initSettingsPanels from '~/settings_panels';
// Initialize expandable settings panels
initSettingsPanels();
-new DueDateSelectors(); // eslint-disable-line no-new
+// Used for deploy tokens "expires at" field
+initDatePicker();
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
index 9e75985c130..2aec0617b5a 100644
--- a/app/assets/javascripts/pages/groups/shared/group_details.js
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -3,6 +3,7 @@
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
import initInviteMembersBanner from '~/groups/init_invite_members_banner';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import { getPagePath, getDashPath } from '~/lib/utils/common_utils';
import initNotificationsDropdown from '~/notifications';
import ProjectsList from '~/projects_list';
@@ -24,4 +25,5 @@ export default function initGroupDetails(actionName = 'show') {
new ProjectsList();
initInviteMembersBanner();
+ initInviteMembersModal();
}
diff --git a/app/assets/javascripts/pages/help/show/index.js b/app/assets/javascripts/pages/help/show/index.js
deleted file mode 100644
index ec426a850b6..00000000000
--- a/app/assets/javascripts/pages/help/show/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initHelp from '~/help/help';
-
-document.addEventListener('DOMContentLoaded', initHelp);
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index fc2702b8c37..8a8ce70e998 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -1,25 +1,35 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
import BlobViewer from '~/blob/viewer/index';
import GpgBadges from '~/gpg_badges';
+import createDefaultClient from '~/lib/graphql';
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';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
const viewBlobEl = document.querySelector('#js-view-blob-app');
if (viewBlobEl) {
- const { blobPath } = viewBlobEl.dataset;
+ const { blobPath, projectPath } = viewBlobEl.dataset;
// eslint-disable-next-line no-new
new Vue({
el: viewBlobEl,
+ apolloProvider,
render(createElement) {
return createElement(BlobContentViewer, {
props: {
path: blobPath,
+ projectPath,
},
});
},
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 90a663802d2..d75c3cc6b8b 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -33,7 +33,7 @@ if (filesContainer.length) {
axios
.get(batchPath)
.then(({ data }) => {
- filesContainer.html($(data.html));
+ filesContainer.html($(data));
syntaxHighlight(filesContainer);
handleLocationHash();
new Diff();
diff --git a/app/assets/javascripts/pages/projects/compare/index.js b/app/assets/javascripts/pages/projects/compare/index.js
deleted file mode 100644
index 768da8fb236..00000000000
--- a/app/assets/javascripts/pages/projects/compare/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initCompareAutocomplete from '~/compare_autocomplete';
-
-document.addEventListener('DOMContentLoaded', () => initCompareAutocomplete());
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 288d6711682..07cc0ce46bc 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
@@ -20,6 +20,7 @@ import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
+import validation from '~/vue_shared/directives/validation';
const PRIVATE_VISIBILITY = 'private';
const INTERNAL_VISIBILITY = 'internal';
@@ -31,6 +32,13 @@ const ALLOWED_VISIBILITY = {
public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY],
};
+const initFormField = ({ value, required = true, skipValidation = false }) => ({
+ value,
+ required,
+ state: skipValidation ? true : null,
+ feedback: null,
+});
+
export default {
components: {
GlForm,
@@ -46,6 +54,9 @@ export default {
GlFormRadioGroup,
GlFormSelect,
},
+ directives: {
+ validation: validation(),
+ },
inject: {
newGroupPath: {
default: '',
@@ -77,7 +88,8 @@ export default {
},
projectDescription: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
projectVisibility: {
type: String,
@@ -85,16 +97,30 @@ export default {
},
},
data() {
+ const form = {
+ state: false,
+ showValidation: false,
+ fields: {
+ namespace: initFormField({
+ value: null,
+ }),
+ name: initFormField({ value: this.projectName }),
+ slug: initFormField({ value: this.projectPath }),
+ description: initFormField({
+ value: this.projectDescription,
+ required: false,
+ skipValidation: true,
+ }),
+ visibility: initFormField({
+ value: this.projectVisibility,
+ skipValidation: true,
+ }),
+ },
+ };
return {
isSaving: false,
namespaces: [],
- selectedNamespace: {},
- fork: {
- name: this.projectName,
- slug: this.projectPath,
- description: this.projectDescription,
- visibility: this.projectVisibility,
- },
+ form,
};
},
computed: {
@@ -106,7 +132,7 @@ export default {
},
namespaceAllowedVisibility() {
return (
- ALLOWED_VISIBILITY[this.selectedNamespace.visibility] ||
+ ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] ||
ALLOWED_VISIBILITY[PUBLIC_VISIBILITY]
);
},
@@ -139,16 +165,17 @@ export default {
},
},
watch: {
- selectedNamespace(newVal) {
+ // eslint-disable-next-line func-names
+ 'form.fields.namespace.value': function (newVal) {
const { visibility } = newVal;
if (this.projectAllowedVisibility.includes(visibility)) {
- this.fork.visibility = visibility;
+ this.form.fields.visibility.value = visibility;
}
},
// eslint-disable-next-line func-names
- 'fork.name': function (newVal) {
- this.fork.slug = kebabCase(newVal);
+ 'form.fields.name.value': function (newVal) {
+ this.form.fields.slug.value = kebabCase(newVal);
},
},
mounted() {
@@ -166,19 +193,25 @@ export default {
);
},
async onSubmit() {
+ this.form.showValidation = true;
+
+ if (!this.form.state) {
+ return;
+ }
+
this.isSaving = true;
+ this.form.showValidation = false;
const { projectId } = this;
- const { name, slug, description, visibility } = this.fork;
- const { id: namespaceId } = this.selectedNamespace;
+ const { name, slug, description, visibility, namespace } = this.form.fields;
const postParams = {
id: projectId,
- name,
- namespace_id: namespaceId,
- path: slug,
- description,
- visibility,
+ name: name.value,
+ namespace_id: namespace.value.id,
+ path: slug.value,
+ description: description.value,
+ visibility: visibility.value,
};
const forkProjectPath = `/api/:version/projects/:id/fork`;
@@ -198,16 +231,34 @@ export default {
</script>
<template>
- <gl-form method="POST" @submit.prevent="onSubmit">
+ <gl-form novalidate method="POST" @submit.prevent="onSubmit">
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
- <gl-form-group label="Project name" label-for="fork-name">
- <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required />
+ <gl-form-group
+ :label="__('Project name')"
+ label-for="fork-name"
+ :invalid-feedback="form.fields.name.feedback"
+ >
+ <gl-form-input
+ id="fork-name"
+ v-model="form.fields.name.value"
+ v-validation:[form.showValidation]
+ name="name"
+ data-testid="fork-name-input"
+ :state="form.fields.name.state"
+ required
+ />
</gl-form-group>
<div class="gl-md-display-flex">
<div class="gl-flex-basis-half">
- <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3">
+ <gl-form-group
+ :label="__('Project URL')"
+ label-for="fork-url"
+ class="gl-md-mr-3"
+ :state="form.fields.namespace.state"
+ :invalid-feedback="s__('ForkProject|Please select a namespace')"
+ >
<gl-form-input-group>
<template #prepend>
<gl-input-group-text>
@@ -216,9 +267,12 @@ export default {
</template>
<gl-form-select
id="fork-url"
- v-model="selectedNamespace"
+ v-model="form.fields.namespace.value"
+ v-validation:[form.showValidation]
+ name="namespace"
data-testid="fork-url-input"
data-qa-selector="fork_namespace_dropdown"
+ :state="form.fields.namespace.state"
required
>
<template slot="first">
@@ -232,11 +286,19 @@ export default {
</gl-form-group>
</div>
<div class="gl-flex-basis-half">
- <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3">
+ <gl-form-group
+ :label="__('Project slug')"
+ label-for="fork-slug"
+ class="gl-md-ml-3"
+ :invalid-feedback="form.fields.slug.feedback"
+ >
<gl-form-input
id="fork-slug"
- v-model="fork.slug"
+ v-model="form.fields.slug.value"
+ v-validation:[form.showValidation]
data-testid="fork-slug-input"
+ name="slug"
+ :state="form.fields.slug.state"
required
/>
</gl-form-group>
@@ -250,11 +312,13 @@ export default {
</gl-link>
</p>
- <gl-form-group label="Project description (optional)" label-for="fork-description">
+ <gl-form-group :label="__('Project description (optional)')" label-for="fork-description">
<gl-form-textarea
id="fork-description"
- v-model="fork.description"
+ v-model="form.fields.description.value"
data-testid="fork-description-textarea"
+ name="description"
+ :state="form.fields.description.state"
/>
</gl-form-group>
@@ -266,8 +330,9 @@ export default {
</gl-link>
</label>
<gl-form-radio-group
- v-model="fork.visibility"
+ v-model="form.fields.visibility.value"
data-testid="fork-visibility-radio-group"
+ name="visibility"
required
>
<gl-form-radio
@@ -291,6 +356,7 @@ export default {
type="submit"
category="primary"
variant="confirm"
+ class="js-no-auto-disable"
data-testid="submit-button"
data-qa-selector="fork_project_button"
:loading="isSaving"
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 85489ae8687..8cd703133f5 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -1,36 +1,38 @@
-/* eslint-disable no-new */
-
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons';
import initIssuableByEmail from '~/issuable/init_issuable_by_email';
import IssuableIndex from '~/issuable_index';
-import initIssuablesList, { initIssuesListApp } from '~/issues_list';
+import { mountIssuablesListApp, mountIssuesListApp, mountJiraIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import UsersSelect from '~/users_select';
-IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
-
-initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- useDefaultState: true,
-});
-
if (gon.features?.vueIssuesList) {
- new IssuableIndex();
+ mountIssuesListApp();
} else {
- new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+ IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ useDefaultState: true,
+ });
+
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE); // eslint-disable-line no-new
+ new UsersSelect(); // eslint-disable-line no-new
+
+ initCsvImportExportButtons();
+ initIssuableByEmail();
+ initManualOrdering();
+
+ if (gon.features?.vueIssuablesList) {
+ mountIssuablesListApp();
+ }
}
-new ShortcutsNavigation();
-new UsersSelect();
+new ShortcutsNavigation(); // eslint-disable-line no-new
-initManualOrdering();
-initIssuablesList();
-initIssuableByEmail();
-initCsvImportExportButtons();
-initIssuesListApp();
+mountJiraIssuesListApp();
diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
index 5be9f6117dc..d906c579697 100644
--- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js
+++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js
@@ -1,4 +1,4 @@
-import initIssuablesList from '~/issues_list';
+import { mountIssuablesListApp } from '~/issues_list';
import FilteredSearchServiceDesk from './filtered_search';
const supportBotData = JSON.parse(
@@ -11,5 +11,5 @@ if (document.querySelector('.filtered-search')) {
}
if (gon.features?.vueIssuablesList) {
- initIssuablesList();
+ mountIssuablesListApp();
}
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 2b679a83eac..3143ff5adac 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -1,8 +1,6 @@
import loadAwardsHandler from '~/awards_handler';
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';
@@ -58,7 +56,5 @@ export default function initShowIssue() {
} else {
loadAwardsHandler();
}
- initInviteMemberModal();
- initInviteMemberTrigger();
}
}
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 ef9e13f7ccf..51980b2d971 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
@@ -18,9 +18,13 @@ export default {
required: true,
type: Object,
},
+ sections: {
+ required: true,
+ type: Object,
+ },
},
maxValue: Object.keys(ACTION_LABELS).length,
- sections: Object.keys(ACTION_SECTIONS),
+ actionSections: Object.keys(ACTION_SECTIONS),
computed: {
progressValue() {
return Object.values(this.actions).filter((a) => a.completed).length;
@@ -38,6 +42,9 @@ export default {
);
return actions;
},
+ svgFor(section) {
+ return this.sections[section].svg;
+ },
},
};
</script>
@@ -59,8 +66,12 @@ export default {
<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 v-for="section in $options.actionSections" :key="section" class="col gl-mb-6">
+ <learn-gitlab-section-card
+ :section="section"
+ :svg="svgFor(section)"
+ :actions="actionsFor(section)"
+ />
</div>
</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 6cd3bbc359b..ad6dfbf41ca 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
@@ -64,7 +64,15 @@ export default {
<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>
+ <gl-link
+ :href="url"
+ target="_blank"
+ rel="noopener noreferrer"
+ data-track-action="click_link"
+ :data-track-label="actionLabel"
+ data-track-property="Growth::Activation::Experiment::LearnGitLabB"
+ >{{ actionLabel }}</gl-link
+ >
</div>
</gl-card>
</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
index db694a66afd..6a196687a76 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue
@@ -1,6 +1,5 @@
<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';
@@ -16,6 +15,10 @@ export default {
required: true,
type: String,
},
+ svg: {
+ required: true,
+ type: String,
+ },
actions: {
required: true,
type: Object,
@@ -28,17 +31,12 @@ export default {
);
},
},
- 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)" />
+ <img :src="svg" />
<h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2>
<p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p>
</div>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 6f51c7372fd..3d31ac6c267 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -34,7 +34,15 @@ export default {
{{ $options.i18n.ACTION_LABELS[action].title }}
</span>
<span v-else>
- <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link>
+ <gl-link
+ target="_blank"
+ :href="value.url"
+ data-track-action="click_link"
+ :data-track-label="$options.i18n.ACTION_LABELS[action].title"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ >
+ {{ $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 }}
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
index c4dec89b984..ac7c94bdd9e 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import LearnGitlabA from '../components/learn_gitlab_a.vue';
import LearnGitlabB from '../components/learn_gitlab_b.vue';
@@ -11,13 +12,18 @@ function initLearnGitlab() {
}
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
+ const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
const { learnGitlabA } = gon.experiments;
+ trackLearnGitlab(learnGitlabA);
+
return new Vue({
el,
render(createElement) {
- return createElement(learnGitlabA ? LearnGitlabA : LearnGitlabB, { props: { actions } });
+ return createElement(learnGitlabA ? LearnGitlabA : LearnGitlabB, {
+ props: { actions, sections },
+ });
},
});
}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
index 1a0fa6e544e..8d152ec4ba6 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
-import initCompareAutocomplete from '~/compare_autocomplete';
import axios from '~/lib/utils/axios_utils';
import { localTimeAgo } from '~/lib/utils/datetime_utility';
+import initCompareAutocomplete from './compare_autocomplete';
import initTargetProjectDropdown from './target_project_dropdown';
const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
index 314e4e911ee..68ab7021cf3 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
@@ -2,11 +2,11 @@
import $ from 'jquery';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { __ } from '~/locale';
import { fixTitle } from '~/tooltips';
-import { deprecatedCreateFlash as flash } from './flash';
-import axios from './lib/utils/axios_utils';
-import { capitalizeFirstCharacter } from './lib/utils/text_utility';
-import { __ } from './locale';
export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) {
$('.js-compare-dropdown').each(function () {
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 a5118e3529a..6cd3202815b 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -1,16 +1,17 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
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 StatusBox from '~/issuable/components/status_box.vue';
+import createDefaultClient from '~/lib/graphql';
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';
+import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
@@ -28,15 +29,20 @@ export default function initMergeRequestShow() {
} else {
loadAwardsHandler();
}
- initInviteMemberModal();
- initInviteMemberTrigger();
initInviteMembersModal();
initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box');
+ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient() });
// eslint-disable-next-line no-new
new Vue({
el,
+ apolloProvider,
+ provide: {
+ query: getStateQuery,
+ projectPath: el.dataset.projectPath,
+ iid: el.dataset.iid,
+ },
render(h) {
return h(StatusBox, {
props: {
diff --git a/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql
new file mode 100644
index 00000000000..b5a82b9428e
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/queries/get_state.query.graphql
@@ -0,0 +1,7 @@
+query getMergeRequestState($projectPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $projectPath) {
+ issuable: mergeRequest(iid: $iid) {
+ state
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js
index 364b0d95d9c..4f8514a9a1d 100644
--- a/app/assets/javascripts/pages/projects/milestones/new/index.js
+++ b/app/assets/javascripts/pages/projects/milestones/new/index.js
@@ -1,3 +1,3 @@
-import initForm from '../../../../shared/milestones/form';
+import initForm from '~/shared/milestones/form';
initForm();
diff --git a/app/assets/javascripts/pages/projects/new/components/app.vue b/app/assets/javascripts/pages/projects/new/components/app.vue
new file mode 100644
index 00000000000..60a4fbc3e6b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/new/components/app.vue
@@ -0,0 +1,148 @@
+<script>
+import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/project-create-from-template-sm.svg';
+import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg';
+import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg';
+import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg';
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { experiment } from '~/experimentation/utils';
+import { s__ } from '~/locale';
+import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
+import NewProjectPushTipPopover from './new_project_push_tip_popover.vue';
+
+const NEW_REPO_EXPERIMENT = 'new_repo';
+const CI_CD_PANEL = 'cicd_for_external_repo';
+const PANELS = [
+ {
+ key: 'blank',
+ name: 'blank_project',
+ selector: '#blank-project-pane',
+ title: s__('ProjectsNew|Create blank project'),
+ description: s__(
+ 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.',
+ ),
+ illustration: blankProjectIllustration,
+ },
+ {
+ key: 'template',
+ name: 'create_from_template',
+ selector: '#create-from-template-pane',
+ title: s__('ProjectsNew|Create from template'),
+ description: s__(
+ 'ProjectsNew|Create a project pre-populated with the necessary files to get you started quickly.',
+ ),
+ illustration: createFromTemplateIllustration,
+ },
+ {
+ key: 'import',
+ name: 'import_project',
+ selector: '#import-project-pane',
+ title: s__('ProjectsNew|Import project'),
+ description: s__(
+ 'ProjectsNew|Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.',
+ ),
+ illustration: importProjectIllustration,
+ },
+ {
+ key: 'ci',
+ name: CI_CD_PANEL,
+ selector: '#ci-cd-project-pane',
+ title: s__('ProjectsNew|Run CI/CD for external repository'),
+ description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'),
+ illustration: ciCdProjectIllustration,
+ },
+];
+
+export default {
+ components: {
+ NewNamespacePage,
+ NewProjectPushTipPopover,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ hasErrors: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isCiCdAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ newProjectGuidelines: {
+ type: String,
+ required: false,
+ 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] ?? title,
+ }));
+ },
+
+ availablePanels() {
+ return this.isCiCdAvailable
+ ? this.decoratedPanels
+ : this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL);
+ },
+ },
+
+ methods: {
+ resetProjectErrors() {
+ const errorsContainer = document.querySelector('.project-edit-errors');
+ if (errorsContainer) {
+ errorsContainer.innerHTML = '';
+ }
+ },
+ },
+ EXPERIMENT: NEW_REPO_EXPERIMENT,
+};
+</script>
+
+<template>
+ <new-namespace-page
+ :initial-breadcrumb="s__('New project')"
+ :panels="availablePanels"
+ :jump-to-last-persisted-panel="hasErrors"
+ :title="s__('ProjectsNew|Create new project')"
+ :experiment="$options.EXPERIMENT"
+ persistence-key="new_project_last_active_tab"
+ @panel-change="resetProjectErrors"
+ >
+ <template #extra-description>
+ <div
+ v-if="newProjectGuidelines"
+ id="new-project-guideline"
+ v-safe-html="newProjectGuidelines"
+ ></div>
+ </template>
+ <template #welcome-footer>
+ <div class="gl-pt-5 gl-text-center">
+ <p>
+ {{ __('You can also create a project from the command line.') }}
+ <a ref="clipTip" href="#" @click.prevent>
+ {{ __('Show command') }}
+ </a>
+ <new-project-push-tip-popover :target="() => $refs.clipTip" />
+ </p>
+ </div>
+ </template>
+ </new-namespace-page>
+</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue b/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue
index e42d9154866..e42d9154866 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue
+++ b/app/assets/javascripts/pages/projects/new/components/new_project_push_tip_popover.vue
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index e10e2872dce..f469c56e808 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,28 +1,44 @@
-import { deprecatedCreateFlash as createFlash } from '~/flash';
-import { __ } from '~/locale';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
+import NewProjectCreationApp from './components/app.vue';
initProjectVisibilitySelector();
initProjectNew.bindEvents();
-import(
- /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
-)
- .then((m) => {
- const el = document.querySelector('.js-experiment-new-project-creation');
+function initNewProjectCreation(el) {
+ const {
+ pushToCreateProjectCommand,
+ workingWithProjectsHelpPath,
+ newProjectGuidelines,
+ hasErrors,
+ isCiCdAvailable,
+ } = el.dataset;
- if (!el) {
- return;
- }
+ const props = {
+ hasErrors: parseBoolean(hasErrors),
+ isCiCdAvailable: parseBoolean(isCiCdAvailable),
+ newProjectGuidelines,
+ };
- const config = {
- hasErrors: 'hasErrors' in el.dataset,
- isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
- newProjectGuidelines: el.dataset.newProjectGuidelines,
- };
- m.default(el, config);
- })
- .catch(() => {
- createFlash(__('An error occurred while loading project creation UI'));
+ const provide = {
+ workingWithProjectsHelpPath,
+ pushToCreateProjectCommand,
+ };
+
+ return new Vue({
+ el,
+ components: {
+ NewProjectCreationApp,
+ },
+ provide,
+ render(h) {
+ return h(NewProjectCreationApp, { props });
+ },
});
+}
+
+const el = document.querySelector('.js-new-project-creation');
+
+initNewProjectCreation(el);
diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js
index 32299287a9c..e1f71965853 100644
--- a/app/assets/javascripts/pages/projects/pipelines/new/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js
@@ -1,17 +1,3 @@
-import $ from 'jquery';
-import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
-import NewBranchForm from '~/new_branch_form';
-import initNewPipeline from '~/pipeline_new/index';
+import initNewPipelineForm from '~/pipeline_new/index';
-const el = document.getElementById('js-new-pipeline');
-
-if (el) {
- initNewPipeline();
-} else {
- new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new
-
- setupNativeFormVariableList({
- container: $('.js-ci-variable-list-section'),
- formField: 'variables_attributes',
- });
-}
+initNewPipelineForm();
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 be9259ec3ca..10105af3561 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
@@ -3,9 +3,9 @@ import SecretValues from '~/behaviors/secret_values';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
+import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
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 initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
@@ -36,10 +36,6 @@ document.addEventListener('DOMContentLoaded', () => {
initSettingsPipelinesTriggers();
initArtifactsSettings();
-
- if (gon?.features?.vueifySharedRunnersToggle) {
- initSharedRunnersToggle();
- }
-
+ initSharedRunnersToggle();
initInstallRunner();
});
diff --git a/app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js b/app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js
new file mode 100644
index 00000000000..93c6a2c63a3
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/packages_and_registries/show/index.js
@@ -0,0 +1,5 @@
+import registrySettingsApp from '~/packages_and_registries/settings/project/registry_settings_bundle';
+import initSettingsPanels from '~/settings_panels';
+
+registrySettingsApp();
+initSettingsPanels();
diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js
index 8d390c8586b..380091a3501 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/form.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/form.js
@@ -1,7 +1,7 @@
/* eslint-disable no-new */
+import initDatePicker from '~/behaviors/date_picker';
import initDeployKeys from '~/deploy_keys';
-import DueDateSelectors from '~/due_date_select';
import fileUpload from '~/lib/utils/file_upload';
import ProtectedBranchCreate from '~/protected_branches/protected_branch_create';
import ProtectedBranchEditList from '~/protected_branches/protected_branch_edit_list';
@@ -16,6 +16,6 @@ export default () => {
initSettingsPanels();
new ProtectedBranchCreate({ hasLicense: false });
new ProtectedBranchEditList();
- new DueDateSelectors();
+ initDatePicker(); // Used for deploy token "expires at" field
fileUpload('.js-choose-file', '.js-object-map-input');
};
diff --git a/app/assets/javascripts/pages/projects/snippets/show/index.js b/app/assets/javascripts/pages/projects/snippets/show/index.js
index f955a41e18a..c719601ee0b 100644
--- a/app/assets/javascripts/pages/projects/snippets/show/index.js
+++ b/app/assets/javascripts/pages/projects/snippets/show/index.js
@@ -1 +1,9 @@
import '~/snippet/snippet_show';
+
+const awardEmojiEl = document.getElementById('js-vue-awards-block');
+
+if (awardEmojiEl) {
+ import('~/emoji/awards_app')
+ .then((m) => m.default(awardEmojiEl))
+ .catch(() => {});
+}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 6afc33ec8a5..43753926039 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -1,9 +1,21 @@
<script>
-import { GlForm, GlIcon, GlLink, GlButton, GlSprintf } from '@gitlab/ui';
+import {
+ GlForm,
+ GlIcon,
+ GlLink,
+ GlButton,
+ GlSprintf,
+ GlAlert,
+ GlLoadingIcon,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf';
import { setUrlFragment } from '~/lib/utils/url_utility';
-import { __, s__, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const MARKDOWN_LINK_TEXT = {
markdown: '[Link Title](page-slug)',
@@ -13,21 +25,98 @@ const MARKDOWN_LINK_TEXT = {
};
export default {
+ i18n: {
+ title: {
+ label: s__('WikiPage|Title'),
+ placeholder: s__('WikiPage|Page title'),
+ helpText: {
+ existingPage: s__(
+ 'WikiPage|Tip: You can move this page by adding the path to the beginning of the title.',
+ ),
+ newPage: s__(
+ 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.',
+ ),
+ moreInformation: s__('WikiPage|More Information.'),
+ },
+ },
+ format: {
+ label: s__('WikiPage|Format'),
+ },
+ content: {
+ label: s__('WikiPage|Content'),
+ placeholder: s__('WikiPage|Write your content or drag files here…'),
+ },
+ contentEditor: {
+ renderFailed: {
+ message: s__(
+ 'WikiPage|An error occured while trying to render the content editor. Please try again later.',
+ ),
+ primaryAction: s__('WikiPage|Retry'),
+ },
+ useNewEditor: s__('WikiPage|Use new editor'),
+ switchToOldEditor: {
+ label: s__('WikiPage|Switch to old editor'),
+ helpText: s__("WikiPage|Switching will discard any changes you've made in the new editor."),
+ modal: {
+ title: s__('WikiPage|Are you sure you want to switch to the old editor?'),
+ primary: s__('WikiPage|Switch to old editor'),
+ cancel: s__('WikiPage|Keep editing'),
+ text: s__(
+ "WikiPage|Switching to the old editor will discard any changes you've made in the new editor.",
+ ),
+ },
+ },
+ helpText: s__(
+ "WikiPage|This editor is in beta and may not display the page's contents properly.",
+ ),
+ },
+ linksHelpText: s__(
+ 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
+ ),
+ commitMessage: {
+ label: s__('WikiPage|Commit message'),
+ value: {
+ existingPage: s__('WikiPage|Update %{pageTitle}'),
+ newPage: s__('WikiPage|Create %{pageTitle}'),
+ },
+ },
+ submitButton: {
+ existingPage: s__('WikiPage|Save changes'),
+ newPage: s__('WikiPage|Create page'),
+ },
+ cancel: s__('WikiPage|Cancel'),
+ },
components: {
+ GlAlert,
GlForm,
GlSprintf,
GlIcon,
GlLink,
GlButton,
+ GlModal,
MarkdownField,
+ GlLoadingIcon,
+ ContentEditor: () =>
+ import(
+ /* webpackChunkName: 'content_editor' */ '~/content_editor/components/content_editor.vue'
+ ),
+ },
+ directives: {
+ GlModalDirective,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['formatOptions', 'pageInfo'],
data() {
return {
title: this.pageInfo.title?.trim() || '',
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content?.trim() || '',
+ isContentEditorLoading: true,
+ useContentEditor: false,
commitMessage: '',
+ contentEditor: null,
+ isDirty: false,
+ contentEditorRenderFailed: false,
};
},
computed: {
@@ -45,15 +134,21 @@ export default {
},
commitMessageI18n() {
return this.pageInfo.persisted
- ? s__('WikiPage|Update %{pageTitle}')
- : s__('WikiPage|Create %{pageTitle}');
+ ? this.$options.i18n.commitMessage.value.existingPage
+ : this.$options.i18n.commitMessage.value.newPage;
},
linkExample() {
return MARKDOWN_LINK_TEXT[this.format];
},
submitButtonText() {
- if (this.pageInfo.persisted) return __('Save changes');
- return s__('WikiPage|Create page');
+ return this.pageInfo.persisted
+ ? this.$options.i18n.submitButton.existingPage
+ : this.$options.i18n.submitButton.newPage;
+ },
+ titleHelpText() {
+ return this.pageInfo.persisted
+ ? this.$options.i18n.title.helpText.existingPage
+ : this.$options.i18n.title.helpText.newPage;
},
cancelFormPath() {
if (this.pageInfo.persisted) return this.pageInfo.path;
@@ -62,20 +157,53 @@ export default {
wikiSpecificMarkdownHelpPath() {
return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown');
},
+ isMarkdownFormat() {
+ return this.format === 'markdown';
+ },
+ showContentEditorButton() {
+ return this.isMarkdownFormat && !this.useContentEditor && this.glFeatures.wikiContentEditor;
+ },
+ disableSubmitButton() {
+ return !this.content || !this.title || this.contentEditorRenderFailed;
+ },
+ isContentEditorActive() {
+ return this.isMarkdownFormat && this.useContentEditor;
+ },
},
mounted() {
this.updateCommitMessage();
+
+ window.addEventListener('beforeunload', this.onPageUnload);
+ },
+ destroyed() {
+ window.removeEventListener('beforeunload', this.onPageUnload);
},
methods: {
+ getContentHTML(content) {
+ return axios
+ .post(this.pageInfo.markdownPreviewPath, { text: content })
+ .then(({ data }) => data.body);
+ },
+
handleFormSubmit() {
- window.removeEventListener('beforeunload', this.onBeforeUnload);
+ if (this.useContentEditor) {
+ this.content = this.contentEditor.getSerializedContent();
+ }
+
+ this.isDirty = false;
},
handleContentChange() {
- window.addEventListener('beforeunload', this.onBeforeUnload);
+ this.isDirty = true;
},
- onBeforeUnload() {
+ onPageUnload(event) {
+ if (!this.isDirty) return undefined;
+
+ event.preventDefault();
+
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = '';
return '';
},
@@ -88,6 +216,48 @@ export default {
const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: newTitle }, false);
this.commitMessage = newCommitMessage;
},
+
+ async initContentEditor() {
+ this.isContentEditorLoading = true;
+ this.useContentEditor = true;
+
+ const { createContentEditor } = await import(
+ /* webpackChunkName: 'content_editor' */ '~/content_editor/services/create_content_editor'
+ );
+ this.contentEditor =
+ this.contentEditor ||
+ createContentEditor({
+ renderMarkdown: (markdown) => this.getContentHTML(markdown),
+ tiptapOptions: {
+ onUpdate: () => this.handleContentChange(),
+ },
+ });
+
+ try {
+ await this.contentEditor.setSerializedContent(this.content);
+ this.isContentEditorLoading = false;
+ } catch (e) {
+ this.contentEditorRenderFailed = true;
+ }
+ },
+
+ retryInitContentEditor() {
+ this.contentEditorRenderFailed = false;
+ this.initContentEditor();
+ },
+
+ switchToOldEditor() {
+ this.useContentEditor = false;
+ },
+
+ confirmSwitchToOldEditor() {
+ if (this.contentEditorRenderFailed) {
+ this.contentEditorRenderFailed = false;
+ this.switchToOldEditor();
+ } else {
+ this.$refs.confirmSwitchToOldEditorModal.show();
+ }
+ },
},
};
</script>
@@ -99,6 +269,19 @@ export default {
class="wiki-form common-note-form gl-mt-3 js-quick-submit"
@submit="handleFormSubmit"
>
+ <gl-alert
+ v-if="isContentEditorActive && contentEditorRenderFailed"
+ class="gl-mb-6"
+ :dismissible="false"
+ variant="danger"
+ :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction"
+ @primaryAction="retryInitContentEditor()"
+ >
+ <p>
+ {{ $options.i18n.contentEditor.renderFailed.message }}
+ </p>
+ </gl-alert>
+
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
<input
@@ -109,7 +292,9 @@ export default {
/>
<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>
+ <label class="control-label-full-width" for="wiki_title">{{
+ $options.i18n.title.label
+ }}</label>
</div>
<div class="col-sm-10">
<input
@@ -121,22 +306,15 @@ export default {
data-qa-selector="wiki_title_textbox"
:required="true"
:autofocus="!pageInfo.persisted"
- :placeholder="s__('WikiPage|Page title')"
+ :placeholder="$options.i18n.title.placeholder"
@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
+ {{ titleHelpText }}
+ <gl-link :href="helpPath" target="_blank"
+ ><gl-icon name="question-o" />
+ {{ $options.i18n.title.helpText.moreInformation }}</gl-link
>
</span>
</div>
@@ -144,25 +322,63 @@ export default {
<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')
+ $options.i18n.format.label
}}</label>
</div>
<div class="col-sm-10">
- <select id="wiki_format" v-model="format" class="form-control" name="wiki[format]">
+ <select
+ id="wiki_format"
+ v-model="format"
+ class="form-control"
+ name="wiki[format]"
+ :disabled="isContentEditorActive"
+ >
<option v-for="(key, label) of formatOptions" :key="key" :value="key">
{{ label }}
</option>
</select>
+ <div>
+ <gl-button
+ v-if="showContentEditorButton"
+ category="secondary"
+ variant="confirm"
+ class="gl-mt-4"
+ @click="initContentEditor"
+ >{{ $options.i18n.contentEditor.useNewEditor }}</gl-button
+ >
+ <div v-if="isContentEditorActive" class="gl-mt-4 gl-display-flex">
+ <div class="gl-mr-4">
+ <gl-button category="secondary" variant="confirm" @click="confirmSwitchToOldEditor">{{
+ $options.i18n.contentEditor.switchToOldEditor.label
+ }}</gl-button>
+ </div>
+ <div class="gl-mt-2">
+ <gl-icon name="warning" />
+ {{ $options.i18n.contentEditor.switchToOldEditor.helpText }}
+ </div>
+ </div>
+ <gl-modal
+ ref="confirmSwitchToOldEditorModal"
+ modal-id="confirm-switch-to-old-editor"
+ :title="$options.i18n.contentEditor.switchToOldEditor.modal.title"
+ :action-primary="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.primary }"
+ :action-cancel="{ text: $options.i18n.contentEditor.switchToOldEditor.modal.cancel }"
+ @primary="switchToOldEditor"
+ >
+ {{ $options.i18n.contentEditor.switchToOldEditor.modal.text }}
+ </gl-modal>
+ </div>
</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')
+ $options.i18n.content.label
}}</label>
</div>
<div class="col-sm-10">
<markdown-field
+ v-if="!isContentEditorActive"
:markdown-preview-path="pageInfo.markdownPreviewPath"
:can-attach-file="true"
:enable-autocomplete="true"
@@ -182,24 +398,25 @@ export default {
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…')"
+ :aria-label="$options.i18n.content.label"
+ :placeholder="$options.i18n.content.placeholder"
@input="handleContentChange"
>
</textarea>
</template>
</markdown-field>
+
+ <div v-if="isContentEditorActive">
+ <gl-loading-icon v-if="isContentEditorLoading" class="bordered-box gl-w-full gl-py-6" />
+ <content-editor v-else :content-editor="contentEditor" />
+ <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
+ </div>
+
<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}.',
- )
- "
- >
+ <gl-sprintf v-if="!isContentEditorActive" :message="$options.i18n.linksHelpText">
<template #linkExample
><code>{{ linkExample }}</code></template
>
@@ -214,13 +431,16 @@ export default {
></template
>
</gl-sprintf>
+ <span v-else>
+ {{ $options.i18n.contentEditor.helpText }}
+ </span>
</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')
+ $options.i18n.commitMessage.label
}}</label>
</div>
<div class="col-sm-10">
@@ -231,7 +451,7 @@ export default {
type="text"
class="form-control"
data-qa-selector="wiki_message_textbox"
- :placeholder="s__('WikiPage|Commit message')"
+ :placeholder="$options.i18n.commitMessage.label"
/>
</div>
</div>
@@ -242,12 +462,10 @@ export default {
type="submit"
data-qa-selector="wiki_submit_button"
data-testid="wiki-submit-button"
- :disabled="!content || !title"
+ :disabled="disableSubmitButton"
>{{ submitButtonText }}</gl-button
>
- <gl-button :href="cancelFormPath" class="float-right" data-testid="wiki-cancel-button">{{
- __('Cancel')
- }}</gl-button>
+ <gl-button :href="cancelFormPath" class="float-right">{{ $options.i18n.cancel }}</gl-button>
</div>
</gl-form>
</template>
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index d236dc4610a..c416106fdd8 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -247,7 +247,7 @@ export default class ActivityCalendar {
renderKey() {
const keyValues = [
- __('no contributions'),
+ __('No contributions'),
__('1-9 contributions'),
__('10-19 contributions'),
__('20-29 contributions'),
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 80e14842f51..f9d70845560 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -223,14 +223,14 @@ export default class UserTabs {
.then((data) => UserTabs.renderActivityCalendar(data, $calendarWrap))
.catch(() => {
const cWrap = $calendarWrap[0];
- cWrap.querySelector('.spinner').classList.add('invisible');
+ cWrap.querySelector('.gl-spinner').classList.add('invisible');
cWrap.querySelector('.user-calendar-error').classList.remove('invisible');
cWrap
.querySelector('.user-calendar-error .js-retry-load')
.addEventListener('click', (e) => {
e.preventDefault();
cWrap.querySelector('.user-calendar-error').classList.add('invisible');
- cWrap.querySelector('.spinner').classList.remove('invisible');
+ cWrap.querySelector('.gl-spinner').classList.remove('invisible');
this.loadActivityCalendar();
});
});
diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js
index 98b2e4238c1..1db80057d0c 100644
--- a/app/assets/javascripts/performance/constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -43,6 +43,7 @@ export const WEBIDE_MEASURE_FETCH_FILES = 'WebIDE: Fetch Files';
//
// MR Diffs namespace
+//
// Marks
export const MR_DIFFS_MARK_FILE_TREE_START = 'mr-diffs-mark-file-tree-start';
@@ -75,3 +76,14 @@ export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION =
export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds';
export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total';
export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_links_per_job_ratio';
+
+//
+// REPO BROWSER NAMESPACE
+//
+
+// Marks
+export const REPO_BLOB_LOAD_VIEWER_START = 'blobviewer-load-viewer-start';
+export const REPO_BLOB_LOAD_VIEWER_FINISH = 'blobviewer-load-viewer-finish';
+
+// Measures
+export const REPO_BLOB_LOAD_VIEWER = 'Repository File Viewer: loading the content';
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
index 14a4a9d5710..567164cb0ee 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -11,6 +11,7 @@ import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphq
import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql';
import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql';
+import getPipelineEtag from '../../graphql/queries/client/pipeline_etag.graphql';
import CommitForm from './commit_form.vue';
@@ -94,10 +95,15 @@ export default {
},
update(store, { data }) {
const commitSha = data?.commitCreate?.commit?.sha;
+ const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath;
if (commitSha) {
store.writeQuery({ query: getCommitSha, data: { commitSha } });
}
+
+ if (pipelineEtag) {
+ store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } });
+ }
},
});
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
new file mode 100644
index 00000000000..22c1563350d
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlCard, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import PipelineVisualReference from '../ui/pipeline_visual_reference.vue';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|🚀 Run your first pipeline'),
+ firstParagraph: s__(
+ 'PipelineEditorTutorial|A typical GitLab pipeline consists of three stages: build, test and deploy. Each stage can have one or more jobs.',
+ ),
+ secondParagraph: s__(
+ 'PipelineEditorTutorial|In the example below, %{codeStart}build%{codeEnd} and %{codeStart}deploy%{codeEnd} each contain one job, and %{codeStart}test%{codeEnd} contains two jobs. Your scripts run in jobs like these.',
+ ),
+ thirdParagraph: s__(
+ 'PipelineEditorTutorial|You can use %{linkStart}CI/CD examples and templates%{linkEnd} to get your first %{codeStart}.gitlab-ci.yml%{codeEnd} configuration file started. Your first pipeline runs when you commit the changes.',
+ ),
+ note: s__(
+ 'PipelineEditorTutorial|If you’re using a self-managed GitLab instance, %{linkStart}make sure your instance has runners available.%{linkEnd}',
+ ),
+ },
+ components: {
+ GlCard,
+ GlLink,
+ GlSprintf,
+ PipelineVisualReference,
+ },
+ inject: ['ciExamplesHelpPagePath', 'runnerHelpPagePath'],
+};
+</script>
+<template>
+ <gl-card>
+ <template #default>
+ <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <p class="gl-mb-3">
+ <gl-sprintf :message="$options.i18n.secondParagraph">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <pipeline-visual-reference />
+ <p class="gl-my-3">
+ <gl-sprintf :message="$options.i18n.thirdParagraph">
+ <template #link="{ content }">
+ <gl-link :href="ciExamplesHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.note">
+ <template #link="{ content }">
+ <gl-link :href="runnerHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue
new file mode 100644
index 00000000000..3da535f5f94
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlCard, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|Get started with GitLab CI/CD'),
+ firstParagraph: s__(
+ 'PipelineEditorTutorial|GitLab CI/CD can automatically build, test, and deploy your application.',
+ ),
+ secondParagraph: s__(
+ 'PipelineEditorTutorial|The pipeline stages and jobs are defined in a %{codeStart}.gitlab-ci.yml%{codeEnd} file. You can edit, visualize and validate the syntax in this file by using the Pipeline Editor.',
+ ),
+ },
+ components: {
+ GlCard,
+ GlSprintf,
+ },
+};
+</script>
+<template>
+ <gl-card>
+ <template #default>
+ <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <p class="gl-mb-0">
+ <gl-sprintf :message="$options.i18n.secondParagraph">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
new file mode 100644
index 00000000000..f714f6411f1
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlCard, GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|⚙️ Pipeline configuration reference'),
+ firstParagraph: s__('PipelineEditorTutorial|Resources to help with your CI/CD configuration:'),
+ browseExamples: s__(
+ 'PipelineEditorTutorial|Browse %{linkStart}CI/CD examples and templates%{linkEnd}',
+ ),
+ viewSyntaxRef: s__(
+ 'PipelineEditorTutorial|View %{linkStart}.gitlab-ci.yml syntax reference%{linkEnd}',
+ ),
+ learnMore: s__(
+ 'PipelineEditorTutorial|Learn more about %{linkStart}GitLab CI/CD concepts%{linkEnd}',
+ ),
+ needs: s__(
+ 'PipelineEditorTutorial|Make your pipeline more efficient with the %{linkStart}Needs keyword%{linkEnd}',
+ ),
+ },
+ components: {
+ GlCard,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['ciExamplesHelpPagePath', 'ciHelpPagePath', 'needsHelpPagePath', 'ymlHelpPagePath'],
+};
+</script>
+<template>
+ <gl-card>
+ <template #default>
+ <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
+ <p class="gl-mb-3">{{ $options.i18n.firstParagraph }}</p>
+ <ul>
+ <li>
+ <gl-sprintf :message="$options.i18n.browseExamples">
+ <template #link="{ content }">
+ <gl-link :href="ciExamplesHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.viewSyntaxRef">
+ <template #link="{ content }">
+ <gl-link :href="ymlHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.learnMore">
+ <template #link="{ content }">
+ <gl-link :href="ciHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.needs">
+ <template #link="{ content }">
+ <gl-link :href="needsHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ </template>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
new file mode 100644
index 00000000000..512414f0246
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlCard } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ i18n: {
+ title: s__('PipelineEditorTutorial|💡 Tip: Visualize and validate your pipeline'),
+ firstParagraph: s__(
+ 'PipelineEditorTutorial|Use the Visualize and Lint tabs in the Pipeline Editor to visualize your pipeline and check for any errors or warnings before committing your changes.',
+ ),
+ },
+ components: {
+ GlCard,
+ },
+};
+</script>
+<template>
+ <gl-card>
+ <template #default>
+ <h4 class="gl-font-lg gl-mt-0">{{ $options.i18n.title }}</h4>
+ <p class="gl-mb-0">{{ $options.i18n.firstParagraph }}</p>
+ </template>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
new file mode 100644
index 00000000000..ff1e0b6388f
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue
@@ -0,0 +1,105 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import { DRAWER_EXPANDED_KEY } from '../../constants';
+import FirstPipelineCard from './cards/first_pipeline_card.vue';
+import GettingStartedCard from './cards/getting_started_card.vue';
+import PipelineConfigReferenceCard from './cards/pipeline_config_reference_card.vue';
+import VisualizeAndLintCard from './cards/visualize_and_lint_card.vue';
+
+export default {
+ width: {
+ expanded: '482px',
+ collapsed: '58px',
+ },
+ i18n: {
+ toggleTxt: __('Collapse'),
+ },
+ localDrawerKey: DRAWER_EXPANDED_KEY,
+ components: {
+ FirstPipelineCard,
+ GettingStartedCard,
+ GlButton,
+ GlIcon,
+ LocalStorageSync,
+ PipelineConfigReferenceCard,
+ VisualizeAndLintCard,
+ },
+ data() {
+ return {
+ isExpanded: false,
+ topPosition: 0,
+ };
+ },
+ computed: {
+ buttonIconName() {
+ return this.isExpanded ? 'chevron-double-lg-right' : 'chevron-double-lg-left';
+ },
+ buttonClass() {
+ return this.isExpanded ? 'gl-justify-content-end!' : '';
+ },
+ rootStyle() {
+ const { expanded, collapsed } = this.$options.width;
+ const top = this.topPosition;
+ const style = { top: `${top}px` };
+
+ return this.isExpanded ? { ...style, width: expanded } : { ...style, width: collapsed };
+ },
+ },
+ mounted() {
+ this.setTopPosition();
+ this.setInitialExpandState();
+ },
+ methods: {
+ setInitialExpandState() {
+ // We check in the local storage and if no value is defined, we want the default
+ // to be true. We want to explicitly set it to true here so that the drawer
+ // animates to open on load.
+ const localValue = localStorage.getItem(this.$options.localDrawerKey);
+ if (localValue === null) {
+ this.isExpanded = true;
+ }
+ },
+ setTopPosition() {
+ const navbarEl = document.querySelector('.js-navbar');
+
+ if (navbarEl) {
+ this.topPosition = navbarEl.getBoundingClientRect().bottom;
+ }
+ },
+ toggleDrawer() {
+ this.isExpanded = !this.isExpanded;
+ },
+ },
+};
+</script>
+<template>
+ <local-storage-sync v-model="isExpanded" :storage-key="$options.localDrawerKey" as-json>
+ <aside
+ aria-live="polite"
+ class="gl-fixed gl-right-0 gl-bg-gray-10 gl-shadow-drawer gl-transition-property-width gl-transition-duration-medium gl-border-l-solid gl-border-1 gl-border-gray-100 gl-h-full gl-z-index-3 gl-overflow-y-auto"
+ :style="rootStyle"
+ >
+ <gl-button
+ category="tertiary"
+ class="gl-w-full gl-h-9 gl-rounded-0! gl-border-none! gl-border-b-solid! gl-border-1! gl-border-gray-100 gl-text-decoration-none! gl-outline-0! gl-display-flex"
+ :class="buttonClass"
+ :title="__('Toggle sidebar')"
+ @click="toggleDrawer"
+ >
+ <span v-if="isExpanded" class="gl-text-gray-500 gl-mr-3" data-testid="collapse-text">
+ {{ __('Collapse') }}
+ </span>
+ <gl-icon data-testid="toggle-icon" :name="buttonIconName" />
+ </gl-button>
+ <div v-if="isExpanded" class="gl-h-full gl-p-5" data-testid="drawer-content">
+ <getting-started-card class="gl-mb-4" />
+ <first-pipeline-card class="gl-mb-4" />
+ <visualize-and-lint-card class="gl-mb-4" />
+ <pipeline-config-reference-card />
+ <div class="gl-h-13"></div>
+ </div>
+ </aside>
+ </local-storage-sync>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue
new file mode 100644
index 00000000000..049504181c4
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ props: {
+ jobName: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="gl-w-13 gl-h-6 gl-font-sm gl-bg-white gl-inset-border-1-blue-500 gl-text-center gl-text-truncate gl-rounded-pill gl-px-4 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
+ >
+ {{ jobName }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue
new file mode 100644
index 00000000000..1017237365b
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/drawer/ui/pipeline_visual_reference.vue
@@ -0,0 +1,43 @@
+<script>
+import { s__ } from '~/locale';
+import DemoJobPill from './demo_job_pill.vue';
+
+export default {
+ i18n: {
+ stageNames: {
+ build: s__('StageName|Build'),
+ test: s__('StageName|Test'),
+ deploy: s__('StageName|Deploy'),
+ },
+ jobNames: {
+ build: s__('JobName|build-job'),
+ test_1: s__('JobName|unit-test'),
+ test_2: s__('JobName|lint-test'),
+ deploy: s__('JobName|deploy-app'),
+ },
+ },
+ stageClasses:
+ 'gl-bg-blue-50 gl-display-flex gl-flex-direction-column gl-align-items-center gl-p-4 gl-rounded-base',
+ titleClasses: 'gl-text-blue-600 gl-mb-4',
+ components: {
+ DemoJobPill,
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-justify-content-center">
+ <div :class="$options.stageClasses" class="gl-mr-5">
+ <div :class="$options.titleClasses">{{ $options.i18n.stageNames.build }}</div>
+ <demo-job-pill :job-name="$options.i18n.jobNames.build" />
+ </div>
+ <div :class="$options.stageClasses" class="gl-mr-5">
+ <div :class="$options.titleClasses">{{ $options.i18n.stageNames.test }}</div>
+ <demo-job-pill class="gl-mb-3" :job-name="$options.i18n.jobNames.test_1" />
+ <demo-job-pill :job-name="$options.i18n.jobNames.test_2" />
+ </div>
+ <div :class="$options.stageClasses">
+ <div :class="$options.titleClasses">{{ $options.i18n.stageNames.deploy }}</div>
+ <demo-job-pill :job-name="$options.i18n.jobNames.deploy" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index b3eba0fcc19..1acf3a03e73 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -1,32 +1,77 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlInfiniteScroll,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { historyPushState } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
+import {
+ BRANCH_PAGINATION_LIMIT,
+ BRANCH_SEARCH_DEBOUNCE,
+ 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: {
+ dropdownHeader: s__('Switch Branch'),
title: s__('Branches'),
fetchError: s__('Unable to fetch branch list for this project.'),
},
+ inputDebounce: BRANCH_SEARCH_DEBOUNCE,
components: {
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
- GlIcon,
+ GlInfiniteScroll,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ inject: ['projectFullPath', 'totalBranches'],
+ props: {
+ paginationLimit: {
+ type: Number,
+ required: false,
+ default: BRANCH_PAGINATION_LIMIT,
+ },
+ },
+ data() {
+ return {
+ branches: [],
+ page: {
+ limit: this.paginationLimit,
+ offset: 0,
+ searchTerm: '',
+ },
+ };
},
- inject: ['projectFullPath'],
apollo: {
- branches: {
+ availableBranches: {
query: getAvailableBranches,
variables() {
return {
+ limit: this.page.limit,
+ offset: this.page.offset,
projectFullPath: this.projectFullPath,
+ searchPattern: this.searchPattern,
};
},
update(data) {
- return data.project?.repository?.branches || [];
+ return data.project?.repository?.branchNames || [];
+ },
+ result({ data }) {
+ const newBranches = data.project?.repository?.branchNames || [];
+
+ // check that we're not re-concatenating existing fetch results
+ if (!this.branches.includes(newBranches[0])) {
+ this.branches = this.branches.concat(newBranches);
+ }
},
error() {
this.$emit('showError', {
@@ -40,26 +85,99 @@ export default {
},
},
computed: {
- hasBranchList() {
- return this.branches?.length > 0;
+ isBranchesLoading() {
+ return this.$apollo.queries.availableBranches.loading;
+ },
+ showBranchSwitcher() {
+ return this.branches.length > 0 || this.page.searchTerm.length > 0;
+ },
+ searchPattern() {
+ if (this.page.searchTerm === '') {
+ return '*';
+ }
+
+ return `*${this.page.searchTerm}*`;
+ },
+ },
+ methods: {
+ // if there is no searchPattern, paginate by {paginationLimit} branches
+ fetchNextBranches() {
+ if (
+ this.isBranchesLoading ||
+ this.page.searchTerm.length > 0 ||
+ this.branches.length === this.totalBranches
+ ) {
+ return;
+ }
+
+ this.page = {
+ ...this.page,
+ limit: this.paginationLimit,
+ offset: this.page.offset + this.paginationLimit,
+ };
+ },
+ async selectBranch(newBranch) {
+ if (newBranch === this.currentBranch) {
+ return;
+ }
+
+ await this.$apollo.getClient().writeQuery({
+ query: getCurrentBranch,
+ data: { currentBranch: newBranch },
+ });
+
+ const updatedPath = setUrlParams({ branch_name: newBranch });
+ historyPushState(updatedPath);
+
+ this.$emit('refetchContent');
+ },
+ setSearchTerm(newSearchTerm) {
+ this.branches = [];
+ this.page = {
+ limit: newSearchTerm.trim() === '' ? this.paginationLimit : this.totalBranches,
+ offset: 0,
+ searchTerm: newSearchTerm.trim(),
+ };
},
},
};
</script>
<template>
- <gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch">
+ <gl-dropdown
+ v-if="showBranchSwitcher"
+ class="gl-ml-2"
+ :header-text="$options.i18n.dropdownHeader"
+ :text="currentBranch"
+ icon="branch"
+ >
+ <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" />
<gl-dropdown-section-header>
- {{ this.$options.i18n.title }}
+ {{ $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-infinite-scroll
+ :fetched-items="branches.length"
+ :total-items="totalBranches"
+ :max-list-height="250"
+ @bottomReached="fetchNextBranches"
>
- <gl-icon name="check" class="gl-visibility-hidden" />
- {{ branch.name }}
- </gl-dropdown-item>
+ <template #items>
+ <gl-dropdown-item
+ v-for="branch in branches"
+ :key="branch"
+ :is-checked="currentBranch === branch"
+ :is-check-item="true"
+ @click="selectBranch(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ </template>
+ <template #default>
+ <gl-dropdown-item v-if="isBranchesLoading" key="loading">
+ <gl-loading-icon size="md" />
+ </gl-dropdown-item>
+ </template>
+ </gl-infinite-scroll>
</gl-dropdown>
</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 fefa784f060..24bca04e115 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
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PipelineStatus from './pipeline_status.vue';
import ValidationSegment from './validation_segment.vue';
@@ -29,7 +28,6 @@ export default {
PipelineStatus,
ValidationSegment,
},
- mixins: [glFeatureFlagsMixin()],
props: {
ciConfigData: {
type: Object,
@@ -42,7 +40,7 @@ export default {
},
computed: {
showPipelineStatus() {
- return this.glFeatures.pipelineStatusForPipelineEditor && !this.isNewCiConfigFile;
+ return !this.isNewCiConfigFile;
},
// make sure corners are rounded correctly depending on if
// pipeline status is rendered
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 4a92e106da1..368a026bdaa 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -5,7 +5,11 @@ 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 getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.graphql';
+import {
+ getQueryHeaders,
+ toggleQueryPollingByVisibility,
+} from '~/pipelines/components/graph/utils';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const POLL_INTERVAL = 10000;
@@ -31,7 +35,13 @@ export default {
commitSha: {
query: getCommitSha,
},
+ pipelineEtag: {
+ query: getPipelineEtag,
+ },
pipeline: {
+ context() {
+ return getQueryHeaders(this.pipelineEtag);
+ },
query: getPipelineQuery,
variables() {
return {
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 5acb3355b23..4e2f26af51d 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -110,7 +110,6 @@ export default {
<text-editor :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<editor-tab
- v-if="glFeatures.ciConfigVisualizationTab"
class="gl-mb-3"
:empty-message="$options.i18n.empty.visualization"
:is-empty="isEmpty"
@@ -135,7 +134,6 @@ export default {
<ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
</editor-tab>
<editor-tab
- v-if="glFeatures.ciConfigMergedTab"
class="gl-mb-3"
:empty-message="$options.i18n.empty.merge"
:keep-component-mounted="false"
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
index d4f04a0d055..0ac4a40ff4a 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -1,12 +1,14 @@
<script>
import { GlButton, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
+import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
GlButton,
GlSprintf,
+ PipelineEditorFileNav,
},
i18n: {
title: __('Optimize your workflow with CI/CD Pipelines'),
@@ -22,6 +24,9 @@ export default {
},
},
computed: {
+ showFileNav() {
+ return this.glFeatures.pipelineEditorBranchSwitcher;
+ },
showCTAButton() {
return this.glFeatures.pipelineEditorEmptyStateAction;
},
@@ -34,23 +39,26 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
- <img :src="emptyStateIllustrationPath" />
- <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
- <p class="gl-mt-3">
- <gl-sprintf :message="$options.i18n.body">
- <template #code="{ content }">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </p>
- <gl-button
- v-if="showCTAButton"
- variant="confirm"
- class="gl-mt-3"
- @click="createEmptyConfigFile"
- >
- {{ $options.i18n.btnText }}
- </gl-button>
+ <div>
+ <pipeline-editor-file-nav v-if="showFileNav" v-on="$listeners" />
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <img :src="emptyStateIllustrationPath" />
+ <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
+ <p class="gl-mt-3">
+ <gl-sprintf :message="$options.i18n.body">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-button
+ v-if="showCTAButton"
+ variant="confirm"
+ class="gl-mt-3"
+ @click="createEmptyConfigFile"
+ >
+ {{ $options.i18n.btnText }}
+ </gl-button>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
new file mode 100644
index 00000000000..091b202e10b
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue
@@ -0,0 +1,155 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
+import { __, s__ } from '~/locale';
+import {
+ COMMIT_FAILURE,
+ COMMIT_SUCCESS,
+ DEFAULT_FAILURE,
+ DEFAULT_SUCCESS,
+ LOAD_FAILURE_UNKNOWN,
+} from '../../constants';
+import CodeSnippetAlert from '../code_snippet_alert/code_snippet_alert.vue';
+import {
+ CODE_SNIPPET_SOURCE_URL_PARAM,
+ CODE_SNIPPET_SOURCES,
+} from '../code_snippet_alert/constants';
+
+export default {
+ components: {
+ GlAlert,
+ CodeSnippetAlert,
+ },
+ errorTexts: {
+ [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
+ [DEFAULT_FAILURE]: __('Something went wrong on our end.'),
+ [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
+ },
+ successTexts: {
+ [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
+ [DEFAULT_SUCCESS]: __('Your action succeeded.'),
+ },
+ props: {
+ failureType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ failureReasons: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ showFailure: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showSuccess: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ successType: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ codeSnippetCopiedFrom: '',
+ };
+ },
+ computed: {
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE_UNKNOWN:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
+ variant: 'danger',
+ };
+ case COMMIT_FAILURE:
+ return {
+ text: this.$options.errorTexts[COMMIT_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT_FAILURE],
+ variant: 'danger',
+ };
+ }
+ },
+ success() {
+ switch (this.successType) {
+ case COMMIT_SUCCESS:
+ return {
+ text: this.$options.successTexts[COMMIT_SUCCESS],
+ variant: 'info',
+ };
+ default:
+ return {
+ text: this.$options.successTexts[DEFAULT_SUCCESS],
+ variant: 'info',
+ };
+ }
+ },
+ },
+ created() {
+ this.parseCodeSnippetSourceParam();
+ },
+ methods: {
+ dismissCodeSnippetAlert() {
+ this.codeSnippetCopiedFrom = '';
+ },
+ dismissFailure() {
+ this.$emit('hide-failure');
+ },
+ dismissSuccess() {
+ this.$emit('hide-success');
+ },
+ 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]),
+ );
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <code-snippet-alert
+ v-if="codeSnippetCopiedFrom"
+ :source="codeSnippetCopiedFrom"
+ class="gl-mb-5"
+ @dismiss="dismissCodeSnippetAlert"
+ />
+ <gl-alert
+ v-if="showSuccess"
+ :variant="success.variant"
+ class="gl-mb-5"
+ @dismiss="dismissSuccess"
+ >
+ {{ success.text }}
+ </gl-alert>
+ <gl-alert
+ v-if="showFailure"
+ :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>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 8d0ec6c3e2d..f0a24e0c061 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -14,6 +14,7 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE';
export const COMMIT_SUCCESS = 'COMMIT_SUCCESS';
export const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
+export const DEFAULT_SUCCESS = 'DEFAULT_SUCCESS';
export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export const CREATE_TAB = 'CREATE_TAB';
@@ -25,3 +26,8 @@ export const TABS_WITH_COMMIT_FORM = [CREATE_TAB, LINT_TAB, VISUALIZE_TAB];
export const COMMIT_ACTION_CREATE = 'CREATE';
export const COMMIT_ACTION_UPDATE = 'UPDATE';
+
+export const DRAWER_EXPANDED_KEY = 'pipeline_editor_drawer_expanded';
+
+export const BRANCH_PAGINATION_LIMIT = 20;
+export const BRANCH_SEARCH_DEBOUNCE = '500';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
index 3b2daa45a18..94e6facabfd 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -22,6 +22,7 @@ mutation commitCIFile(
commit {
sha
}
+ commitPipelinePath
errors
}
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
index f162bb11d47..46e9b108b41 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql
@@ -1,9 +1,12 @@
-query getAvailableBranches($projectFullPath: ID!) {
- project(fullPath: $projectFullPath) @client {
+query getAvailableBranches(
+ $limit: Int!
+ $offset: Int!
+ $projectFullPath: ID!
+ $searchPattern: String!
+) {
+ project(fullPath: $projectFullPath) {
repository {
- branches {
- name
- }
+ branchNames(limit: $limit, offset: $offset, searchPattern: $searchPattern)
}
}
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql
new file mode 100644
index 00000000000..b9946a9e233
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.graphql
@@ -0,0 +1,3 @@
+query getPipelineEtag {
+ pipelineEtag @client
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index caa2a65d424..81e75c32846 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -11,23 +11,6 @@ export const resolvers = {
}),
};
},
- /* eslint-disable @gitlab/require-i18n-strings */
- project() {
- return {
- __typename: 'Project',
- repository: {
- __typename: 'Repository',
- branches: [
- { __typename: 'Branch', name: 'master' },
- { __typename: 'Branch', name: 'main' },
- { __typename: 'Branch', name: 'develop' },
- { __typename: 'Branch', name: 'production' },
- { __typename: 'Branch', name: 'test' },
- ],
- },
- };
- },
- /* eslint-enable @gitlab/require-i18n-strings */
},
Mutation: {
lintCI: (_, { endpoint, content, dry_run }) => {
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 8a1e26f9bff..66158bdba88 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -6,6 +6,7 @@ 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 getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql';
import { resolvers } from './graphql/resolvers';
import typeDefs from './graphql/typedefs.graphql';
import PipelineEditorApp from './pipeline_editor_app.vue';
@@ -26,15 +27,23 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
// Add to apollo cache as it can be updated by future queries
commitSha,
initialBranchName,
+ pipelineEtag,
// Add to provide/inject API for static values
ciConfigPath,
+ ciExamplesHelpPagePath,
+ ciHelpPagePath,
defaultBranch,
emptyStateIllustrationPath,
+ helpPaths,
lintHelpPagePath,
+ needsHelpPagePath,
newMergeRequestPath,
+ pipelinePagePath,
projectFullPath,
projectPath,
projectNamespace,
+ runnerHelpPagePath,
+ totalBranches,
ymlHelpPagePath,
} = el?.dataset;
@@ -48,7 +57,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers, { typeDefs }),
+ defaultClient: createDefaultClient(resolvers, { typeDefs, useGet: true }),
});
const { cache } = apolloProvider.clients.defaultClient;
@@ -66,20 +75,34 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
},
});
+ cache.writeQuery({
+ query: getPipelineEtag,
+ data: {
+ pipelineEtag,
+ },
+ });
+
return new Vue({
el,
apolloProvider,
provide: {
ciConfigPath,
+ ciExamplesHelpPagePath,
+ ciHelpPagePath,
+ configurationPaths,
defaultBranch,
emptyStateIllustrationPath,
+ helpPaths,
lintHelpPagePath,
+ needsHelpPagePath,
newMergeRequestPath,
+ pipelinePagePath,
projectFullPath,
projectPath,
projectNamespace,
+ runnerHelpPagePath,
+ totalBranches: parseInt(totalBranches, 10),
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 e0fb38004ec..79a2a51cebc 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,21 +1,15 @@
<script>
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { fetchPolicies } from '~/lib/graphql';
import httpStatusCodes from '~/lib/utils/http_status';
-import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
-import { __, s__ } from '~/locale';
+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 PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue';
import {
- COMMIT_FAILURE,
- COMMIT_SUCCESS,
- DEFAULT_FAILURE,
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_ERROR,
EDITOR_APP_STATUS_LOADING,
@@ -31,11 +25,10 @@ import PipelineEditorHome from './pipeline_editor_home.vue';
export default {
components: {
ConfirmUnsavedChangesDialog,
- GlAlert,
GlLoadingIcon,
PipelineEditorEmptyState,
PipelineEditorHome,
- CodeSnippetAlert,
+ PipelineEditorMessages,
},
inject: {
ciConfigPath: {
@@ -50,20 +43,20 @@ export default {
ciConfigData: {},
failureType: null,
failureReasons: [],
- showStartScreen: false,
- isNewCiConfigFile: false,
initialCiFileContent: '',
+ isNewCiConfigFile: false,
lastCommittedContent: '',
currentCiFileContent: '',
- showFailureAlert: false,
- showSuccessAlert: false,
successType: null,
- codeSnippetCopiedFrom: '',
+ showStartScreen: false,
+ showSuccess: false,
+ showFailure: false,
};
},
apollo: {
initialCiFileContent: {
+ fetchPolicy: fetchPolicies.NETWORK,
query: getBlobContent,
// If it's a brand new file, we don't want to fetch the content.
// Then when the user commits the first time, the query would run
@@ -87,10 +80,21 @@ export default {
this.lastCommittedContent = fileContent;
this.currentCiFileContent = fileContent;
+
+ // make sure to reset the start screen flag during a refetch
+ // e.g. when switching branches
+ if (fileContent.length) {
+ this.showStartScreen = false;
+ }
},
error(error) {
this.handleBlobContentError(error);
},
+ watchLoading(isLoading) {
+ if (isLoading) {
+ this.setAppStatus(EDITOR_APP_STATUS_LOADING);
+ }
+ },
},
ciConfigData: {
query: getCiConfigData,
@@ -145,50 +149,12 @@ export default {
isEmpty() {
return this.currentCiFileContent === '';
},
- failure() {
- switch (this.failureType) {
- case LOAD_FAILURE_UNKNOWN:
- return {
- text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
- variant: 'danger',
- };
- case COMMIT_FAILURE:
- return {
- text: this.$options.errorTexts[COMMIT_FAILURE],
- variant: 'danger',
- };
- default:
- return {
- text: this.$options.errorTexts[DEFAULT_FAILURE],
- variant: 'danger',
- };
- }
- },
- success() {
- switch (this.successType) {
- case COMMIT_SUCCESS:
- return {
- text: this.$options.successTexts[COMMIT_SUCCESS],
- variant: 'info',
- };
- default:
- return null;
- }
- },
},
i18n: {
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
},
- errorTexts: {
- [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
- [DEFAULT_FAILURE]: __('Something went wrong on our end.'),
- [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
- },
- successTexts: {
- [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'),
- },
watch: {
isEmpty(flag) {
if (flag) {
@@ -196,9 +162,6 @@ export default {
}
},
},
- created() {
- this.parseCodeSnippetSourceParam();
- },
methods: {
handleBlobContentError(error = {}) {
const { networkError } = error;
@@ -216,24 +179,27 @@ export default {
this.reportFailure(LOAD_FAILURE_UNKNOWN);
}
},
-
- dismissFailure() {
- this.showFailureAlert = false;
+ hideFailure() {
+ this.showFailure = false;
+ },
+ hideSuccess() {
+ this.showSuccess = false;
},
- dismissSuccess() {
- this.showSuccessAlert = false;
+ async refetchContent() {
+ this.$apollo.queries.initialCiFileContent.skip = false;
+ await this.$apollo.queries.initialCiFileContent.refetch();
},
reportFailure(type, reasons = []) {
this.setAppStatus(EDITOR_APP_STATUS_ERROR);
window.scrollTo({ top: 0, behavior: 'smooth' });
- this.showFailureAlert = true;
+ this.showFailure = true;
this.failureType = type;
this.failureReasons = reasons;
},
reportSuccess(type) {
window.scrollTo({ top: 0, behavior: 'smooth' });
- this.showSuccessAlert = true;
+ this.showSuccess = true;
this.successType = type;
},
resetContent() {
@@ -266,20 +232,6 @@ 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>
@@ -290,33 +242,18 @@ export default {
<pipeline-editor-empty-state
v-else-if="showStartScreen"
@createEmptyConfigFile="setNewEmptyCiConfigFile"
+ @refetchContent="refetchContent"
/>
<div v-else>
- <code-snippet-alert
- v-if="codeSnippetCopiedFrom"
- :source="codeSnippetCopiedFrom"
- class="gl-mb-5"
- @dismiss="dismissCodeSnippetAlert"
+ <pipeline-editor-messages
+ :failure-type="failureType"
+ :failure-reasons="failureReasons"
+ :show-failure="showFailure"
+ :show-success="showSuccess"
+ :success-type="successType"
+ @hide-success="hideSuccess"
+ @hide-failure="hideFailure"
/>
- <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"
- 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
:ci-config-data="ciConfigData"
:ci-file-content="currentCiFileContent"
@@ -324,6 +261,7 @@ export default {
@commit="updateOnCommit"
@resetContent="resetContent"
@showError="showErrorAlert"
+ @refetchContent="refetchContent"
@updateCiConfig="updateCiConfig"
/>
<confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" />
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index adba55f9f4b..dfe9c82b912 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,5 +1,7 @@
<script>
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import CommitSection from './components/commit/commit_section.vue';
+import PipelineEditorDrawer from './components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from './components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from './components/pipeline_editor_tabs.vue';
@@ -8,10 +10,12 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants';
export default {
components: {
CommitSection,
+ PipelineEditorDrawer,
PipelineEditorFileNav,
PipelineEditorHeader,
PipelineEditorTabs,
},
+ mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -35,6 +39,9 @@ export default {
showCommitForm() {
return TABS_WITH_COMMIT_FORM.includes(this.currentTab);
},
+ showPipelineDrawer() {
+ return this.glFeatures.pipelineEditorDrawer;
+ },
},
methods: {
setCurrentTab(tabName) {
@@ -45,7 +52,7 @@ export default {
</script>
<template>
- <div>
+ <div class="gl-pr-9 gl-transition-medium gl-w-full">
<pipeline-editor-file-nav v-on="$listeners" />
<pipeline-editor-header
:ci-config-data="ciConfigData"
@@ -58,5 +65,6 @@ export default {
@set-current-tab="setCurrentTab"
/>
<commit-section v-if="showCommitForm" :ci-file-content="ciFileContent" v-on="$listeners" />
+ <pipeline-editor-drawer v-if="showPipelineDrawer" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index e44d80ee9d1..5472e51445a 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -21,7 +21,13 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__, __, n__ } from '~/locale';
-import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants';
+import {
+ VARIABLE_TYPE,
+ FILE_TYPE,
+ CONFIG_VARIABLES_TIMEOUT,
+ CC_VALIDATION_REQUIRED_ERROR,
+} from '../constants';
+import filterVariables from '../utils/filter_variables';
import RefsDropdown from './refs_dropdown.vue';
const i18n = {
@@ -59,6 +65,8 @@ export default {
GlSprintf,
GlLoadingIcon,
RefsDropdown,
+ CcValidationRequiredAlert: () =>
+ import('ee_component/billings/components/cc_validation_required_alert.vue'),
},
directives: { SafeHtml },
props: {
@@ -142,6 +150,9 @@ export default {
descriptions() {
return this.form[this.refFullName]?.descriptions ?? {};
},
+ ccRequiredError() {
+ return this.error === CC_VALIDATION_REQUIRED_ERROR;
+ },
},
watch: {
refValue() {
@@ -281,20 +292,13 @@ export default {
},
createPipeline() {
this.submitted = true;
- const filteredVariables = this.variables
- .filter(({ key, value }) => key !== '' && value !== '')
- .map(({ variable_type, key, value }) => ({
- variable_type,
- key,
- secret_value: value,
- }));
return axios
.post(this.pipelinesPath, {
// send shortName as fall back for query params
// https://gitlab.com/gitlab-org/gitlab/-/issues/287815
ref: this.refValue.fullName || this.refShortName,
- variables_attributes: filteredVariables,
+ variables_attributes: filterVariables(this.variables),
})
.then(({ data }) => {
redirectTo(`${this.pipelinesPath}/${data.id}`);
@@ -335,8 +339,9 @@ export default {
<template>
<gl-form @submit.prevent="createPipeline">
+ <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" />
<gl-alert
- v-if="error"
+ v-else-if="error"
:title="errorTitle"
:dismissible="false"
variant="danger"
@@ -393,6 +398,7 @@ export default {
v-model="variable.variable_type"
:class="$options.formElementClasses"
:options="$options.typeOptions"
+ data-testid="pipeline-form-ci-variable-type"
/>
<gl-form-input
v-model="variable.key"
diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
index ed5c659d1df..d35d2010150 100644
--- a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
+++ b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue
@@ -81,11 +81,12 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="refShortName" block @show.once="loadRefs">
+ <gl-dropdown :text="refShortName" block data-testid="ref-select" @show.once="loadRefs">
<gl-search-box-by-type
v-model.trim="searchTerm"
:is-loading="isLoading"
:placeholder="__('Search refs')"
+ data-testid="search-refs"
/>
<gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
<gl-dropdown-item
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index 681755dc6ab..91a064a0fb8 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -4,3 +4,6 @@ export const DEBOUNCE_REFS_SEARCH_MS = 250;
export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';
+
+export const CC_VALIDATION_REQUIRED_ERROR =
+ 'Credit card required to be on file in order to create a pipeline';
diff --git a/app/assets/javascripts/pipeline_new/utils/filter_variables.js b/app/assets/javascripts/pipeline_new/utils/filter_variables.js
new file mode 100644
index 00000000000..57ce3d13a9a
--- /dev/null
+++ b/app/assets/javascripts/pipeline_new/utils/filter_variables.js
@@ -0,0 +1,13 @@
+// We need to filter out blank variables
+// and filter out variables that have no key
+// before sending to the API to create a pipeline.
+
+export default (variables) => {
+ return variables
+ .filter(({ key }) => key !== '')
+ .map(({ variable_type, key, value }) => ({
+ variable_type,
+ key,
+ secret_value: value,
+ }));
+};
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 63048777724..71ec81b8969 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -2,6 +2,7 @@
import { reportToSentry } from '../../utils';
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
+import { generateColumnsFromLayersListMemoized } from '../parsing_utils';
import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
@@ -25,6 +26,10 @@ export default {
type: Object,
required: true,
},
+ showLinks: {
+ type: Boolean,
+ required: true,
+ },
viewType: {
type: String,
required: true,
@@ -74,7 +79,9 @@ export default {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
layout() {
- return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList();
+ return this.isStageView
+ ? this.pipeline.stages
+ : generateColumnsFromLayersListMemoized(this.pipeline, this.pipelineLayers);
},
hasDownstreamPipelines() {
return Boolean(this.pipeline?.downstream?.length > 0);
@@ -91,8 +98,8 @@ export default {
collectMetrics: true,
};
},
- shouldHideLinks() {
- return this.isStageView;
+ showJobLinks() {
+ return !this.isStageView && this.showLinks;
},
shouldShowStageName() {
return !this.isStageView;
@@ -120,26 +127,6 @@ 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,
@@ -178,7 +165,7 @@ export default {
<div class="js-pipeline-graph">
<div
ref="mainPipelineContainer"
- class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap"
+ class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap gl-border-t-solid gl-border-t-1 gl-border-gray-100"
:class="{ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline }"
>
<linked-graph-wrapper>
@@ -188,6 +175,7 @@ export default {
:config-paths="configPaths"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
+ :show-links="showJobLinks"
:type="$options.pipelineTypeConstants.UPSTREAM"
:view-type="viewType"
@error="onError"
@@ -202,9 +190,8 @@ export default {
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
:metrics-config="metricsConfig"
- :never-show-links="shouldHideLinks"
+ :show-links="showJobLinks"
:view-type="viewType"
- default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
>
@@ -234,6 +221,7 @@ export default {
:config-paths="configPaths"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
+ :show-links="showJobLinks"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
:view-type="viewType"
@downstreamHovered="setSourceJob"
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 0bc6d883245..9329a35ba99 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -5,7 +5,9 @@ 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 DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
+import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
+import { reportToSentry, reportMessageToSentry } 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';
@@ -17,6 +19,9 @@ import {
unwrapPipelineData,
} from './utils';
+const featureName = 'pipeline_needs_hover_tip';
+const enumFeatureName = featureName.toUpperCase();
+
export default {
name: 'PipelineGraphWrapper',
components: {
@@ -44,10 +49,12 @@ export default {
data() {
return {
alertType: null,
+ callouts: [],
currentViewType: STAGE_VIEW,
pipeline: null,
pipelineLayers: null,
showAlert: false,
+ showLinks: false,
};
},
errorTexts: {
@@ -59,6 +66,18 @@ export default {
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
apollo: {
+ callouts: {
+ query: getUserCallouts,
+ update(data) {
+ return data?.currentUser?.callouts?.nodes.map((callout) => callout.featureName) || [];
+ },
+ error(err) {
+ reportToSentry(
+ this.$options.name,
+ `type: callout_load_failure, info: ${serializeLoadErrors(err)}`,
+ );
+ },
+ },
pipeline: {
context() {
return getQueryHeaders(this.graphqlResourceEtag);
@@ -90,9 +109,16 @@ export default {
},
error(err) {
this.reportFailure({ type: LOAD_FAILURE, skipSentry: true });
- reportToSentry(
+
+ reportMessageToSentry(
this.$options.name,
- `type: ${LOAD_FAILURE}, info: ${serializeLoadErrors(err)}`,
+ `| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`,
+ {
+ projectPath: this.projectPath,
+ pipelineIid: this.pipelineIid,
+ pipelineStages: this.pipeline?.stages?.length || 0,
+ nbOfDownstreams: this.pipeline?.downstream?.length || 0,
+ },
);
},
result({ error }) {
@@ -137,6 +163,13 @@ export default {
metricsPath: this.metricsPath,
};
},
+ graphViewType() {
+ /* This prevents reading view type off the localStorage value if it does not apply. */
+ return this.showGraphViewSelector ? this.currentViewType : STAGE_VIEW;
+ },
+ hoverTipPreviouslyDismissed() {
+ return this.callouts.includes(enumFeatureName);
+ },
showLoadingIcon() {
/*
Shows the icon only when the graph is empty, not when it is is
@@ -166,6 +199,18 @@ export default {
return this.pipelineLayers;
},
+ handleTipDismissal() {
+ try {
+ this.$apollo.mutate({
+ mutation: DismissPipelineGraphCallout,
+ variables: {
+ featureName,
+ },
+ });
+ } catch (err) {
+ reportToSentry(this.$options.name, `type: callout_dismiss_failure, info: ${err}`);
+ }
+ },
hideAlert() {
this.showAlert = false;
this.alertType = null;
@@ -182,6 +227,9 @@ export default {
}
},
/* eslint-enable @gitlab/require-i18n-strings */
+ updateShowLinksState(val) {
+ this.showLinks = val;
+ },
updateViewType(type) {
this.currentViewType = type;
},
@@ -201,8 +249,12 @@ export default {
>
<graph-view-selector
v-if="showGraphViewSelector"
- :type="currentViewType"
+ :type="graphViewType"
+ :show-links="showLinks"
+ :tip-previously-dismissed="hoverTipPreviouslyDismissed"
+ @dismissHoverTip="handleTipDismissal"
@updateViewType="updateViewType"
+ @updateShowLinksState="updateShowLinksState"
/>
</local-storage-sync>
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
@@ -211,7 +263,8 @@ export default {
:config-paths="configPaths"
:pipeline="pipeline"
:pipeline-layers="getPipelineLayers()"
- :view-type="currentViewType"
+ :show-links="showLinks"
+ :view-type="graphViewType"
@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
index f33e6290e37..1435276edd3 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -1,17 +1,25 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlSegmentedControl, GlToggle } from '@gitlab/ui';
import { __ } from '~/locale';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
components: {
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlSprintf,
+ GlAlert,
+ GlLoadingIcon,
+ GlSegmentedControl,
+ GlToggle,
},
props: {
+ showLinks: {
+ type: Boolean,
+ required: true,
+ },
+ tipPreviouslyDismissed: {
+ type: Boolean,
+ required: true,
+ },
type: {
type: String,
required: true,
@@ -19,67 +27,138 @@ export default {
},
data() {
return {
- currentViewType: STAGE_VIEW,
+ hoverTipDismissed: false,
+ isToggleLoading: false,
+ isSwitcherLoading: false,
+ segmentSelectedType: this.type,
+ showLinksActive: false,
};
},
i18n: {
- labelText: __('Order jobs by'),
+ hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'),
+ linksLabelText: __('Show dependencies'),
+ viewLabelText: __('Group 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'),
+ primary: __('Job dependencies'),
},
},
},
computed: {
- currentDropdownText() {
- return this.$options.views[this.type].text.primary;
+ showLinksToggle() {
+ return this.segmentSelectedType === LAYER_VIEW;
+ },
+ showTip() {
+ return (
+ this.showLinks &&
+ this.showLinksActive &&
+ !this.tipPreviouslyDismissed &&
+ !this.hoverTipDismissed
+ );
+ },
+ viewTypesList() {
+ return Object.keys(this.$options.views).map((key) => {
+ return {
+ value: key,
+ text: this.$options.views[key].text.primary,
+ };
+ });
+ },
+ },
+ watch: {
+ /*
+ How does this reset the loading? As we note in the methods comment below,
+ the loader is set to on before the update work is undertaken (in the parent).
+ Once the work is complete, one of these values will change, since that's the
+ point of the work. When that happens, the related value will update and we are done.
+
+ The bonus for this approach is that it works the same whichever "direction"
+ the work goes in.
+ */
+ showLinks() {
+ this.isToggleLoading = false;
+ },
+ type() {
+ this.isSwitcherLoading = false;
},
},
methods: {
- itemClick(type) {
- this.$emit('updateViewType', type);
+ dismissTip() {
+ this.hoverTipDismissed = true;
+ this.$emit('dismissHoverTip');
+ },
+ /*
+ In both toggle methods, we use setTimeout so that the loading indicator displays,
+ then the work is done to update the DOM. The process is:
+ → user clicks
+ → call stack: set loading to true
+ → render: the loading icon appears on the screen
+ → callback queue: now do the work to calculate the new view / links
+ (note: this work is done in the parent after the event is emitted)
+
+ setTimeout is how we move the work to the callback queue.
+ We can't use nextTick because that is called before the render loop.
+
+ See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details.
+ */
+ toggleView(type) {
+ this.isSwitcherLoading = true;
+ setTimeout(() => {
+ this.$emit('updateViewType', type);
+ });
+ },
+ toggleShowLinksActive(val) {
+ this.isToggleLoading = true;
+ setTimeout(() => {
+ this.$emit('updateShowLinksState', val);
+ });
},
},
};
</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>
+ <div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4">
+ <gl-loading-icon
+ v-if="isSwitcherLoading"
+ data-testid="switcher-loading-state"
+ class="gl-absolute gl-w-full gl-bg-white gl-opacity-5 gl-z-index-2"
+ size="lg"
+ />
+ <span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span>
+ <gl-segmented-control
+ v-model="segmentSelectedType"
+ :options="viewTypesList"
+ :disabled="isSwitcherLoading"
+ data-testid="pipeline-view-selector"
+ class="gl-mx-4"
+ @input="toggleView"
+ />
+
+ <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
+ <gl-toggle
+ v-model="showLinksActive"
+ data-testid="show-links-toggle"
+ class="gl-mx-4"
+ :label="$options.i18n.linksLabelText"
+ :is-loading="isToggleLoading"
+ label-position="left"
+ @change="toggleShowLinksActive"
+ />
+ </div>
+ </div>
+ <gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip">
+ {{ $options.i18n.hoverTipText }}
+ </gl-alert>
</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 6451605a222..b2a3f27e079 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -53,6 +53,7 @@ export default {
};
</script>
<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button
type="button"
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 7f772e35e55..45113ecff41 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -3,7 +3,7 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu
import { LOAD_FAILURE } from '../../constants';
import { reportToSentry } from '../../utils';
import { listByLayers } from '../parsing_utils';
-import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants';
+import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
import {
getQueryHeaders,
@@ -32,6 +32,10 @@ export default {
type: Array,
required: true,
},
+ showLinks: {
+ type: Boolean,
+ required: true,
+ },
type: {
type: String,
required: true,
@@ -76,6 +80,9 @@ export default {
graphPosition() {
return this.isUpstream ? 'left' : 'right';
},
+ graphViewType() {
+ return this.currentPipeline?.usesNeeds ? this.viewType : STAGE_VIEW;
+ },
isUpstream() {
return this.type === UPSTREAM;
},
@@ -217,8 +224,9 @@ export default {
:config-paths="configPaths"
:pipeline="currentPipeline"
:pipeline-layers="getPipelineLayers(pipeline.id)"
+ :show-links="showLinks"
:is-linked-pipeline="true"
- :view-type="viewType"
+ :view-type="graphViewType"
/>
</div>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index fa2f381c8a4..81d59f1ef65 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -160,7 +160,10 @@ export default {
:pipeline-id="pipelineId"
:stage-name="showStageName ? group.stageName : ''"
css-class-job-name="gl-build-content"
- :class="{ 'gl-opacity-3': isFadedOut(group.name) }"
+ :class="[
+ { 'gl-opacity-3': isFadedOut(group.name) },
+ 'gl-transition-duration-slow gl-transition-timing-function-ease',
+ ]"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
<div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index 373aa6bf9a1..163b3898c28 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import Visibility from 'visibilityjs';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils';
@@ -39,15 +40,15 @@ const serializeGqlErr = (gqlError) => {
const serializeLoadErrors = (errors) => {
const { gqlError, graphQLErrors, networkError, message } = errors;
- if (graphQLErrors) {
+ if (!isEmpty(graphQLErrors)) {
return graphQLErrors.map((err) => serializeGqlErr(err)).join('; ');
}
- if (gqlError) {
+ if (!isEmpty(gqlError)) {
return serializeGqlErr(gqlError);
}
- if (networkError) {
+ if (!isEmpty(networkError)) {
return `Network error: ${networkError.message}`;
}
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js
index 49cd04d11e9..0fe7d9ffda3 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/api.js
+++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js
@@ -2,6 +2,11 @@ import axios from '~/lib/utils/axios_utils';
import { reportToSentry } from '../../utils';
export const reportPerformance = (path, stats) => {
+ // FIXME: https://gitlab.com/gitlab-org/gitlab/-/issues/330245
+ if (!path) {
+ return;
+ }
+
axios.post(path, stats).catch((err) => {
reportToSentry('links_inner_perf', `error: ${err}`);
});
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js
index 202498fb188..7c306683305 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js
+++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js
@@ -15,6 +15,7 @@ export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobNam
export const generateLinksData = ({ links }, containerID, modifier = '') => {
const containerEl = document.getElementById(containerID);
+
return links.map((link) => {
const path = d3.path();
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 0ed5b8a5f09..5c775df7b48 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
@@ -1,19 +1,8 @@
<script>
import { isEmpty } from 'lodash';
-import {
- PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
- PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
- PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
- PIPELINES_DETAIL_LINK_DURATION,
- PIPELINES_DETAIL_LINKS_TOTAL,
- PIPELINES_DETAIL_LINKS_JOB_RATIO,
-} from '~/performance/constants';
-import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants';
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';
export default {
@@ -28,6 +17,10 @@ export default {
type: Object,
required: true,
},
+ parsedData: {
+ type: Object,
+ required: true,
+ },
pipelineId: {
type: Number,
required: true,
@@ -36,15 +29,6 @@ export default {
type: Array,
required: true,
},
- totalGroups: {
- type: Number,
- required: true,
- },
- metricsConfig: {
- type: Object,
- required: false,
- default: () => ({}),
- },
defaultLinkColor: {
type: String,
required: false,
@@ -65,13 +49,9 @@ export default {
return {
links: [],
needsObject: null,
- parsedData: {},
};
},
computed: {
- shouldCollectMetrics() {
- return this.metricsConfig.collectMetrics && this.metricsConfig.path;
- },
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
@@ -115,13 +95,16 @@ export default {
highlightedJobs(jobs) {
this.$emit('highlightedJobsChange', jobs);
},
+ parsedData() {
+ this.calculateLinkData();
+ },
viewType() {
/*
We need to wait a tick so that the layout reflows
before the links refresh.
*/
this.$nextTick(() => {
- this.refreshLinks();
+ this.calculateLinkData();
});
},
},
@@ -129,69 +112,21 @@ export default {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
mounted() {
- if (!isEmpty(this.pipelineData)) {
- this.prepareLinkData();
+ if (!isEmpty(this.parsedData)) {
+ this.calculateLinkData();
}
},
methods: {
- beginPerfMeasure() {
- if (this.shouldCollectMetrics) {
- performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START });
- }
- },
- finishPerfMeasureAndSend() {
- if (this.shouldCollectMetrics) {
- performanceMarkAndMeasure({
- mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END,
- measures: [
- {
- name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
- start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START,
- },
- ],
- });
- }
-
- window.requestAnimationFrame(() => {
- const duration = window.performance.getEntriesByName(
- PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION,
- )[0]?.duration;
-
- if (!duration) {
- return;
- }
-
- const data = {
- histograms: [
- { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 },
- { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length },
- {
- name: PIPELINES_DETAIL_LINKS_JOB_RATIO,
- value: this.links.length / this.totalGroups,
- },
- ],
- };
-
- reportPerformance(this.metricsConfig.path, data);
- });
- },
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
},
- prepareLinkData() {
- this.beginPerfMeasure();
+ calculateLinkData() {
try {
- const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
- this.parsedData = parseData(arrayOfJobs);
- this.refreshLinks();
+ this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`);
} 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 [
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 8dbab245f44..81409752621 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
@@ -1,5 +1,4 @@
<script>
-import { GlAlert } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __ } from '~/locale';
import {
@@ -19,10 +18,8 @@ import LinksInner from './links_inner.vue';
export default {
name: 'LinksLayer',
components: {
- GlAlert,
LinksInner,
},
- MAX_GROUPS: 200,
props: {
containerMeasurements: {
type: Object,
@@ -37,15 +34,16 @@ export default {
required: false,
default: () => ({}),
},
- neverShowLinks: {
+ showLinks: {
type: Boolean,
required: false,
- default: false,
+ default: true,
},
},
data() {
return {
alertDismissed: false,
+ parsedData: {},
showLinksOverride: false,
};
},
@@ -67,43 +65,15 @@ export default {
shouldCollectMetrics() {
return this.metricsConfig.collectMetrics && this.metricsConfig.path;
},
- showAlert() {
- /*
- This is a hard override that allows us to turn off the links without
- needing to remove the component entirely for iteration or based on graph type.
- */
- if (this.neverShowLinks) {
- return false;
- }
-
- return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed;
- },
showLinkedLayers() {
- /*
- This is a hard override that allows us to turn off the links without
- needing to remove the component entirely for iteration or based on graph type.
- */
- if (this.neverShowLinks) {
- return false;
- }
-
- return (
- !this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS)
- );
+ return this.showLinks && !this.containerZero;
},
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
mounted() {
- /*
- This is code to get metrics for the graph (to observe links performance).
- It is currently here because we want values for links without drawing them.
- It can be removed when https://gitlab.com/gitlab-org/gitlab/-/issues/298930
- is closed and functionality is enabled by default.
- */
-
- if (this.neverShowLinks && !isEmpty(this.pipelineData)) {
+ if (!isEmpty(this.pipelineData)) {
window.requestAnimationFrame(() => {
this.prepareLinkData();
});
@@ -151,19 +121,13 @@ export default {
reportPerformance(this.metricsConfig.path, data);
});
},
- dismissAlert() {
- this.alertDismissed = true;
- },
- overrideShowLinks() {
- this.dismissAlert();
- this.showLinksOverride = true;
- },
prepareLinkData() {
this.beginPerfMeasure();
let numLinks;
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
- numLinks = parseData(arrayOfJobs).links.length;
+ this.parsedData = parseData(arrayOfJobs);
+ numLinks = this.parsedData.links.length;
} catch (err) {
reportToSentry(this.$options.name, err);
}
@@ -176,24 +140,15 @@ export default {
<links-inner
v-if="showLinkedLayers"
:container-measurements="containerMeasurements"
+ :parsed-data="parsedData"
:pipeline-data="pipelineData"
:total-groups="numGroups"
- :metrics-config="metricsConfig"
v-bind="$attrs"
v-on="$listeners"
>
<slot></slot>
</links-inner>
<div v-else>
- <gl-alert
- v-if="showAlert"
- class="gl-ml-4 gl-mb-4"
- :primary-button-text="$options.i18n.showLinksAnyways"
- @primaryAction="overrideShowLinks"
- @dismiss="dismissAlert"
- >
- {{ $options.i18n.tooManyJobs }}
- </gl-alert>
<div class="gl-display-flex gl-relative">
<slot></slot>
</div>
diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
index 6982586ab12..6dff3828a34 100644
--- a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
+++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
@@ -2,7 +2,7 @@
import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
+import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
const featureName = 'pipeline_needs_banner';
@@ -55,7 +55,7 @@ export default {
this.dismissedAlert = true;
try {
this.$apollo.mutate({
- mutation: DismissPipelineNotification,
+ mutation: DismissPipelineGraphCallout,
variables: {
featureName,
},
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index f5ab869633b..9d886e0e379 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -1,4 +1,4 @@
-import { uniqWith, isEqual } from 'lodash';
+import { isEqual, memoize, uniqWith } from 'lodash';
import { createSankey } from './dag/drawing_utils';
/*
@@ -170,3 +170,26 @@ export const listByLayers = ({ stages }) => {
return acc;
}, []);
};
+
+export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => {
+ return 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 } = stagesLookup[id];
+ return stages[stageIdx]?.groups?.[groupIdx];
+ });
+
+ return {
+ name: '',
+ id: `layer-${idx}`,
+ status: { action: null },
+ groups: groups.filter(Boolean),
+ };
+ });
+};
+
+export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare);
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 c3bcfcb18fb..e9773f055a7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,6 +1,8 @@
<script>
-import { GlEmptyState } from '@gitlab/ui';
-import Experiment from '~/experimentation/components/experiment.vue';
+import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { startCodeQualityWalkthrough, track } from '~/code_quality_walkthrough/utils';
+import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
+import { getExperimentData } from '~/experimentation/utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import PipelinesCiTemplates from './pipelines_ci_templates.vue';
@@ -12,12 +14,18 @@ export default {
test, and deploy your code. Let GitLab take care of time
consuming tasks, so you can spend more time creating.`),
btnText: s__('Pipelines|Get started with CI/CD'),
+ codeQualityTitle: s__('Pipelines|Improve code quality with GitLab CI/CD'),
+ codeQualityDescription: s__(`Pipelines|To keep your codebase simple,
+ readable, and accessible to contributors, use GitLab CI/CD
+ to analyze your code quality with every push to your project.`),
+ codeQualityBtnText: s__('Pipelines|Add a code quality job'),
noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'),
},
name: 'PipelinesEmptyState',
components: {
GlEmptyState,
- Experiment,
+ GlButton,
+ GitlabExperiment,
PipelinesCiTemplates,
},
props: {
@@ -29,36 +37,82 @@ export default {
type: Boolean,
required: true,
},
+ codeQualityPagePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
ciHelpPagePath() {
return helpPagePath('ci/quick_start/index.md');
},
+ isPipelineEmptyStateTemplatesExperimentActive() {
+ return this.canSetCi && Boolean(getExperimentData('pipeline_empty_state_templates'));
+ },
+ },
+ mounted() {
+ startCodeQualityWalkthrough();
+ },
+ methods: {
+ trackClick() {
+ track('cta_clicked');
+ },
},
};
</script>
<template>
<div>
- <experiment name="pipeline_empty_state_templates">
+ <gitlab-experiment
+ v-if="isPipelineEmptyStateTemplatesExperimentActive"
+ 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"
/>
+ </template>
+ <template #candidate>
+ <pipelines-ci-templates />
+ </template>
+ </gitlab-experiment>
+ <gitlab-experiment v-else-if="canSetCi" name="code_quality_walkthrough">
+ <template #control>
<gl-empty-state
- v-else
- title=""
+ :title="$options.i18n.title"
:svg-path="emptyStateSvgPath"
- :description="$options.i18n.noCiDescription"
- />
+ :description="$options.i18n.description"
+ >
+ <template #actions>
+ <gl-button :href="ciHelpPagePath" variant="confirm" @click="trackClick()">
+ {{ $options.i18n.btnText }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
</template>
<template #candidate>
- <pipelines-ci-templates />
+ <gl-empty-state
+ :title="$options.i18n.codeQualityTitle"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.codeQualityDescription"
+ >
+ <template #actions>
+ <gl-button :href="codeQualityPagePath" variant="confirm" @click="trackClick()">
+ {{ $options.i18n.codeQualityBtnText }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
</template>
- </experiment>
+ </gitlab-experiment>
+ <gl-empty-state
+ v-else
+ title=""
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.noCiDescription"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
new file mode 100644
index 00000000000..d7bd2d731b1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -0,0 +1,115 @@
+<script>
+import {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, s__ } from '~/locale';
+
+export const i18n = {
+ artifacts: __('Artifacts'),
+ downloadArtifact: __('Download %{name} artifact'),
+ artifactSectionHeader: __('Download artifacts'),
+ artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
+};
+
+export default {
+ i18n,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSprintf,
+ },
+ inject: {
+ artifactsEndpoint: {
+ default: '',
+ },
+ artifactsEndpointPlaceholder: {
+ default: '',
+ },
+ },
+ props: {
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ artifacts: [],
+ hasError: false,
+ isLoading: false,
+ };
+ },
+ methods: {
+ fetchArtifacts() {
+ this.isLoading = true;
+ // Replace the placeholder with the ID of the pipeline we are viewing
+ const endpoint = this.artifactsEndpoint.replace(
+ this.artifactsEndpointPlaceholder,
+ this.pipelineId,
+ );
+ return axios
+ .get(endpoint)
+ .then(({ data }) => {
+ this.artifacts = data.artifacts;
+ })
+ .catch(() => {
+ this.hasError = true;
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ v-gl-tooltip
+ :title="$options.i18n.artifacts"
+ :text="$options.i18n.artifacts"
+ :aria-label="$options.i18n.artifacts"
+ icon="ellipsis_v"
+ data-testid="pipeline-multi-actions-dropdown"
+ right
+ lazy
+ text-sr-only
+ no-caret
+ @show.once="fetchArtifacts"
+ >
+ <gl-dropdown-section-header>{{
+ $options.i18n.artifactSectionHeader
+ }}</gl-dropdown-section-header>
+
+ <gl-alert v-if="hasError" variant="danger" :dismissible="false">
+ {{ $options.i18n.artifactsFetchErrorMessage }}
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" />
+
+ <gl-dropdown-item
+ v-for="(artifact, i) in artifacts"
+ :key="i"
+ :href="artifact.path"
+ rel="nofollow"
+ download
+ data-testid="artifact-item"
+ >
+ <gl-sprintf :message="$options.i18n.downloadArtifact">
+ <template #name>{{ artifact.name }}</template>
+ </gl-sprintf>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index 81eeead2171..85ee44f427d 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -2,7 +2,7 @@
import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import eventHub from '../../event_hub';
-import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
+import PipelineMultiActions from './pipeline_multi_actions.vue';
import PipelinesManualActions from './pipelines_manual_actions.vue';
export default {
@@ -16,8 +16,8 @@ export default {
},
components: {
GlButton,
+ PipelineMultiActions,
PipelinesManualActions,
- PipelinesArtifactsComponent,
},
props: {
pipeline: {
@@ -36,14 +36,6 @@ export default {
};
},
computed: {
- displayPipelineActions() {
- return (
- this.pipeline.flags.retryable ||
- this.pipeline.flags.cancelable ||
- this.pipeline.details.manual_actions.length ||
- this.pipeline.details.artifacts.length
- );
- },
actions() {
if (!this.pipeline || !this.pipeline.details) {
return [];
@@ -76,15 +68,10 @@ export default {
</script>
<template>
- <div v-if="displayPipelineActions" class="gl-text-right">
+ <div class="gl-text-right">
<div class="btn-group">
<pipelines-manual-actions 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
@@ -114,6 +101,8 @@ export default {
class="js-pipelines-cancel-button"
@click="handleCancelClick"
/>
+
+ <pipeline-multi-actions :pipeline-id="pipeline.id" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index f14a582d731..0218cb2e1b8 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -94,6 +94,11 @@ export default {
type: Object,
required: true,
},
+ codeQualityPagePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -331,6 +336,7 @@ export default {
v-else-if="stateToRender === $options.stateMap.emptyState"
:empty-state-svg-path="emptyStateSvgPath"
:can-set-ci="canCreatePipeline"
+ :code-quality-page-path="codeQualityPagePath"
/>
<gl-empty-state
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
index 9c3990f82df..147fff52101 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue
@@ -1,40 +1,107 @@
<script>
-import { GlDropdown, GlDropdownItem, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
+import {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __, s__ } from '~/locale';
+
+export const i18n = {
+ artifacts: __('Artifacts'),
+ downloadArtifact: __('Download %{name} artifact'),
+ artifactSectionHeader: __('Download artifacts'),
+ artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
+ noArtifacts: s__('Pipelines|No artifacts available'),
+};
export default {
+ i18n,
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
+ GlAlert,
GlDropdown,
GlDropdownItem,
+ GlLoadingIcon,
GlSprintf,
},
- translations: {
- artifacts: __('Artifacts'),
- downloadArtifact: __('Download %{name} artifact'),
+ inject: {
+ artifactsEndpoint: {
+ default: '',
+ },
+ artifactsEndpointPlaceholder: {
+ default: '',
+ },
},
props: {
- artifacts: {
- type: Array,
+ pipelineId: {
+ type: Number,
required: true,
},
},
+ data() {
+ return {
+ artifacts: [],
+ hasError: false,
+ isLoading: false,
+ };
+ },
+ computed: {
+ hasArtifacts() {
+ return Boolean(this.artifacts.length);
+ },
+ },
+ methods: {
+ fetchArtifacts() {
+ this.isLoading = true;
+ // Replace the placeholder with the ID of the pipeline we are viewing
+ const endpoint = this.artifactsEndpoint.replace(
+ this.artifactsEndpointPlaceholder,
+ this.pipelineId,
+ );
+ return axios
+ .get(endpoint)
+ .then(({ data }) => {
+ this.artifacts = data.artifacts;
+ })
+ .catch(() => {
+ this.hasError = true;
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ },
};
</script>
<template>
<gl-dropdown
v-gl-tooltip
class="build-artifacts js-pipeline-dropdown-download"
- :title="$options.translations.artifacts"
- :text="$options.translations.artifacts"
- :aria-label="$options.translations.artifacts"
+ :title="$options.i18n.artifacts"
+ :text="$options.i18n.artifacts"
+ :aria-label="$options.i18n.artifacts"
icon="download"
right
lazy
text-sr-only
+ @show.once="fetchArtifacts"
>
+ <gl-alert v-if="hasError" variant="danger" :dismissible="false">
+ {{ $options.i18n.artifactsFetchErrorMessage }}
+ </gl-alert>
+
+ <gl-loading-icon v-if="isLoading" />
+
+ <gl-alert v-else-if="!hasArtifacts" variant="info" :dismissible="false">
+ {{ $options.i18n.noArtifacts }}
+ </gl-alert>
+
<gl-dropdown-item
v-for="(artifact, i) in artifacts"
:key="i"
@@ -42,7 +109,7 @@ export default {
rel="nofollow"
download
>
- <gl-sprintf :message="$options.translations.downloadArtifact">
+ <gl-sprintf :message="$options.i18n.downloadArtifact">
<template #name>{{ artifact.name }}</template>
</gl-sprintf>
</gl-dropdown-item>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
index 492c562ec5c..de3f783ac84 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
@@ -1,7 +1,8 @@
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { map } from 'lodash';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import PipelineStatusToken from './tokens/pipeline_status_token.vue';
import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue';
@@ -43,7 +44,7 @@ export default {
title: s__('Pipeline|Trigger author'),
unique: true,
token: PipelineTriggerAuthorToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
projectId: this.projectId,
},
{
@@ -52,7 +53,7 @@ export default {
title: s__('Pipeline|Branch name'),
unique: true,
token: PipelineBranchNameToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
projectId: this.projectId,
disabled: this.selectedTypes.includes(this.$options.tagType),
},
@@ -62,7 +63,7 @@ export default {
title: s__('Pipeline|Tag name'),
unique: true,
token: PipelineTagNameToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
projectId: this.projectId,
disabled: this.selectedTypes.includes(this.$options.branchType),
},
@@ -72,7 +73,7 @@ export default {
title: s__('Pipeline|Status'),
unique: true,
token: PipelineStatusToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
},
];
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
index cc3c8d522b3..f56457a4162 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -1,9 +1,12 @@
<script>
+import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue';
+import { PIPELINE_STATUSES } from '~/code_quality_walkthrough/constants';
import { CHILD_VIEW } from '~/pipelines/constants';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
export default {
components: {
+ CodeQualityWalkthrough,
CiBadge,
},
props: {
@@ -23,15 +26,37 @@ export default {
isChildView() {
return this.viewType === CHILD_VIEW;
},
+ shouldRenderCodeQualityWalkthrough() {
+ return Object.values(PIPELINE_STATUSES).includes(this.pipelineStatus.group);
+ },
+ codeQualityStep() {
+ const prefix = [PIPELINE_STATUSES.successWithWarnings, PIPELINE_STATUSES.failed].includes(
+ this.pipelineStatus.group,
+ )
+ ? 'failed'
+ : this.pipelineStatus.group;
+ return `${prefix}_pipeline`;
+ },
+ codeQualityBuildPath() {
+ return this.pipeline?.details?.code_quality_build_path;
+ },
},
};
</script>
<template>
- <ci-badge
- :status="pipelineStatus"
- :show-text="!isChildView"
- :icon-classes="'gl-vertical-align-middle!'"
- data-qa-selector="pipeline_commit_status"
- />
+ <div>
+ <ci-badge
+ id="js-code-quality-walkthrough"
+ :status="pipelineStatus"
+ :show-text="!isChildView"
+ :icon-classes="'gl-vertical-align-middle!'"
+ data-qa-selector="pipeline_commit_status"
+ />
+ <code-quality-walkthrough
+ v-if="shouldRenderCodeQualityWalkthrough"
+ :step="codeQualityStep"
+ :link="codeQualityBuildPath"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue
new file mode 100644
index 00000000000..e9f7874d3e4
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/test_reports/empty_state.vue
@@ -0,0 +1,60 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+export const i18n = {
+ noTestsButton: s__('TestReports|Learn more about pipeline test reports'),
+ noTestsDescription: s__('TestReports|No test cases were found in the test report.'),
+ noTestsTitle: s__('TestReports|There are no tests to display'),
+ noReportsButton: s__('TestReports|Learn how to upload pipeline test reports'),
+ noReportsDescription: s__(
+ 'TestReports|You can configure your job to use unit test reports, and GitLab displays a report here and in the related merge request.',
+ ),
+ noReportsTitle: s__('TestReports|There are no test reports for this pipeline'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlEmptyState,
+ },
+ inject: {
+ emptyStateImagePath: {
+ default: '',
+ },
+ hasTestReport: {
+ default: false,
+ },
+ },
+ computed: {
+ emptyStateText() {
+ if (this.hasTestReport) {
+ return {
+ button: this.$options.i18n.noTestsButton,
+ description: this.$options.i18n.noTestsDescription,
+ title: this.$options.i18n.noTestsTitle,
+ };
+ }
+ return {
+ button: this.$options.i18n.noReportsButton,
+ description: this.$options.i18n.noReportsDescription,
+ title: this.$options.i18n.noReportsTitle,
+ };
+ },
+ testReportDocPath() {
+ return helpPagePath('ci/unit_test_reports');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="emptyStateText.title"
+ :description="emptyStateText.description"
+ :svg-path="emptyStateImagePath"
+ :primary-button-link="testReportDocPath"
+ :primary-button-text="emptyStateText.button"
+ />
+</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
index 2edc84e62cb..47e5bb0bde8 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue
@@ -1,6 +1,6 @@
<script>
-import { GlBadge, GlModal } from '@gitlab/ui';
-import { __, n__, sprintf } from '~/locale';
+import { GlBadge, GlFriendlyWrap, GlLink, GlModal } from '@gitlab/ui';
+import { __, n__, s__, sprintf } from '~/locale';
import CodeBlock from '~/vue_shared/components/code_block.vue';
export default {
@@ -8,6 +8,8 @@ export default {
components: {
CodeBlock,
GlBadge,
+ GlFriendlyWrap,
+ GlLink,
GlModal,
},
props: {
@@ -50,6 +52,7 @@ export default {
duration: __('Execution time'),
history: __('History'),
trace: __('System output'),
+ attachment: s__('TestReports|Attachment'),
},
modalCloseButton: {
text: __('Close'),
@@ -85,6 +88,18 @@ export default {
</div>
</div>
+ <div v-if="testCase.attachment_url" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3">
+ <strong class="gl-text-right col-sm-3">{{ $options.text.attachment }}</strong>
+ <gl-link
+ class="col-sm-9"
+ :href="testCase.attachment_url"
+ target="_blank"
+ data-testid="test-case-attachment-url"
+ >
+ <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.attachment_url" />
+ </gl-link>
+ </div>
+
<div
v-if="testCase.system_output"
class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 58d60e2a185..58d072b0005 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
import TestSummary from './test_summary.vue';
import TestSummaryTable from './test_summary_table.vue';
@@ -8,6 +9,7 @@ import TestSummaryTable from './test_summary_table.vue';
export default {
name: 'TestReports',
components: {
+ EmptyState,
GlLoadingIcon,
TestSuiteTable,
TestSummary,
@@ -83,11 +85,5 @@ export default {
</transition>
</div>
- <div v-else>
- <div class="row gl-mt-3">
- <div class="col-12">
- <p data-testid="no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p>
- </div>
- </div>
- </div>
+ <empty-state v-else />
</template>
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
index e4fd55a28be..e8af1db9592 100644
--- a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
+++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
@@ -1,4 +1,4 @@
-mutation DismissPipelineNotification($featureName: String!) {
+mutation DismissPipelineGraphCallout($featureName: String!) {
userCalloutCreate(input: { featureName: $featureName }) {
errors
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index a2bc049c3c7..911f40f4db3 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import { deprecatedCreateFlash as Flash } from '~/flash';
+import { parseBoolean } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import Translate from '~/vue_shared/translate';
import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue';
@@ -63,7 +64,8 @@ const createLegacyPipelinesDetailApp = (mediator) => {
const createTestDetails = () => {
const el = document.querySelector(SELECTORS.PIPELINE_TESTS);
- const { blobPath, summaryEndpoint, suiteEndpoint } = el?.dataset || {};
+ const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } =
+ el?.dataset || {};
const testReportsStore = createTestReportsStore({
blobPath,
summaryEndpoint,
@@ -76,6 +78,10 @@ const createTestDetails = () => {
components: {
TestReports,
},
+ provide: {
+ emptyStateImagePath,
+ hasTestReport: parseBoolean(hasTestReport),
+ },
store: testReportsStore,
render(createElement) {
return createElement('test-reports');
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 9ed4365ad75..c892311782c 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -22,6 +22,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
const {
endpoint,
+ artifactsEndpoint,
+ artifactsEndpointPlaceholder,
pipelineScheduleUrl,
emptyStateSvgPath,
errorStateSvgPath,
@@ -35,12 +37,15 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
resetCachePath,
projectId,
params,
+ codeQualityPagePath,
} = el.dataset;
return new Vue({
el,
provide: {
addCiYmlPath,
+ artifactsEndpoint,
+ artifactsEndpointPlaceholder,
suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
},
data() {
@@ -70,6 +75,7 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
resetCachePath,
projectId,
params: JSON.parse(params),
+ codeQualityPagePath,
},
});
},
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 0a6c326fa3d..800a363cada 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -73,3 +73,12 @@ export const reportToSentry = (component, failureType) => {
Sentry.captureException(failureType);
});
};
+
+export const reportMessageToSentry = (component, message, context) => {
+ Sentry.withScope((scope) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ scope.setContext('Vue data', context);
+ scope.setTag('component', component);
+ Sentry.captureMessage(message);
+ });
+};
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index e1e04d63576..7222c2bd908 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -62,6 +62,7 @@ const projectSelect = () => {
with_shared: this.withShared,
include_subgroups: this.includeProjectsInSubgroups,
order_by: 'similarity',
+ simple: true,
},
projectsCallback,
);
diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
index d96d1035ed0..0fd31381ba6 100644
--- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
+++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue
@@ -65,6 +65,7 @@ export default {
<gl-dropdown-item
v-if="canRevert"
data-testid="revert-link"
+ data-qa-selector="revert_button"
@click="showModal($options.openRevertModal)"
>
{{ s__('ChangeTypeAction|Revert') }}
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index d2fb524489e..f7cfc82db11 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
+import { joinPaths } from '~/lib/utils/url_utility';
import RevisionCard from './revision_card.vue';
export default {
@@ -36,11 +37,46 @@ export default {
type: String,
required: true,
},
+ defaultProject: {
+ type: Object,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ from: {
+ projects: this.projects,
+ selectedProject: this.defaultProject,
+ revision: this.paramsFrom,
+ refsProjectPath: this.refsProjectPath,
+ },
+ to: {
+ selectedProject: this.defaultProject,
+ revision: this.paramsTo,
+ refsProjectPath: this.refsProjectPath,
+ },
+ };
},
methods: {
onSubmit() {
this.$refs.form.submit();
},
+ onSelectProject({ direction, project }) {
+ const refsPath = joinPaths(gon.relative_url_root || '', `/${project.name}`, '/refs');
+ // direction is either 'from' or 'to'
+ this[direction].refsProjectPath = refsPath;
+ this[direction].selectedProject = project;
+ },
+ onSelectRevision({ direction, revision }) {
+ this[direction].revision = revision; // direction is either 'from' or 'to'
+ },
+ onSwapRevision() {
+ [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to'
+ },
},
};
</script>
@@ -57,10 +93,15 @@ export default {
class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards"
>
<revision-card
- :refs-project-path="refsProjectPath"
+ data-testid="sourceRevisionCard"
+ :refs-project-path="to.refsProjectPath"
revision-text="Source"
params-name="to"
- :params-branch="paramsTo"
+ :params-branch="to.revision"
+ :projects="to.projects"
+ :selected-project="to.selectedProject"
+ @selectProject="onSelectProject"
+ @selectRevision="onSelectRevision"
/>
<div
class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0"
@@ -69,16 +110,24 @@ export default {
...
</div>
<revision-card
- :refs-project-path="refsProjectPath"
+ data-testid="targetRevisionCard"
+ :refs-project-path="from.refsProjectPath"
revision-text="Target"
params-name="from"
- :params-branch="paramsFrom"
+ :params-branch="from.revision"
+ :projects="from.projects"
+ :selected-project="from.selectedProject"
+ @selectProject="onSelectProject"
+ @selectRevision="onSelectRevision"
/>
</div>
<div class="gl-mt-4">
<gl-button category="primary" variant="success" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }}
</gl-button>
+ <gl-button data-testid="swapRevisionsButton" class="btn btn-default" @click="onSwapRevision">
+ {{ s__('CompareRevisions|Swap revisions') }}
+ </gl-button>
<gl-button
v-if="projectMergeRequestPath"
:href="projectMergeRequestPath"
diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
index cb9d8b64b33..ba1e00a2b36 100644
--- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue
@@ -1,57 +1,51 @@
<script>
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
-const SOURCE_PARAM_NAME = 'to';
-
export default {
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
},
- inject: ['projectTo', 'projectsFrom'],
props: {
paramsName: {
type: String,
required: true,
},
+ projects: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ selectedProject: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
searchTerm: '',
- selectedRepo: {},
};
},
computed: {
+ disableRepoDropdown() {
+ return this.projects === null;
+ },
filteredRepos() {
const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
- return this?.projectsFrom.filter(({ name }) =>
- name.toLowerCase().includes(lowerCaseSearchTerm),
- );
- },
- isSourceRevision() {
- return this.paramsName === SOURCE_PARAM_NAME;
+ return this?.projects.filter(({ name }) => name.toLowerCase().includes(lowerCaseSearchTerm));
},
inputName() {
return `${this.paramsName}_project_id`;
},
},
- mounted() {
- this.setDefaultRepo();
- },
methods: {
- onClick(repo) {
- this.selectedRepo = repo;
- this.emitTargetProject(repo.name);
- },
- setDefaultRepo() {
- this.selectedRepo = this.projectTo;
+ onClick(project) {
+ this.emitTargetProject(project);
},
- emitTargetProject(name) {
- if (!this.isSourceRevision) {
- this.$emit('changeTargetProject', name);
- }
+ emitTargetProject(project) {
+ this.$emit('selectProject', { direction: this.paramsName, project });
},
},
};
@@ -59,23 +53,23 @@ export default {
<template>
<div>
- <input type="hidden" :name="inputName" :value="selectedRepo.id" />
+ <input type="hidden" :name="inputName" :value="selectedProject.id" />
<gl-dropdown
- :text="selectedRepo.name"
+ :text="selectedProject.name"
:header-text="s__(`CompareRevisions|Select target project`)"
class="gl-w-full gl-font-monospace gl-sm-pr-3"
toggle-class="gl-min-w-0"
- :disabled="isSourceRevision"
+ :disabled="disableRepoDropdown"
>
<template #header>
- <gl-search-box-by-type v-if="!isSourceRevision" v-model.trim="searchTerm" />
+ <gl-search-box-by-type v-if="!disableRepoDropdown" v-model.trim="searchTerm" />
</template>
- <template v-if="!isSourceRevision">
+ <template v-if="!disableRepoDropdown">
<gl-dropdown-item
v-for="repo in filteredRepos"
:key="repo.id"
is-check-item
- :is-checked="selectedRepo.id === repo.id"
+ :is-checked="selectedProject.id === repo.id"
@click="onClick(repo)"
>
{{ repo.name }}
diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue
index 15d24792310..02a329221cc 100644
--- a/app/assets/javascripts/projects/compare/components/revision_card.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_card.vue
@@ -27,17 +27,14 @@ export default {
required: false,
default: null,
},
- },
- data() {
- return {
- selectedRefsProjectPath: this.refsProjectPath,
- };
- },
- methods: {
- onChangeTargetProject(targetProjectName) {
- if (this.paramsName === 'from') {
- this.selectedRefsProjectPath = `/${targetProjectName}/refs`;
- }
+ projects: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ selectedProject: {
+ type: Object,
+ required: true,
},
},
};
@@ -52,13 +49,16 @@ export default {
<repo-dropdown
class="gl-sm-w-half"
:params-name="paramsName"
- @changeTargetProject="onChangeTargetProject"
+ :projects="projects"
+ :selected-project="selectedProject"
+ v-on="$listeners"
/>
<revision-dropdown
class="gl-sm-w-half gl-mt-3 gl-sm-mt-0"
- :refs-project-path="selectedRefsProjectPath"
+ :refs-project-path="refsProjectPath"
:params-name="paramsName"
:params-branch="paramsBranch"
+ v-on="$listeners"
/>
</div>
</gl-card>
diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
index d0b69344c12..f0b8e73e528 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue
@@ -56,6 +56,9 @@ export default {
searchTerm: debounce(function debounceSearch() {
this.searchBranchesAndTags();
}, SEARCH_DEBOUNCE_MS),
+ paramsBranch(newBranch) {
+ this.setSelectedRevision(newBranch);
+ },
},
mounted() {
this.fetchBranchesAndTags();
@@ -84,7 +87,7 @@ export default {
this.loading = true;
if (reset) {
- this.selectedRevision = this.getDefaultBranch();
+ this.setSelectedRevision(this.paramsBranch);
}
return axios
@@ -108,10 +111,14 @@ export default {
return this.paramsBranch || EMPTY_DROPDOWN_TEXT;
},
onClick(revision) {
- this.selectedRevision = revision;
+ this.setSelectedRevision(revision);
+ this.$emit('selectRevision', { direction: this.paramsName, revision });
},
onSearchEnter() {
- this.selectedRevision = this.searchTerm;
+ this.setSelectedRevision(this.searchTerm);
+ },
+ setSelectedRevision(revision) {
+ this.selectedRevision = revision || EMPTY_DROPDOWN_TEXT;
},
},
};
@@ -122,7 +129,7 @@ export default {
<input type="hidden" :name="paramsName" :value="selectedRevision" />
<gl-dropdown
class="gl-w-full gl-font-monospace"
- toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0"
+ toggle-class="form-control compare-dropdown-toggle gl-min-w-0"
:text="selectedRevision"
:header-text="s__('CompareRevisions|Select Git revision')"
:loading="loading"
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 f57a8942a77..19cf4cda2be 100644
--- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue
@@ -112,7 +112,7 @@ export default {
<input type="hidden" :name="paramsName" :value="selectedRevision" />
<gl-dropdown
class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace"
- toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!"
+ toggle-class="form-control compare-dropdown-toggle gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!"
:text="selectedRevision"
header-text="Select Git revision"
:loading="loading"
diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js
index 4ba4e308cd4..322dff773b8 100644
--- a/app/assets/javascripts/projects/compare/index.js
+++ b/app/assets/javascripts/projects/compare/index.js
@@ -22,10 +22,6 @@ export default function init() {
components: {
CompareApp,
},
- provide: {
- projectTo: JSON.parse(projectTo),
- projectsFrom: JSON.parse(projectsFrom),
- },
render(createElement) {
return createElement(CompareApp, {
props: {
@@ -35,6 +31,8 @@ export default function init() {
projectCompareIndexPath,
projectMergeRequestPath,
createMrPath,
+ defaultProject: JSON.parse(projectTo),
+ projects: JSON.parse(projectsFrom),
},
});
},
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
deleted file mode 100644
index 1060b37067e..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
+++ /dev/null
@@ -1,201 +0,0 @@
-<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';
-import importProjectIllustration from '../illustrations/import-project.svg';
-import LegacyContainer from './legacy_container.vue';
-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'),
- description: s__(
- 'ProjectsNew|Create a blank project to house your files, plan your work, and collaborate on code, among other things.',
- ),
- illustration: blankProjectIllustration,
- },
- {
- key: 'template',
- name: 'create_from_template',
- selector: '#create-from-template-pane',
- title: s__('ProjectsNew|Create from template'),
- description: s__(
- 'Create a project pre-populated with the necessary files to get you started quickly.',
- ),
- illustration: createFromTemplateIllustration,
- },
- {
- key: 'import',
- name: 'import_project',
- selector: '#import-project-pane',
- title: s__('ProjectsNew|Import project'),
- description: s__(
- 'Migrate your data from an external source like GitHub, Bitbucket, or another instance of GitLab.',
- ),
- illustration: importProjectIllustration,
- },
- {
- key: 'ci',
- name: CI_CD_PANEL,
- selector: '#ci-cd-project-pane',
- title: s__('ProjectsNew|Run CI/CD for external repository'),
- description: s__('ProjectsNew|Connect your external repository to GitLab CI/CD.'),
- illustration: ciCdProjectIllustration,
- },
-];
-
-export default {
- components: {
- GlBreadcrumb,
- GlIcon,
- WelcomePage,
- LegacyContainer,
- },
- directives: {
- SafeHtml,
- },
- props: {
- hasErrors: {
- type: Boolean,
- required: false,
- default: false,
- },
- isCiCdAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
- newProjectGuidelines: {
- type: String,
- required: false,
- default: '',
- },
- },
-
- data() {
- return {
- activeTab: null,
- };
- },
-
- 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 this.decoratedPanels;
- }
-
- return this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL);
- },
-
- activePanel() {
- return this.decoratedPanels.find((p) => p.name === this.activeTab);
- },
-
- breadcrumbs() {
- if (!this.activeTab || !this.activePanel) {
- return null;
- }
-
- return [
- { text: __('New project'), href: '#' },
- { text: this.activePanel.title, href: `#${this.activeTab}` },
- ];
- },
- },
-
- created() {
- this.handleLocationHashChange();
-
- if (this.hasErrors) {
- this.activeTab = localStorage.getItem(LAST_ACTIVE_TAB_KEY) || BLANK_PANEL;
- }
-
- window.addEventListener('hashchange', () => {
- this.handleLocationHashChange();
- this.resetProjectErrors();
- });
- this.$root.$on('clicked::link', (e) => {
- window.location = e.target.href;
- });
- },
-
- methods: {
- resetProjectErrors() {
- const errorsContainer = document.querySelector('.project-edit-errors');
- if (errorsContainer) {
- errorsContainer.innerHTML = '';
- }
- },
-
- handleLocationHashChange() {
- this.activeTab = window.location.hash.substring(1) || null;
- if (this.activeTab) {
- localStorage.setItem(LAST_ACTIVE_TAB_KEY, this.activeTab);
- }
- },
- },
-
- PANELS,
-};
-</script>
-
-<template>
- <welcome-page v-if="activeTab === null" :panels="availablePanels" />
- <div v-else class="row">
- <div class="col-lg-3">
- <div class="gl-text-white" v-html="activePanel.illustration"></div>
- <h4>{{ activePanel.title }}</h4>
- <p>{{ activePanel.description }}</p>
- <div
- v-if="newProjectGuidelines"
- id="new-project-guideline"
- v-safe-html="newProjectGuidelines"
- ></div>
- </div>
- <div class="col-lg-9">
- <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs">
- <template #separator>
- <gl-icon name="chevron-right" :size="8" />
- </template>
- </gl-breadcrumb>
- <template v-for="panel in $options.PANELS">
- <legacy-container
- v-if="activeTab === panel.name"
- :key="panel.name"
- class="gl-mt-3"
- :selector="panel.selector"
- />
- </template>
- </div>
- </div>
-</template>
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
deleted file mode 100644
index d342ce4c9c2..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue
+++ /dev/null
@@ -1,66 +0,0 @@
-<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, experiment: NEW_REPO_EXPERIMENT });
-
-export default {
- components: {
- NewProjectPushTipPopover,
- },
- mixins: [trackingMixin],
- props: {
- panels: {
- type: Array,
- required: true,
- },
- },
-};
-</script>
-<template>
- <div class="container">
- <div class="blank-state-welcome">
- <h2 class="blank-state-welcome-title gl-mt-5! gl-mb-3!">
- {{ s__('ProjectsNew|Create new project') }}
- </h2>
- <p div class="blank-state-text">&nbsp;</p>
- </div>
- <div class="row blank-state-row">
- <a
- v-for="panel in panels"
- :key="panel.name"
- :href="`#${panel.name}`"
- :data-qa-selector="`${panel.name}_link`"
- class="blank-state blank-state-link experiment-new-project-page-blank-state"
- @click="track('click_tab', { label: panel.name })"
- >
- <div class="blank-state-icon gl-text-white" v-html="panel.illustration"></div>
- <div class="blank-state-body gl-pl-4!">
- <h3 class="blank-state-title experiment-new-project-page-blank-state-title">
- {{ panel.title }}
- </h3>
- <p class="blank-state-text">
- {{ panel.description }}
- </p>
- </div>
- </a>
- </div>
- <div class="blank-state-welcome">
- <p>
- {{ __('You can also create a project from the command line.') }}
- <a
- ref="clipTip"
- href="#"
- click.prevent
- class="push-new-project-tip"
- rel="noopener noreferrer"
- >
- {{ __('Show command') }}
- </a>
- <new-project-push-tip-popover :target="() => $refs.clipTip" />
- </p>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js
deleted file mode 100644
index 402ca887cf1..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js
+++ /dev/null
@@ -1 +0,0 @@
-export const NEW_REPO_EXPERIMENT = 'new_repo';
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg
deleted file mode 100644
index f73ae70dba8..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/blank-project.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<svg width="82" height="80" viewBox="0 0 82 80" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M66.1912 8.19118H77.6176C78.2755 8.19118 78.8088 8.72448 78.8088 9.38235V69.6176C78.8088 70.2755 78.2755 70.8088 77.6176 70.8088H66.1912V8.19118Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2.38235"/>
-<path d="M22.0517 19.2723L22.0094 10.1001C22.004 8.92546 22.8555 7.92221 24.0153 7.73664L63.3613 1.44139C64.8087 1.2098 66.12 2.32794 66.12 3.79382V75.8717C66.12 77.3323 64.8177 78.449 63.3742 78.2262L24.3037 72.1952C23.1461 72.0165 22.2902 71.023 22.2848 69.8517L22.2428 60.7554" stroke="#DBDBDB" stroke-width="2.38235"/>
-<circle cx="23" cy="40" r="21" stroke="#6E49CB" stroke-width="2.38235"/>
-<circle cx="23" cy="40" r="17" fill="#6E49CB"/>
-<circle cx="23" cy="40" r="17" fill="white" fill-opacity="0.9"/>
-<path d="M22.3125 48V33.3659" stroke="#6E49CB" stroke-width="2.38235" stroke-linecap="round"/>
-<path d="M15 40.3049H30" stroke="#6E49CB" stroke-width="2.38235" stroke-linecap="round"/>
-</svg>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg
deleted file mode 100644
index 8d6cf58f196..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/ci-cd-project.svg
+++ /dev/null
@@ -1,23 +0,0 @@
-<svg width="169" height="78" viewBox="0 0 169 78" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M115.571 41.5714L147.714 41.5714C158.365 41.5714 167 32.9369 167 22.2857C167 11.6345 158.365 3 147.714 3C137.063 3 128.429 11.6345 128.429 22.2857C128.429 27.3128 130.352 31.8907 133.503 35.3235" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/>
-<path d="M115.107 41.5714H125.786C133.084 41.5714 139 47.4877 139 54.7857C139 62.0838 133.084 68 125.786 68C118.488 68 112.571 62.0838 112.571 54.7857C112.571 53.039 112.91 51.3715 113.526 49.8453" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/>
-<path d="M87.5486 37H76.3943C75.6243 37 75 36.3746 75 35.6032C75 34.8318 75.6243 34.2064 76.3943 34.2064H87.5486C88.3187 34.2064 88.9429 34.8318 88.9429 35.6032C88.9429 36.3746 88.3187 37 87.5486 37Z" fill="#FC6D26"/>
-<path d="M118.703 37H96.3943C95.6243 37 95 36.3746 95 35.6032C95 34.8318 95.6243 34.2064 96.3943 34.2064H118.703C119.473 34.2064 120.097 34.8318 120.097 35.6032C120.097 36.3746 119.473 37 118.703 37Z" fill="#FC6D26"/>
-<path d="M118.703 37H96.3943C95.6243 37 95 36.3746 95 35.6032C95 34.8318 95.6243 34.2064 96.3943 34.2064H118.703C119.473 34.2064 120.097 34.8318 120.097 35.6032C120.097 36.3746 119.473 37 118.703 37Z" fill="white" fill-opacity="0.6"/>
-<path d="M93.8573 32H71.3944C70.6243 32 70.0001 31.3746 70.0001 30.6032C70.0001 29.8318 70.6243 29.2064 71.3944 29.2064L93.8573 29.2064C94.6273 29.2064 95.2516 29.8318 95.2516 30.6032C95.2516 31.3746 94.6273 32 93.8573 32Z" fill="#6E49CB"/>
-<path d="M93.8573 32H71.3944C70.6243 32 70.0001 31.3746 70.0001 30.6032C70.0001 29.8318 70.6243 29.2064 71.3944 29.2064L93.8573 29.2064C94.6273 29.2064 95.2516 29.8318 95.2516 30.6032C95.2516 31.3746 94.6273 32 93.8573 32Z" fill="white" fill-opacity="0.8"/>
-<path d="M86.8573 49H71.3944C70.6243 49 70.0001 48.3746 70.0001 47.6032C70.0001 46.8317 70.6243 46.2064 71.3944 46.2064H86.8573C87.6273 46.2064 88.2516 46.8317 88.2516 47.6032C88.2516 48.3746 87.6273 49 86.8573 49Z" fill="#6E49CB"/>
-<path d="M86.8573 49H71.3944C70.6243 49 70.0001 48.3746 70.0001 47.6032C70.0001 46.8317 70.6243 46.2064 71.3944 46.2064H86.8573C87.6273 46.2064 88.2516 46.8317 88.2516 47.6032C88.2516 48.3746 87.6273 49 86.8573 49Z" fill="white" fill-opacity="0.8"/>
-<path d="M109.166 43L73.3944 43C72.6243 43 72.0001 42.3746 72.0001 41.6032C72.0001 40.8317 72.6243 40.2064 73.3944 40.2064L109.166 40.2064C109.936 40.2064 110.56 40.8317 110.56 41.6032C110.56 42.3746 109.936 43 109.166 43Z" fill="#6E49CB"/>
-<path d="M109.166 43L73.3944 43C72.6243 43 72.0001 42.3746 72.0001 41.6032C72.0001 40.8317 72.6243 40.2064 73.3944 40.2064L109.166 40.2064C109.936 40.2064 110.56 40.8317 110.56 41.6032C110.56 42.3746 109.936 43 109.166 43Z" fill="white" fill-opacity="0.4"/>
-<path d="M146.262 24.2349L143.048 21.0153C142.767 20.7338 142.282 20.7323 141.983 21.0313L140.394 22.6236C140.1 22.9181 140.088 23.4002 140.378 23.6903L145.344 28.6651C145.841 29.1637 146.666 29.1795 147.166 28.6793L147.866 27.9779L155.864 19.9653C156.171 19.658 156.167 19.1776 155.868 18.8786L154.279 17.2863C153.985 16.9918 153.495 16.9891 153.194 17.2903L146.262 24.2349Z" fill="#FC6D26"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M125.682 56.7113L123.087 59.3221C122.858 59.5529 122.547 59.6825 122.223 59.6824C121.898 59.6824 121.587 59.5526 121.358 59.3218C121.129 59.091 121 58.7779 121 58.4515C121 58.1251 121.129 57.8121 121.358 57.5813L123.087 55.8412L121.358 54.1011C121.129 53.8703 121 53.5573 121 53.2309C121 52.9045 121.129 52.5915 121.358 52.3606C121.587 52.1298 121.898 52.0001 122.223 52C122.547 51.9999 122.858 52.1296 123.087 52.3603L125.682 54.9711C125.911 55.2019 126.04 55.5149 126.04 55.8412C126.04 56.1675 125.911 56.4805 125.682 56.7113ZM131.796 56.7113L129.202 59.3221C129.088 59.4364 128.954 59.527 128.805 59.5888C128.657 59.6506 128.498 59.6824 128.337 59.6824C128.177 59.6824 128.018 59.6505 127.869 59.5886C127.721 59.5268 127.586 59.4361 127.472 59.3218C127.359 59.2075 127.269 59.0718 127.207 58.9225C127.146 58.7732 127.114 58.6131 127.114 58.4515C127.114 58.2899 127.146 58.1299 127.208 57.9806C127.269 57.8313 127.359 57.6956 127.473 57.5813L129.202 55.8412L127.473 54.1011C127.359 53.9868 127.269 53.8512 127.208 53.7018C127.146 53.5525 127.114 53.3925 127.114 53.2309C127.114 53.0693 127.146 52.9092 127.207 52.7599C127.269 52.6106 127.359 52.4749 127.472 52.3606C127.586 52.2463 127.721 52.1556 127.869 52.0938C128.018 52.0319 128.177 52 128.337 52C128.498 52 128.657 52.0318 128.805 52.0936C128.954 52.1554 129.088 52.246 129.202 52.3603L131.796 54.9711C132.026 55.2019 132.154 55.5149 132.154 55.8412C132.154 56.1675 132.026 56.4805 131.796 56.7113Z" fill="#6E49CB"/>
-<path d="M2 26C2 28.415 14.4361 30.3727 29.7769 30.3727C33.7709 30.3727 37.568 30.24 41 30.0011" stroke="#DBDBDB" stroke-width="1.28173"/>
-<path d="M2 50C2 52.415 14.4361 54.3727 29.7769 54.3727C35.6133 54.3727 41.0293 54.0893 45.5 53.6052" stroke="#DBDBDB" stroke-width="1.28173"/>
-<path d="M57.5537 5V22M2 5V68.6673C2 73.1731 20.9696 75.5204 29.7769 75.5204C38.5842 75.5204 57.5537 73.1731 57.5537 68.6673V57" stroke="#DBDBDB" stroke-width="2.56346" stroke-linejoin="round"/>
-<ellipse cx="29.7769" cy="5.64391" rx="27.7769" ry="3.64391" stroke="#DBDBDB" stroke-width="2.56346"/>
-<ellipse cx="55.4286" cy="39.46" rx="17.4286" ry="17.46" stroke="#6E49CB" stroke-width="2.56346"/>
-<ellipse cx="55.2458" cy="39.2696" rx="13.2458" ry="13.2696" fill="#6E49CB"/>
-<ellipse cx="55.2458" cy="39.2696" rx="13.2458" ry="13.2696" fill="white" fill-opacity="0.9"/>
-<path d="M61.763 38.5893C62.5797 39.0892 62.5797 40.2756 61.763 40.7756L52.951 46.1704C52.0969 46.6933 51 46.0787 51 45.0773L51 34.2875C51 33.2861 52.0969 32.6715 52.951 33.1944L61.763 38.5893Z" fill="#6E49CB"/>
-</svg>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg
deleted file mode 100644
index 2ff4e4969b1..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/create-from-template.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-<svg width="82" height="80" viewBox="0 0 82 80" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M68.1765 8.17647H79.6471C80.2968 8.17647 80.8235 8.70319 80.8235 9.35294V69.6471C80.8235 70.2968 80.2968 70.8235 79.6471 70.8235H68.1765V8.17647Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2.35294"/>
-<path d="M24.0504 19L24.0093 10.0746C24.0039 8.9145 24.8449 7.92363 25.9905 7.74035L65.393 1.43595C66.8226 1.20721 68.1176 2.31155 68.1176 3.75934V75.903C68.1176 77.3456 66.8314 78.4485 65.4057 78.2284L26.2788 72.1887C25.1356 72.0122 24.2902 71.0309 24.2849 69.8742L24.244 61" stroke="#DBDBDB" stroke-width="2.35294"/>
-<path d="M60.0194 11.1796L30.0195 15.2198C29.4357 15.2984 29 15.7966 29 16.3857V19.1235C29 19.8153 29.594 20.3578 30.283 20.2951L60.283 17.5679C60.889 17.5128 61.3529 17.0047 61.3529 16.3962V12.3455C61.3529 11.6334 60.7252 11.0845 60.0194 11.1796Z" fill="#DBDBDB" stroke="#DBDBDB" stroke-width="0.588235" stroke-linecap="round" stroke-linejoin="bevel"/>
-<path d="M51.1704 29.1021L41.8902 29.8481C41.0202 29.918 40.5266 30.8776 40.9756 31.626L42.6523 34.4205C42.8676 34.7793 43.2573 34.9968 43.6758 34.9916L51.2794 34.8968C51.9233 34.8888 52.4412 34.3645 52.4412 33.7205V30.2748C52.4412 29.5879 51.8551 29.0471 51.1704 29.1021Z" fill="#DBDBDB" stroke="#DBDBDB" stroke-width="0.588235" stroke-linecap="round" stroke-linejoin="bevel"/>
-<path d="M61.2104 70.6341V40.1765C61.2104 39.5267 60.6837 39 60.0339 39H44.9909C44.4469 39 43.9738 39.373 43.8469 39.9019L41.118 51.2721C41.0819 51.4226 41.0148 51.5672 40.923 51.6918C37.1778 56.7763 34.7228 57.4741 29.7135 59.6826C29.2815 59.873 29.0064 60.3064 29.0162 60.7783L29.1309 66.295C29.1428 66.8693 29.5679 67.3511 30.1362 67.4345L59.8631 71.7981C60.5732 71.9024 61.2104 71.3519 61.2104 70.6341Z" fill="#DBDBDB" stroke="#DBDBDB" stroke-width="0.588235" stroke-linecap="round" stroke-linejoin="bevel"/>
-<path d="M43.5694 24L36 24.5" stroke="#DBDBDB" stroke-width="1.17647" stroke-linecap="round"/>
-<circle cx="23" cy="40" r="21" stroke="#6E49CB" stroke-width="2.35294"/>
-<circle cx="23" cy="40" r="17" fill="#6E49CB"/>
-<circle cx="23" cy="40" r="17" fill="white" fill-opacity="0.9"/>
-<path d="M22.3125 48V33" stroke="#6E49CB" stroke-width="2.35294" stroke-linecap="round"/>
-<path d="M15 41.3148H30" stroke="#6E49CB" stroke-width="2.35294" stroke-linecap="round"/>
-</svg>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg b/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg
deleted file mode 100644
index 46b4b097bb6..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/illustrations/import-project.svg
+++ /dev/null
@@ -1,38 +0,0 @@
-<svg width="169" height="84" viewBox="0 0 169 84" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0)">
-<path d="M153.5 74.5714H165.684C166.411 74.5714 167 73.9822 167 73.2554V8.74461C167 8.01779 166.411 7.42859 165.684 7.42859H153.5" stroke="#DBDBDB" stroke-width="2.63203"/>
-<path d="M107.94 57L108.014 72.9062C108.017 73.5536 108.49 74.1026 109.13 74.2008L151.913 80.7674C152.71 80.8897 153.429 80.273 153.429 79.4666V2.54193C153.429 1.73264 152.705 1.11511 151.906 1.24226L108.829 8.09543C108.187 8.19744 107.716 8.7519 107.719 9.4012L107.771 20.5" stroke="#DBDBDB" stroke-width="2.63203"/>
-<path d="M133.539 52.5313L122.91 51.9925M137.311 52.7225L148.969 53.3135" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M132.224 43.9783L124 43.6955M135.998 44.1081L147.665 44.5092" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M148.238 12.3644L131.189 14.604M117.282 16.4529L126.416 15.2311" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M149.032 36.8519L131.839 37.0342M125 37.0852L127.024 37.0852" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M112.038 66.3444L120.582 67.4102M148.266 70.8634L134.595 69.1581M125.025 67.9644L129.468 68.5186" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M114.352 23.3947L116.215 23.2387M129.258 22.147L119.433 22.9693M137.388 21.4665L145.18 20.8143" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M135.832 29.2067L125.981 29.5888M138.724 28.9864L146.537 28.6833" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M115.114 59.5557L128.942 60.8796M133.782 61.3429L145.19 62.4351" stroke="#DFDFDF" stroke-width="1.31602" stroke-linecap="round"/>
-<path d="M53.4286 42.4286H21.2857C10.6345 42.4286 2.00002 33.7941 2.00002 23.1429C2.00002 12.4917 10.6345 3.85718 21.2857 3.85718C31.9369 3.85718 40.5714 12.4917 40.5714 23.1429C40.5714 28.17 38.648 32.7479 35.4969 36.1807" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/>
-<path d="M53.0361 42.4286H42.3571C35.0591 42.4286 29.1428 48.3448 29.1428 55.6429C29.1428 62.9409 35.0591 68.8572 42.3571 68.8572C49.6552 68.8572 55.5714 62.9409 55.5714 55.6429C55.5714 53.8962 55.2325 52.2287 54.6169 50.7025" stroke="#DBDBDB" stroke-width="2.63203" stroke-linecap="round"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4286 51.7144C38.4286 50.9254 39.0682 50.2858 39.8572 50.2858H44.1429C44.829 50.2858 45.4022 50.7695 45.5399 51.4146L47.7105 52.6677C48.3938 53.0622 48.6279 53.9359 48.2334 54.6192C47.3183 56.2042 45.5714 59.2248 45.4609 59.4191C45.1836 59.9063 44.7237 60.2858 44.1429 60.2858H39.8572C39.0682 60.2858 38.4286 59.6462 38.4286 58.8572V51.7144ZM39.8572 51.7144H44.1429V58.8572H39.8572L39.8572 51.7144ZM45.5714 56.3727L46.9962 53.9049L45.5714 53.0823V56.3727Z" fill="#FC6D26"/>
-<path d="M25.5984 15.2331C25.8026 14.471 25.3503 13.6877 24.5882 13.4835C23.8261 13.2793 23.0428 13.7315 22.8386 14.4936L18.4017 31.0524C18.1975 31.8145 18.6497 32.5978 19.4118 32.802C20.1739 33.0062 20.9573 32.5539 21.1615 31.7918L25.5984 15.2331Z" fill="#6E49CB"/>
-<path d="M17.2958 17.8469C17.8537 18.4048 17.8537 19.3093 17.2958 19.8672L14.0203 23.1428L17.2958 26.4183C17.8537 26.9762 17.8537 27.8807 17.2958 28.4386C16.738 28.9965 15.8334 28.9965 15.2755 28.4386L10.9898 24.1529C10.4319 23.595 10.4319 22.6905 10.9898 22.1326L15.2755 17.8469C15.8334 17.289 16.738 17.289 17.2958 17.8469Z" fill="#6E49CB"/>
-<path d="M26.7041 17.8469C26.1462 18.4048 26.1462 19.3093 26.7041 19.8672L29.9797 23.1428L26.7041 26.4183C26.1462 26.9762 26.1462 27.8807 26.7041 28.4386C27.262 28.9965 28.1665 28.9965 28.7244 28.4386L33.0101 24.1529C33.568 23.595 33.568 22.6905 33.0101 22.1326L28.7244 17.8469C28.1665 17.289 27.262 17.289 26.7041 17.8469Z" fill="#6E49CB"/>
-<path d="M50.5714 35.2857L62 35.2857C62.7889 35.2857 63.4285 35.9253 63.4285 36.7143C63.4285 37.5032 62.7889 38.1428 62 38.1428L50.5714 38.1428C49.7824 38.1428 49.1428 37.5032 49.1428 36.7143C49.1428 35.9253 49.7824 35.2857 50.5714 35.2857Z" fill="#FC6D26"/>
-<path d="M50.5714 35.2857L62 35.2857C62.7889 35.2857 63.4285 35.9253 63.4285 36.7143C63.4285 37.5032 62.7889 38.1428 62 38.1428L50.5714 38.1428C49.7824 38.1428 49.1428 37.5032 49.1428 36.7143C49.1428 35.9253 49.7824 35.2857 50.5714 35.2857Z" fill="white" fill-opacity="0.6"/>
-<path d="M70.5713 35.2857L83.4285 35.2857C84.2175 35.2857 84.8571 35.9253 84.8571 36.7143C84.8571 37.5032 84.2175 38.1428 83.4285 38.1428L70.5713 38.1428C69.7824 38.1428 69.1428 37.5032 69.1428 36.7143C69.1428 35.9253 69.7824 35.2857 70.5713 35.2857Z" fill="#FC6D26"/>
-<path d="M76.2856 46.7144L92.1428 46.7144C92.9318 46.7144 93.5714 47.3539 93.5714 48.1429C93.5714 48.9319 92.9318 49.5715 92.1428 49.5715L76.2856 49.5715C75.4967 49.5715 74.8571 48.9319 74.8571 48.1429C74.8571 47.354 75.4967 46.7144 76.2856 46.7144Z" fill="#6E49CB"/>
-<path d="M76.2856 46.7144L92.1428 46.7144C92.9318 46.7144 93.5714 47.3539 93.5714 48.1429C93.5714 48.9319 92.9318 49.5715 92.1428 49.5715L76.2856 49.5715C75.4967 49.5715 74.8571 48.9319 74.8571 48.1429C74.8571 47.354 75.4967 46.7144 76.2856 46.7144Z" fill="white" fill-opacity="0.8"/>
-<path d="M62.7142 40.9999L90 40.9999C90.7889 40.9999 91.4285 41.6395 91.4285 42.4285C91.4285 43.2175 90.7889 43.8571 90 43.8571L62.7142 43.8571C61.9253 43.8571 61.2857 43.2175 61.2857 42.4285C61.2857 41.6395 61.9253 40.9999 62.7142 40.9999Z" fill="#6E49CB"/>
-<path d="M62.7142 40.9999L90 40.9999C90.7889 40.9999 91.4285 41.6395 91.4285 42.4285C91.4285 43.2175 90.7889 43.8571 90 43.8571L62.7142 43.8571C61.9253 43.8571 61.2857 43.2175 61.2857 42.4285C61.2857 41.6395 61.9253 40.9999 62.7142 40.9999Z" fill="white" fill-opacity="0.6"/>
-<path d="M69.8571 29.5714L91.5714 29.5714C92.3603 29.5714 92.9999 30.211 92.9999 31C92.9999 31.789 92.3603 32.4286 91.5714 32.4286L69.8571 32.4286C69.0681 32.4286 68.4285 31.789 68.4285 31C68.4285 30.211 69.0681 29.5714 69.8571 29.5714Z" fill="#6E49CB"/>
-<path d="M69.8571 29.5714L91.5714 29.5714C92.3603 29.5714 92.9999 30.211 92.9999 31C92.9999 31.789 92.3603 32.4286 91.5714 32.4286L69.8571 32.4286C69.0681 32.4286 68.4285 31.789 68.4285 31C68.4285 30.211 69.0681 29.5714 69.8571 29.5714Z" fill="white" fill-opacity="0.8"/>
-<circle cx="107.714" cy="38.8571" r="17.8571" stroke="#6E49CB" stroke-width="2.63203"/>
-<circle cx="107.714" cy="38.8573" r="13.5714" fill="#6E49CB"/>
-<circle cx="107.714" cy="38.8573" r="13.5714" fill="white" fill-opacity="0.9"/>
-<path d="M111.431 35.0867L115.367 39.0232L111.431 42.9597C111.016 43.3744 110.344 43.3744 109.929 42.9597C109.515 42.545 109.515 41.8727 109.929 41.458L111.302 40.0851H101.123C100.537 40.0851 100.061 39.6097 100.061 39.0232C100.061 38.4367 100.537 37.9613 101.123 37.9613H111.302L109.929 36.5884C109.515 36.1737 109.515 35.5014 109.929 35.0867C110.344 34.672 111.016 34.672 111.431 35.0867Z" fill="#6E49CB"/>
-</g>
-<defs>
-<clipPath id="clip0">
-<rect width="169" height="84" fill="white"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js
deleted file mode 100644
index ea686d4e1e8..00000000000
--- a/app/assets/javascripts/projects/experiment_new_project_creation/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import NewProjectCreationApp from './components/app.vue';
-
-export default function initNewProjectCreation(el, props) {
- const { pushToCreateProjectCommand, workingWithProjectsHelpPath } = el.dataset;
-
- return new Vue({
- el,
- components: {
- NewProjectCreationApp,
- },
- provide: {
- workingWithProjectsHelpPath,
- pushToCreateProjectCommand,
- },
- render(h) {
- return h(NewProjectCreationApp, { props });
- },
- });
-}
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 8d005373508..25bacc1cc4a 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -9,9 +9,8 @@ export default {
GlTab,
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'),
+ import('ee_component/dora/components/deployment_frequency_charts.vue'),
+ LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'),
},
inject: {
shouldRenderDoraCharts: {
@@ -29,7 +28,7 @@ export default {
const chartsToShow = ['pipelines'];
if (this.shouldRenderDoraCharts) {
- chartsToShow.push('deployments', 'lead-time');
+ chartsToShow.push('deployment-frequency', 'lead-time');
}
return chartsToShow;
@@ -62,10 +61,10 @@ export default {
<pipeline-charts />
</gl-tab>
<template v-if="shouldRenderDoraCharts">
- <gl-tab :title="__('Deployments')">
+ <gl-tab :title="__('Deployment frequency')">
<deployment-frequency-charts />
</gl-tab>
- <gl-tab :title="__('Lead Time')">
+ <gl-tab :title="__('Lead time')">
<lead-time-charts />
</gl-tab>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index 6a963616224..1c4413bef71 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -4,6 +4,7 @@ import { GlColumnChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
+import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
import {
DEFAULT,
CHART_CONTAINER_HEIGHT,
@@ -21,7 +22,6 @@ import {
} from '../constants';
import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql';
import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql';
-import CiCdAnalyticsCharts from './ci_cd_analytics_charts.vue';
import StatisticsList from './statistics_list.vue';
const defaultAnalyticsValues = {
@@ -301,7 +301,7 @@ export default {
<statistics-list v-else :counts="formattedCounts" />
</div>
<div v-if="!loading" class="col-md-6">
- <strong>{{ __('Duration for the last 30 commits') }}</strong>
+ <strong>{{ __('Pipeline durations for the last 30 commits') }}</strong>
<gl-column-chart
:height="$options.chartContainerHeight"
:option="$options.timesChartOptions"
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
index f46068acd68..80ed9a32039 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { sprintf, n__ } from '~/locale';
+import { sprintf, n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -23,6 +23,8 @@ import {
ROOT_IMAGE_TOOLTIP,
} from '../../constants/index';
+import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql';
+
export default {
name: 'DetailsHeader',
components: { GlButton, GlIcon, TitleArea, MetadataItem },
@@ -35,60 +37,77 @@ export default {
type: Object,
required: true,
},
- metadataLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
disabled: {
type: Boolean,
default: false,
required: false,
},
},
+ data() {
+ return {
+ containerRepository: {},
+ fetchTagsCount: false,
+ };
+ },
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryTagsCountQuery,
+ variables() {
+ return {
+ id: this.image.id,
+ };
+ },
+ },
+ },
computed: {
+ imageDetails() {
+ return { ...this.image, ...this.containerRepository };
+ },
visibilityIcon() {
- return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
+ return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
},
timeAgo() {
- return this.timeFormatted(this.image.updatedAt);
+ return this.timeFormatted(this.imageDetails.updatedAt);
},
updatedText() {
return sprintf(UPDATED_AT, { time: this.timeAgo });
},
tagCountText() {
- return n__('%d tag', '%d tags', this.image.tagsCount);
+ if (this.$apollo.queries.containerRepository.loading) {
+ return s__('ContainerRegistry|-- tags');
+ }
+ return n__('%d tag', '%d tags', this.imageDetails.tagsCount);
},
cleanupTextAndTooltip() {
- if (!this.image.project.containerExpirationPolicy?.enabled) {
+ if (!this.imageDetails.project.containerExpirationPolicy?.enabled) {
return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP };
}
return {
[UNSCHEDULED_STATUS]: {
text: sprintf(CLEANUP_UNSCHEDULED_TEXT, {
- time: this.timeFormatted(this.image.project.containerExpirationPolicy.nextRunAt),
+ time: this.timeFormatted(this.imageDetails.project.containerExpirationPolicy.nextRunAt),
}),
},
[SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP },
[ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP },
[UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP },
- }[this.image?.expirationPolicyCleanupStatus];
+ }[this.imageDetails?.expirationPolicyCleanupStatus];
},
deleteButtonDisabled() {
- return this.disabled || !this.image.canDelete;
+ return this.disabled || !this.imageDetails.canDelete;
},
rootImageTooltip() {
- return !this.image.name ? ROOT_IMAGE_TOOLTIP : '';
+ return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
},
imageName() {
- return this.image.name || ROOT_IMAGE_TEXT;
+ return this.imageDetails.name || ROOT_IMAGE_TEXT;
},
},
};
</script>
<template>
- <title-area :metadata-loading="metadataLoading">
+ <title-area>
<template #title>
<span data-testid="title">
{{ imageName }}
@@ -124,13 +143,8 @@ export default {
/>
</template>
<template #right-actions>
- <gl-button
- v-if="!metadataLoading"
- variant="danger"
- :disabled="deleteButtonDisabled"
- @click="$emit('delete')"
- >
- {{ __('Delete') }}
+ <gl-button variant="danger" :disabled="deleteButtonDisabled" @click="$emit('delete')">
+ {{ __('Delete image repository') }}
</gl-button>
</template>
</title-area>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
index bc10246614a..3e19a646f53 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
@@ -1,19 +1,32 @@
<script>
-import { GlButton } from '@gitlab/ui';
-import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index';
+import { GlButton, GlKeysetPagination } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { joinPaths } from '~/lib/utils/url_utility';
+import {
+ REMOVE_TAGS_BUTTON_TITLE,
+ TAGS_LIST_TITLE,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+} from '../../constants/index';
+import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
+import EmptyState from './empty_state.vue';
import TagsListRow from './tags_list_row.vue';
+import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
components: {
GlButton,
+ GlKeysetPagination,
TagsListRow,
+ EmptyState,
+ TagsLoader,
},
+ inject: ['config'],
props: {
- tags: {
- type: Array,
- required: false,
- default: () => [],
+ id: {
+ type: [Number, String],
+ required: true,
},
isMobile: {
type: Boolean,
@@ -25,17 +38,46 @@ export default {
default: false,
required: false,
},
+ isImageLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
},
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryTagsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
data() {
return {
selectedItems: {},
+ containerRepository: {},
};
},
computed: {
+ tags() {
+ return this.containerRepository?.tags?.nodes || [];
+ },
+ tagsPageInfo() {
+ return this.containerRepository?.tags?.pageInfo;
+ },
+ queryVariables() {
+ return {
+ id: joinPaths(this.config.gidPrefix, `${this.id}`),
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
hasSelectedItems() {
return this.tags.some((tag) => this.selectedItems[tag.name]);
},
@@ -45,42 +87,93 @@ export default {
multiDeleteButtonIsDisabled() {
return !this.hasSelectedItems || this.disabled;
},
+ showPagination() {
+ return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
+ },
+ hasNoTags() {
+ return this.tags.length === 0;
+ },
+ isLoading() {
+ return this.isImageLoading || this.$apollo.queries.containerRepository.loading;
+ },
},
methods: {
updateSelectedItems(name) {
this.$set(this.selectedItems, name, !this.selectedItems[name]);
},
+ mapTagsToBeDleeted(items) {
+ return this.tags.filter((tag) => items[tag.name]);
+ },
+ fetchNextPage() {
+ this.$apollo.queries.containerRepository.fetchMore({
+ variables: {
+ after: this.tagsPageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
+ fetchPreviousPage() {
+ this.$apollo.queries.containerRepository.fetchMore({
+ variables: {
+ first: null,
+ before: this.tagsPageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
},
};
</script>
<template>
<div>
- <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
- <h5 data-testid="list-title">
- {{ $options.i18n.TAGS_LIST_TITLE }}
- </h5>
+ <tags-loader v-if="isLoading" />
+ <template v-else>
+ <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
+ <template v-else>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
+ <h5 data-testid="list-title">
+ {{ $options.i18n.TAGS_LIST_TITLE }}
+ </h5>
- <gl-button
- v-if="showMultiDeleteButton"
- :disabled="multiDeleteButtonIsDisabled"
- category="secondary"
- variant="danger"
- @click="$emit('delete', selectedItems)"
- >
- {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
- </gl-button>
- </div>
- <tags-list-row
- v-for="(tag, index) in tags"
- :key="tag.path"
- :tag="tag"
- :first="index === 0"
- :selected="selectedItems[tag.name]"
- :is-mobile="isMobile"
- :disabled="disabled"
- @select="updateSelectedItems(tag.name)"
- @delete="$emit('delete', { [tag.name]: true })"
- />
+ <gl-button
+ v-if="showMultiDeleteButton"
+ :disabled="multiDeleteButtonIsDisabled"
+ category="secondary"
+ variant="danger"
+ @click="$emit('delete', mapTagsToBeDleeted(selectedItems))"
+ >
+ {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
+ </gl-button>
+ </div>
+ <tags-list-row
+ v-for="(tag, index) in tags"
+ :key="tag.path"
+ :tag="tag"
+ :first="index === 0"
+ :selected="selectedItems[tag.name]"
+ :is-mobile="isMobile"
+ :disabled="disabled"
+ @select="updateSelectedItems(tag.name)"
+ @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))"
+ />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="tagsPageInfo.hasNextPage"
+ :has-previous-page="tagsPageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="fetchPreviousPage"
+ @next="fetchNextPage"
+ />
+ </div>
+ </template>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index 74027a376a7..45eb2ce51e4 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -50,6 +50,11 @@ export default {
default: false,
required: false,
},
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
i18n: {
REMOVE_TAG_BUTTON_TITLE,
@@ -92,19 +97,25 @@ export default {
tagLocation() {
return this.tag.path?.replace(`:${this.tag.name}`, '');
},
- invalidTag() {
+ isInvalidTag() {
return !this.tag.digest;
},
+ isCheckboxDisabled() {
+ return this.isInvalidTag || this.disabled;
+ },
+ isDeleteDisabled() {
+ return this.isInvalidTag || this.disabled || !this.tag.canDelete;
+ },
},
};
</script>
<template>
- <list-item v-bind="$attrs" :selected="selected">
+ <list-item v-bind="$attrs" :selected="selected" :disabled="disabled">
<template #left-action>
<gl-form-checkbox
v-if="tag.canDelete"
- :disabled="invalidTag"
+ :disabled="isCheckboxDisabled"
class="gl-m-0"
:checked="selected"
@change="$emit('select')"
@@ -126,10 +137,11 @@ export default {
:title="tag.location"
:text="tag.location"
category="tertiary"
+ :disabled="disabled"
/>
<gl-icon
- v-if="invalidTag"
+ v-if="isInvalidTag"
v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }"
name="warning"
class="gl-text-orange-500 gl-mb-2 gl-ml-2"
@@ -162,7 +174,7 @@ export default {
</template>
<template #right-action>
<delete-button
- :disabled="!tag.canDelete || invalidTag"
+ :disabled="isDeleteDisabled"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
:tooltip-disabled="tag.canDelete"
@@ -172,7 +184,7 @@ export default {
/>
</template>
- <template v-if="!invalidTag" #details-published>
+ <template v-if="!isInvalidTag" #details-published>
<details-row icon="clock" data-testid="published-date-detail">
<gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
<template #repositoryPath>
@@ -187,7 +199,7 @@ export default {
</gl-sprintf>
</details-row>
</template>
- <template v-if="!invalidTag" #details-manifest-digest>
+ <template v-if="!isInvalidTag" #details-manifest-digest>
<details-row icon="log" data-testid="manifest-detail">
<gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST">
<template #digest>
@@ -200,10 +212,11 @@ export default {
:text="tag.digest"
category="tertiary"
size="small"
+ :disabled="disabled"
/>
</details-row>
</template>
- <template v-if="!invalidTag" #details-configuration-digest>
+ <template v-if="!isInvalidTag" #details-configuration-digest>
<details-row icon="cloud-gear" data-testid="configuration-detail">
<gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST">
<template #digest>
@@ -216,6 +229,7 @@ export default {
:text="formattedRevision"
category="tertiary"
size="small"
+ :disabled="disabled"
/>
</details-row>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index 0373a84b271..930ad01c758 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -78,6 +78,9 @@ export default {
imageName() {
return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
},
+ routerLinkEvent() {
+ return this.deleting ? '' : 'click';
+ },
},
};
</script>
@@ -97,6 +100,7 @@ export default {
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
+ :event="routerLinkEvent"
:to="{ name: 'details', params: { id } }"
>
{{ imageName }}
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 7220f9646db..5dcc042a9c4 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -31,7 +31,7 @@ export const CONFIGURATION_DETAILS_ROW_TEST = s__(
);
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
-export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected');
+export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
`ContainerRegistry|You are about to remove %{item}. Are you sure?`,
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql
index 3fd019467ac..88c2e667afd 100644
--- a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -1,12 +1,4 @@
-#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
-
-query getContainerRepositoryDetails(
- $id: ID!
- $first: Int
- $last: Int
- $after: String
- $before: String
-) {
+query getContainerRepositoryDetails($id: ID!) {
containerRepository(id: $id) {
id
name
@@ -16,25 +8,8 @@ query getContainerRepositoryDetails(
canDelete
createdAt
updatedAt
- tagsCount
expirationPolicyStartedAt
expirationPolicyCleanupStatus
- tags(after: $after, before: $before, first: $first, last: $last) {
- nodes {
- digest
- location
- path
- name
- revision
- shortRevision
- createdAt
- totalSize
- canDelete
- }
- pageInfo {
- ...PageInfo
- }
- }
project {
visibility
containerExpirationPolicy {
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
new file mode 100644
index 00000000000..a703c2dd0ac
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
@@ -0,0 +1,29 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getContainerRepositoryTags(
+ $id: ID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ containerRepository(id: $id) {
+ id
+ tags(after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ digest
+ location
+ path
+ name
+ revision
+ shortRevision
+ createdAt
+ totalSize
+ canDelete
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
new file mode 100644
index 00000000000..9092a71edb0
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
@@ -0,0 +1,6 @@
+query getContainerRepositoryTagsCount($id: ID!) {
+ containerRepository(id: $id) {
+ id
+ tagsCount
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 2f515356fa7..34ec3b085a5 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -1,5 +1,5 @@
<script>
-import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui';
+import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -21,7 +21,6 @@ import {
ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
- GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
@@ -36,7 +35,6 @@ export default {
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
- GlKeysetPagination,
DeleteModal,
TagsList,
TagsLoader,
@@ -50,16 +48,12 @@ export default {
mixins: [Tracking.mixin()],
inject: ['breadCrumbState', 'config'],
apollo: {
- image: {
+ containerRepository: {
query: getContainerRepositoryDetailsQuery,
variables() {
return this.queryVariables;
},
- update(data) {
- return data.containerRepository;
- },
- result({ data }) {
- this.tagsPageInfo = data.containerRepository?.tags?.pageInfo;
+ result() {
this.updateBreadcrumb();
},
error() {
@@ -69,8 +63,7 @@ export default {
},
data() {
return {
- image: {},
- tagsPageInfo: {},
+ containerRepository: {},
itemsToBeDeleted: [],
isMobile: false,
mutationLoading: false,
@@ -83,19 +76,15 @@ export default {
queryVariables() {
return {
id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`),
- first: GRAPHQL_PAGE_SIZE,
};
},
isLoading() {
- return this.$apollo.queries.image.loading || this.mutationLoading;
- },
- tags() {
- return this.image?.tags?.nodes || [];
+ return this.$apollo.queries.containerRepository.loading || this.mutationLoading;
},
showPartialCleanupWarning() {
return (
this.config.showUnfinishedTagCleanupCallout &&
- this.image?.expirationPolicyCleanupStatus === UNFINISHED_STATUS &&
+ this.containerRepository?.expirationPolicyCleanupStatus === UNFINISHED_STATUS &&
!this.hidePartialCleanupWarning
);
},
@@ -105,26 +94,20 @@ export default {
this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
- showPagination() {
- return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
- },
- hasNoTags() {
- return this.tags.length === 0;
- },
pageActionsAreDisabled() {
- return Boolean(this.image?.status);
+ return Boolean(this.containerRepository?.status);
},
},
methods: {
updateBreadcrumb() {
- const name = this.image?.id
- ? this.image?.name || ROOT_IMAGE_TEXT
+ const name = this.containerRepository?.id
+ ? this.containerRepository?.name || ROOT_IMAGE_TEXT
: MISSING_OR_DELETED_IMAGE_BREADCRUMB;
this.breadCrumbState.updateName(name);
},
deleteTags(toBeDeleted) {
this.deleteImageAlert = false;
- this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]);
+ this.itemsToBeDeleted = toBeDeleted;
this.track('click_button');
this.$refs.deleteModal.show();
},
@@ -170,33 +153,6 @@ export default {
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
},
- fetchNextPage() {
- if (this.tagsPageInfo?.hasNextPage) {
- this.$apollo.queries.image.fetchMore({
- variables: {
- after: this.tagsPageInfo?.endCursor,
- first: GRAPHQL_PAGE_SIZE,
- },
- updateQuery(previousResult, { fetchMoreResult }) {
- return fetchMoreResult;
- },
- });
- }
- },
- fetchPreviousPage() {
- if (this.tagsPageInfo?.hasPreviousPage) {
- this.$apollo.queries.image.fetchMore({
- variables: {
- first: null,
- before: this.tagsPageInfo?.startCursor,
- last: GRAPHQL_PAGE_SIZE,
- },
- updateQuery(previousResult, { fetchMoreResult }) {
- return fetchMoreResult;
- },
- });
- }
- },
dismissPartialCleanupWarning() {
this.hidePartialCleanupWarning = true;
axios.post(this.config.userCalloutsPath, {
@@ -205,7 +161,7 @@ export default {
},
deleteImage() {
this.deleteImageAlert = true;
- this.itemsToBeDeleted = [{ path: this.image.path }];
+ this.itemsToBeDeleted = [{ path: this.containerRepository.path }];
this.$refs.deleteModal.show();
},
deleteImageError() {
@@ -221,7 +177,7 @@ export default {
<template>
<div v-gl-resize-observer="handleResize" class="gl-my-3">
- <template v-if="image">
+ <template v-if="containerRepository">
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
@@ -236,40 +192,27 @@ export default {
@dismiss="dismissPartialCleanupWarning"
/>
- <status-alert v-if="image.status" :status="image.status" />
+ <status-alert v-if="containerRepository.status" :status="containerRepository.status" />
<details-header
- :image="image"
- :metadata-loading="isLoading"
+ v-if="!isLoading"
+ :image="containerRepository"
:disabled="pageActionsAreDisabled"
@delete="deleteImage"
/>
<tags-loader v-if="isLoading" />
- <template v-else>
- <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
- <template v-else>
- <tags-list
- :tags="tags"
- :is-mobile="isMobile"
- :disabled="pageActionsAreDisabled"
- @delete="deleteTags"
- />
- <div class="gl-display-flex gl-justify-content-center">
- <gl-keyset-pagination
- v-if="showPagination"
- :has-next-page="tagsPageInfo.hasNextPage"
- :has-previous-page="tagsPageInfo.hasPreviousPage"
- class="gl-mt-3"
- @prev="fetchPreviousPage"
- @next="fetchNextPage"
- />
- </div>
- </template>
- </template>
+ <tags-list
+ v-else
+ :id="$route.params.id"
+ :is-image-loading="isLoading"
+ :is-mobile="isMobile"
+ :disabled="pageActionsAreDisabled"
+ @delete="deleteTags"
+ />
<delete-image
- :id="image.id"
+ :id="containerRepository.id"
ref="deleteImage"
use-update-fn
@start="deleteImageIniit"
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 262b5614d65..31d335fa15d 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -51,12 +51,8 @@ export default {
}),
fetchReleases() {
this.fetchReleasesStoreAction({
- // these two parameters are only used in "GraphQL mode"
before: getParameterByName('before'),
after: getParameterByName('after'),
-
- // this parameter is only used when in "REST mode"
- page: getParameterByName('page'),
});
},
},
@@ -73,17 +69,17 @@ export default {
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="success"
- class="js-new-release-btn"
+ data-testid="new-release-button"
>
{{ __('New release') }}
</gl-button>
</div>
- <release-skeleton-loader v-if="isLoading" class="js-loading" />
+ <release-skeleton-loader v-if="isLoading" />
<gl-empty-state
v-else-if="shouldRenderEmptyState"
- class="js-empty-state"
+ data-testid="empty-state"
:title="__('Getting started with releases')"
:svg-path="illustrationPath"
>
@@ -101,7 +97,7 @@ export default {
</template>
</gl-empty-state>
- <div v-else-if="shouldRenderSuccessState" class="js-success-state">
+ <div v-else-if="shouldRenderSuccessState" data-testid="success-state">
<release-block
v-for="(release, index) in releases"
:key="index"
diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue
index c38e93d420b..fdb0f99b735 100644
--- a/app/assets/javascripts/releases/components/app_show.vue
+++ b/app/assets/javascripts/releases/components/app_show.vue
@@ -1,7 +1,7 @@
<script>
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import oneReleaseQuery from '../queries/one_release.query.graphql';
+import oneReleaseQuery from '../graphql/queries/one_release.query.graphql';
import { convertGraphQLRelease } from '../util';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
diff --git a/app/assets/javascripts/releases/components/releases_pagination.vue b/app/assets/javascripts/releases/components/releases_pagination.vue
index 062c72b445b..fddf85ead1e 100644
--- a/app/assets/javascripts/releases/components/releases_pagination.vue
+++ b/app/assets/javascripts/releases/components/releases_pagination.vue
@@ -1,20 +1,37 @@
<script>
-import { mapGetters } from 'vuex';
-import ReleasesPaginationGraphql from './releases_pagination_graphql.vue';
-import ReleasesPaginationRest from './releases_pagination_rest.vue';
+import { GlKeysetPagination } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
- name: 'ReleasesPagination',
- components: { ReleasesPaginationGraphql, ReleasesPaginationRest },
+ name: 'ReleasesPaginationGraphql',
+ components: { GlKeysetPagination },
computed: {
- ...mapGetters(['useGraphQLEndpoint']),
+ ...mapState('index', ['pageInfo']),
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ },
+ },
+ methods: {
+ ...mapActions('index', ['fetchReleases']),
+ onPrev(before) {
+ historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
+ this.fetchReleases({ before });
+ },
+ onNext(after) {
+ historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
+ this.fetchReleases({ after });
+ },
},
};
</script>
-
<template>
<div class="gl-display-flex gl-justify-content-center">
- <releases-pagination-graphql v-if="useGraphQLEndpoint" />
- <releases-pagination-rest v-else />
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ @prev="onPrev($event)"
+ @next="onNext($event)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
deleted file mode 100644
index 13cbf95b9af..00000000000
--- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue
+++ /dev/null
@@ -1,35 +0,0 @@
-<script>
-import { GlKeysetPagination } from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-
-export default {
- name: 'ReleasesPaginationGraphql',
- components: { GlKeysetPagination },
- computed: {
- ...mapState('index', ['graphQlPageInfo']),
- showPagination() {
- return this.graphQlPageInfo.hasPreviousPage || this.graphQlPageInfo.hasNextPage;
- },
- },
- methods: {
- ...mapActions('index', ['fetchReleases']),
- onPrev(before) {
- historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
- this.fetchReleases({ before });
- },
- onNext(after) {
- historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
- this.fetchReleases({ after });
- },
- },
-};
-</script>
-<template>
- <gl-keyset-pagination
- v-if="showPagination"
- v-bind="graphQlPageInfo"
- @prev="onPrev($event)"
- @next="onNext($event)"
- />
-</template>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue
deleted file mode 100644
index 5e97a5a0450..00000000000
--- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<script>
-import { mapActions, mapState } from 'vuex';
-import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
-import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-
-export default {
- name: 'ReleasesPaginationRest',
- components: { TablePagination },
- computed: {
- ...mapState('index', ['restPageInfo']),
- },
- methods: {
- ...mapActions('index', ['fetchReleases']),
- onChangePage(page) {
- historyPushState(buildUrlWithCurrentLocation(`?page=${page}`));
- this.fetchReleases({ page });
- },
- },
-};
-</script>
-
-<template>
- <table-pagination :change="onChangePage" :page-info="restPageInfo" />
-</template>
diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue
index 9df646ca798..80f59485426 100644
--- a/app/assets/javascripts/releases/components/tag_field_new.vue
+++ b/app/assets/javascripts/releases/components/tag_field_new.vue
@@ -74,6 +74,21 @@ export default {
// we need to show the "create from" input.
this.showCreateFrom = true;
},
+ shouldShowCreateTagOption(isLoading, matches, query) {
+ // Show the "create tag" option if:
+ return (
+ // we're not currently loading any results, and
+ !isLoading &&
+ // the search query isn't just whitespace, and
+ query.trim() &&
+ // the `matches` object is non-null, and
+ matches &&
+ // the tag name doesn't already exist
+ !matches.tags.list.some(
+ (tagInfo) => tagInfo.name.toUpperCase() === query.toUpperCase().trim(),
+ )
+ );
+ },
},
translations: {
tagName: {
@@ -111,7 +126,7 @@ export default {
>
<template #footer="{ isLoading, matches, query }">
<gl-dropdown-item
- v-if="!isLoading && matches && matches.tags.totalCount === 0"
+ v-if="shouldShowCreateTagOption(isLoading, matches, query)"
is-check-item
:is-checked="tagName === query"
@click="createTagClicked(query)"
diff --git a/app/assets/javascripts/releases/queries/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index 3a742db7d9e..3a742db7d9e 100644
--- a/app/assets/javascripts/releases/queries/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
new file mode 100644
index 00000000000..47c5afefd78
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
@@ -0,0 +1,23 @@
+fragment ReleaseForEditing on Release {
+ name
+ tagName
+ description
+ assets {
+ links {
+ nodes {
+ id
+ name
+ url
+ linkType
+ }
+ }
+ }
+ links {
+ selfUrl
+ }
+ milestones {
+ nodes {
+ title
+ }
+ }
+}
diff --git a/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql
new file mode 100644
index 00000000000..56bfe7c23d6
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql
@@ -0,0 +1,10 @@
+mutation createRelease($input: ReleaseCreateInput!) {
+ releaseCreate(input: $input) {
+ release {
+ links {
+ selfUrl
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql
new file mode 100644
index 00000000000..4bdfc79dbc4
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/mutations/create_release_link.mutation.graphql
@@ -0,0 +1,5 @@
+mutation createReleaseAssetLink($input: ReleaseAssetLinkCreateInput!) {
+ releaseAssetLinkCreate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql
new file mode 100644
index 00000000000..a75eddcd288
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/mutations/delete_release_link.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteReleaseAssetLink($input: ReleaseAssetLinkDeleteInput!) {
+ releaseAssetLinkDelete(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql
new file mode 100644
index 00000000000..9c6a861d2f1
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/mutations/update_release.mutation.graphql
@@ -0,0 +1,5 @@
+mutation updateRelease($input: ReleaseUpdateInput!) {
+ releaseUpdate(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index a07dabb9fd6..10e4d883e62 100644
--- a/app/assets/javascripts/releases/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -1,4 +1,4 @@
-#import "./release.fragment.graphql"
+#import "../fragments/release.fragment.graphql"
query allReleases(
$fullPath: ID!
diff --git a/app/assets/javascripts/releases/queries/one_release.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql
index b893aea94b0..c80d6e753ab 100644
--- a/app/assets/javascripts/releases/queries/one_release.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/one_release.query.graphql
@@ -1,4 +1,4 @@
-#import "./release.fragment.graphql"
+#import "../fragments/release.fragment.graphql"
query oneRelease($fullPath: ID!, $tagName: String!) {
project(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql
new file mode 100644
index 00000000000..767ba4aeca0
--- /dev/null
+++ b/app/assets/javascripts/releases/graphql/queries/one_release_for_editing.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/release_for_editing.fragment.graphql"
+
+query oneReleaseForEditing($fullPath: ID!, $tagName: String!) {
+ project(fullPath: $fullPath) {
+ release(tagName: $tagName) {
+ ...ReleaseForEditing
+ }
+ }
+}
diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js
index 0b453467c13..bb21ec7c43f 100644
--- a/app/assets/javascripts/releases/mount_index.js
+++ b/app/assets/javascripts/releases/mount_index.js
@@ -15,11 +15,6 @@ export default () => {
modules: {
index: createIndexModule(el.dataset),
},
- featureFlags: {
- graphqlReleaseData: Boolean(gon.features?.graphqlReleaseData),
- graphqlReleasesPage: Boolean(gon.features?.graphqlReleasesPage),
- graphqlMilestoneStats: Boolean(gon.features?.graphqlMilestoneStats),
- },
}),
render: (h) => h(ReleaseIndexApp),
});
diff --git a/app/assets/javascripts/releases/stores/getters.js b/app/assets/javascripts/releases/stores/getters.js
deleted file mode 100644
index 2a06f398e26..00000000000
--- a/app/assets/javascripts/releases/stores/getters.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * @returns {Boolean} `true` if all the feature flags
- * required to enable the GraphQL endpoint are enabled
- */
-export const useGraphQLEndpoint = (rootState) => {
- return Boolean(
- rootState.featureFlags.graphqlReleaseData &&
- rootState.featureFlags.graphqlReleasesPage &&
- rootState.featureFlags.graphqlMilestoneStats,
- );
-};
diff --git a/app/assets/javascripts/releases/stores/index.js b/app/assets/javascripts/releases/stores/index.js
index cc8b586964f..b2e93d789d7 100644
--- a/app/assets/javascripts/releases/stores/index.js
+++ b/app/assets/javascripts/releases/stores/index.js
@@ -1,9 +1,7 @@
import Vuex from 'vuex';
-import * as getters from './getters';
export default ({ modules, featureFlags }) =>
new Vuex.Store({
modules,
state: { featureFlags },
- getters,
});
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index 8dc2083dd2b..b312c2a7506 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -1,14 +1,12 @@
-import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
-import oneReleaseQuery from '~/releases/queries/one_release.query.graphql';
-import {
- releaseToApiJson,
- apiJsonToRelease,
- gqClient,
- convertOneReleaseGraphQLResponse,
-} from '~/releases/util';
+import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql';
+import createReleaseAssetLinkMutation from '~/releases/graphql/mutations/create_release_link.mutation.graphql';
+import deleteReleaseAssetLinkMutation from '~/releases/graphql/mutations/delete_release_link.mutation.graphql';
+import updateReleaseMutation from '~/releases/graphql/mutations/update_release.mutation.graphql';
+import oneReleaseForEditingQuery from '~/releases/graphql/queries/one_release_for_editing.query.graphql';
+import { gqClient, convertOneReleaseGraphQLResponse } from '~/releases/util';
import * as types from './mutation_types';
export const initializeRelease = ({ commit, dispatch, getters }) => {
@@ -24,38 +22,25 @@ export const initializeRelease = ({ commit, dispatch, getters }) => {
return Promise.resolve();
};
-export const fetchRelease = ({ commit, state, rootState }) => {
+export const fetchRelease = async ({ commit, state }) => {
commit(types.REQUEST_RELEASE);
- if (rootState.featureFlags?.graphqlIndividualReleasePage) {
- return gqClient
- .query({
- query: oneReleaseQuery,
- variables: {
- fullPath: state.projectPath,
- tagName: state.tagName,
- },
- })
- .then((response) => {
- const { data: release } = convertOneReleaseGraphQLResponse(response);
-
- commit(types.RECEIVE_RELEASE_SUCCESS, release);
- })
- .catch((error) => {
- commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details.'));
- });
- }
-
- return api
- .release(state.projectId, state.tagName)
- .then(({ data }) => {
- commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data));
- })
- .catch((error) => {
- commit(types.RECEIVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while getting the release details.'));
+ try {
+ const fetchResponse = await gqClient.query({
+ query: oneReleaseForEditingQuery,
+ variables: {
+ fullPath: state.projectPath,
+ tagName: state.tagName,
+ },
});
+
+ const { data: release } = convertOneReleaseGraphQLResponse(fetchResponse);
+
+ commit(types.RECEIVE_RELEASE_SUCCESS, release);
+ } catch (error) {
+ commit(types.RECEIVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while getting the release details.'));
+ }
};
export const updateReleaseTagName = ({ commit }, tagName) =>
@@ -94,9 +79,9 @@ export const removeAssetLink = ({ commit }, linkIdToRemove) => {
commit(types.REMOVE_ASSET_LINK, linkIdToRemove);
};
-export const receiveSaveReleaseSuccess = ({ commit }, release) => {
+export const receiveSaveReleaseSuccess = ({ commit }, urlToRedirectTo) => {
commit(types.RECEIVE_SAVE_RELEASE_SUCCESS);
- redirectTo(release._links.self);
+ redirectTo(urlToRedirectTo);
};
export const saveRelease = ({ commit, dispatch, getters }) => {
@@ -105,83 +90,130 @@ export const saveRelease = ({ commit, dispatch, getters }) => {
dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease');
};
-export const createRelease = ({ commit, dispatch, state, getters }) => {
- const apiJson = releaseToApiJson(
- {
- ...state.release,
- assets: {
- links: getters.releaseLinksToCreate,
- },
- },
- state.createFrom,
- );
+/**
+ * Tests a GraphQL mutation response for the existence of any errors-as-data
+ * (See https://docs.gitlab.com/ee/development/fe_guide/graphql.html#errors-as-data).
+ * If any errors occurred, throw a JavaScript `Error` object, so that this can be
+ * handled by the global error handler.
+ *
+ * @param {Object} gqlResponse The response object returned by the GraphQL client
+ * @param {String} mutationName The name of the mutation that was executed
+ * @param {String} messageIfError An message to build into the error object if something went wrong
+ */
+const checkForErrorsAsData = (gqlResponse, mutationName, messageIfError) => {
+ const allErrors = gqlResponse.data[mutationName].errors;
+ if (allErrors.length > 0) {
+ const allErrorMessages = JSON.stringify(allErrors);
+ throw new Error(`${messageIfError}: ${allErrorMessages}`);
+ }
+};
- return api
- .createRelease(state.projectId, apiJson)
- .then(({ data }) => {
- dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data));
- })
- .catch((error) => {
- commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while creating a new release'));
+export const createRelease = async ({ commit, dispatch, state, getters }) => {
+ try {
+ const response = await gqClient.mutate({
+ mutation: createReleaseMutation,
+ variables: getters.releaseCreateMutatationVariables,
});
+
+ checkForErrorsAsData(
+ response,
+ 'releaseCreate',
+ `Something went wrong while creating a new release with projectPath "${state.projectPath}" and tagName "${state.release.tagName}"`,
+ );
+
+ dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl);
+ } catch (error) {
+ commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while creating a new release.'));
+ }
};
-export const updateRelease = ({ commit, dispatch, state, getters }) => {
- const apiJson = releaseToApiJson({
- ...state.release,
- assets: {
- links: getters.releaseLinksToCreate,
+/**
+ * Deletes a single release link.
+ * Throws an error if any network or validation errors occur.
+ */
+const deleteReleaseLinks = async ({ state, id }) => {
+ const deleteResponse = await gqClient.mutate({
+ mutation: deleteReleaseAssetLinkMutation,
+ variables: {
+ input: { id },
},
});
- let updatedRelease = null;
-
- return (
- api
- .updateRelease(state.projectId, state.tagName, apiJson)
-
- /**
- * Currently, we delete all existing links and then
- * recreate new ones on each edit. This is because the
- * REST API doesn't support bulk updating of Release links,
- * and updating individual links can lead to validation
- * race conditions (in particular, the "URLs must be unique")
- * constraint.
- *
- * This isn't ideal since this is no longer an atomic
- * operation - parts of it can fail while others succeed,
- * leaving the Release in an inconsistent state.
- *
- * This logic should be refactored to use GraphQL once
- * https://gitlab.com/gitlab-org/gitlab/-/issues/208702
- * is closed.
- */
- .then(({ data }) => {
- // Save this response since we need it later in the Promise chain
- updatedRelease = data;
-
- // Delete all links currently associated with this Release
- return Promise.all(
- getters.releaseLinksToDelete.map((l) =>
- api.deleteReleaseLink(state.projectId, state.release.tagName, l.id),
- ),
- );
- })
- .then(() => {
- // Create a new link for each link in the form
- return Promise.all(
- apiJson.assets.links.map((l) =>
- api.createReleaseLink(state.projectId, state.release.tagName, l),
- ),
- );
- })
- .then(() => {
- dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease));
- })
- .catch((error) => {
- commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
- createFlash(s__('Release|Something went wrong while saving the release details'));
- })
+ checkForErrorsAsData(
+ deleteResponse,
+ 'releaseAssetLinkDelete',
+ `Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`,
);
};
+
+/**
+ * Creates a single release link.
+ * Throws an error if any network or validation errors occur.
+ */
+const createReleaseLink = async ({ state, link }) => {
+ const createResponse = await gqClient.mutate({
+ mutation: createReleaseAssetLinkMutation,
+ variables: {
+ input: {
+ projectPath: state.projectPath,
+ tagName: state.tagName,
+ name: link.name,
+ url: link.url,
+ linkType: link.linkType.toUpperCase(),
+ },
+ },
+ });
+
+ checkForErrorsAsData(
+ createResponse,
+ 'releaseAssetLinkCreate',
+ `Something went wrong while creating a release asset link for release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
+ );
+};
+
+export const updateRelease = async ({ commit, dispatch, state, getters }) => {
+ try {
+ /**
+ * Currently, we delete all existing links and then
+ * recreate new ones on each edit. This is because the
+ * backend doesn't support bulk updating of Release links,
+ * and updating individual links can lead to validation
+ * race conditions (in particular, the "URLs must be unique")
+ * constraint.
+ *
+ * This isn't ideal since this is no longer an atomic
+ * operation - parts of it can fail while others succeed,
+ * leaving the Release in an inconsistent state.
+ *
+ * This logic should be refactored to take place entirely
+ * in the backend. This is being discussed in
+ * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50300
+ */
+ const updateReleaseResponse = await gqClient.mutate({
+ mutation: updateReleaseMutation,
+ variables: getters.releaseUpdateMutatationVariables,
+ });
+
+ checkForErrorsAsData(
+ updateReleaseResponse,
+ 'releaseUpdate',
+ `Something went wrong while updating release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
+ );
+
+ // Delete all links currently associated with this Release
+ await Promise.all(
+ getters.releaseLinksToDelete.map(({ id }) => deleteReleaseLinks({ state, id })),
+ );
+
+ // Create a new link for each link in the form
+ await Promise.all(
+ getters.releaseLinksToCreate.map((link) => createReleaseLink({ state, link })),
+ );
+
+ dispatch('receiveSaveReleaseSuccess', state.release._links.self);
+ } catch (error) {
+ commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
+ createFlash(s__('Release|Something went wrong while saving the release details.'));
+ }
+};
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 831037c8861..d83ec05872a 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -103,3 +103,39 @@ export const isValid = (_state, getters) => {
const errors = getters.validationErrors;
return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty;
};
+
+/** Returns all the variables for a `releaseUpdate` GraphQL mutation */
+export const releaseUpdateMutatationVariables = (state) => {
+ const name = state.release.name?.trim().length > 0 ? state.release.name.trim() : null;
+
+ // Milestones may be either a list of milestone objects OR just a list
+ // of milestone titles. The GraphQL mutation requires only the titles be sent.
+ const milestones = (state.release.milestones || []).map((m) => m.title || m);
+
+ return {
+ input: {
+ projectPath: state.projectPath,
+ tagName: state.release.tagName,
+ name,
+ description: state.release.description,
+ milestones,
+ },
+ };
+};
+
+/** Returns all the variables for a `releaseCreate` GraphQL mutation */
+export const releaseCreateMutatationVariables = (state, getters) => {
+ return {
+ input: {
+ ...getters.releaseUpdateMutatationVariables.input,
+ ref: state.createFrom,
+ assets: {
+ links: getters.releaseLinksToCreate.map(({ name, url, linkType }) => ({
+ name,
+ url,
+ linkType: linkType.toUpperCase(),
+ })),
+ },
+ },
+ };
+};
diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js
index f1add54626a..00be25f089b 100644
--- a/app/assets/javascripts/releases/stores/modules/index/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/index/actions.js
@@ -1,45 +1,21 @@
-import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import {
- normalizeHeaders,
- parseIntPagination,
- convertObjectPropsToCamelCase,
-} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import allReleasesQuery from '~/releases/queries/all_releases.query.graphql';
-import { PAGE_SIZE } from '../../../constants';
-import { gqClient, convertAllReleasesGraphQLResponse } from '../../../util';
+import { PAGE_SIZE } from '~/releases/constants';
+import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
+import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util';
import * as types from './mutation_types';
/**
- * Gets a paginated list of releases from the server
+ * Gets a paginated list of releases from the GraphQL endpoint
*
* @param {Object} vuexParams
* @param {Object} actionParams
- * @param {Number} [actionParams.page] The page number of results to fetch
- * (this parameter is only used when fetching results from the REST API)
* @param {String} [actionParams.before] A GraphQL cursor. If provided,
- * the items returned will proceed the provided cursor (this parameter is only
- * used when fetching results from the GraphQL API).
+ * the items returned will proceed the provided cursor.
* @param {String} [actionParams.after] A GraphQL cursor. If provided,
- * the items returned will follow the provided cursor (this parameter is only
- * used when fetching results from the GraphQL API).
+ * the items returned will follow the provided cursor.
*/
-export const fetchReleases = ({ dispatch, rootGetters }, { page = 1, before, after }) => {
- if (rootGetters.useGraphQLEndpoint) {
- dispatch('fetchReleasesGraphQl', { before, after });
- } else {
- dispatch('fetchReleasesRest', { page });
- }
-};
-
-/**
- * Gets a paginated list of releases from the GraphQL endpoint
- */
-export const fetchReleasesGraphQl = (
- { dispatch, commit, state },
- { before = null, after = null },
-) => {
+export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => {
commit(types.REQUEST_RELEASES);
const { sort, orderBy } = state.sorting;
@@ -55,7 +31,7 @@ export const fetchReleasesGraphQl = (
paginationParams = { first: PAGE_SIZE, after };
} else {
throw new Error(
- 'Both a `before` and an `after` parameter were provided to fetchReleasesGraphQl. These parameters cannot be used together.',
+ 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.',
);
}
@@ -69,33 +45,11 @@ export const fetchReleasesGraphQl = (
},
})
.then((response) => {
- const { data, paginationInfo: graphQlPageInfo } = convertAllReleasesGraphQLResponse(response);
+ const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response);
commit(types.RECEIVE_RELEASES_SUCCESS, {
data,
- graphQlPageInfo,
- });
- })
- .catch(() => dispatch('receiveReleasesError'));
-};
-
-/**
- * Gets a paginated list of releases from the REST endpoint
- */
-export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
- commit(types.REQUEST_RELEASES);
-
- const { sort, orderBy } = state.sorting;
-
- api
- .releases(state.projectId, { page, sort, order_by: orderBy })
- .then(({ data, headers }) => {
- const restPageInfo = parseIntPagination(normalizeHeaders(headers));
- const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
-
- commit(types.RECEIVE_RELEASES_SUCCESS, {
- data: camelCasedReleases,
- restPageInfo,
+ pageInfo,
});
})
.catch(() => dispatch('receiveReleasesError'));
diff --git a/app/assets/javascripts/releases/stores/modules/index/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js
index e1aaa2e2a19..55a8a488be8 100644
--- a/app/assets/javascripts/releases/stores/modules/index/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/index/mutations.js
@@ -17,12 +17,11 @@ export default {
* @param {Object} state
* @param {Object} resp
*/
- [types.RECEIVE_RELEASES_SUCCESS](state, { data, restPageInfo, graphQlPageInfo }) {
+ [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
state.hasError = false;
state.isLoading = false;
state.releases = data;
- state.restPageInfo = restPageInfo;
- state.graphQlPageInfo = graphQlPageInfo;
+ state.pageInfo = pageInfo;
},
/**
@@ -36,8 +35,7 @@ export default {
state.isLoading = false;
state.releases = [];
state.hasError = true;
- state.restPageInfo = {};
- state.graphQlPageInfo = {};
+ state.pageInfo = {};
},
[types.SET_SORTING](state, sorting) {
diff --git a/app/assets/javascripts/releases/stores/modules/index/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js
index 164a496d450..5e1aaab7b58 100644
--- a/app/assets/javascripts/releases/stores/modules/index/state.js
+++ b/app/assets/javascripts/releases/stores/modules/index/state.js
@@ -16,8 +16,7 @@ export default ({
isLoading: false,
hasError: false,
releases: [],
- restPageInfo: {},
- graphQlPageInfo: {},
+ pageInfo: {},
sorting: {
sort: DESCENDING_ORDER,
orderBy: RELEASED_AT,
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index 36c17b5b252..22d5fb4f620 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -1,50 +1,7 @@
import { pick } from 'lodash';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
-import {
- convertObjectPropsToCamelCase,
- convertObjectPropsToSnakeCase,
-} from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
-/**
- * Converts a release object into a JSON object that can sent to the public
- * API to create or update a release.
- * @param {Object} release The release object to convert
- * @param {string} createFrom The ref to create a new tag from, if necessary
- */
-export const releaseToApiJson = (release, createFrom = null) => {
- const name = release.name?.trim().length > 0 ? release.name.trim() : null;
-
- // Milestones may be either a list of milestone objects OR just a list
- // of milestone titles. The API requires only the titles be sent.
- const milestones = (release.milestones || []).map((m) => m.title || m);
-
- return convertObjectPropsToSnakeCase(
- {
- name,
- tagName: release.tagName,
- ref: createFrom,
- description: release.description,
- milestones,
- assets: release.assets,
- },
- { deep: true },
- );
-};
-
-/**
- * Converts a JSON release object returned by the Release API
- * into the structure this Vue application can work with.
- * @param {Object} json The JSON object received from the release API
- */
-export const apiJsonToRelease = (json) => {
- const release = convertObjectPropsToCamelCase(json, { deep: true });
-
- release.milestones = release.milestones || [];
-
- return release;
-};
-
export const gqClient = createGqClient({}, { fetchPolicy: fetchPolicies.NO_CACHE });
const convertScalarProperties = (graphQLRelease) =>
@@ -52,24 +9,37 @@ const convertScalarProperties = (graphQLRelease) =>
'name',
'tagName',
'tagPath',
+ 'description',
'descriptionHtml',
'releasedAt',
'upcomingRelease',
]);
-const convertAssets = (graphQLRelease) => ({
- assets: {
- count: graphQLRelease.assets.count,
- sources: [...graphQLRelease.assets.sources.nodes],
- links: graphQLRelease.assets.links.nodes.map((l) => ({
+const convertAssets = (graphQLRelease) => {
+ let sources = [];
+ if (graphQLRelease.assets.sources?.nodes) {
+ sources = [...graphQLRelease.assets.sources.nodes];
+ }
+
+ let links = [];
+ if (graphQLRelease.assets.links?.nodes) {
+ links = graphQLRelease.assets.links.nodes.map((l) => ({
...l,
linkType: l.linkType?.toLowerCase(),
- })),
- },
-});
+ }));
+ }
+
+ return {
+ assets: {
+ count: graphQLRelease.assets.count,
+ sources,
+ links,
+ },
+ };
+};
const convertEvidences = (graphQLRelease) => ({
- evidences: graphQLRelease.evidences.nodes.map((e) => e),
+ evidences: (graphQLRelease.evidences?.nodes ?? []).map((e) => ({ ...e })),
});
const convertLinks = (graphQLRelease) => ({
@@ -100,18 +70,19 @@ const convertMilestones = (graphQLRelease) => ({
...m,
webUrl: m.webPath,
webPath: undefined,
- issueStats: {
- total: m.stats.totalIssuesCount,
- closed: m.stats.closedIssuesCount,
- },
+ issueStats: m.stats
+ ? {
+ total: m.stats.totalIssuesCount,
+ closed: m.stats.closedIssuesCount,
+ }
+ : {},
stats: undefined,
})),
});
/**
* Converts a single release object fetched from GraphQL
- * into a release object that matches the shape of the REST API
- * (the same shape that is returned by `apiJsonToRelease` above.)
+ * into a release object that matches the general structure of the REST API
*
* @param graphQLRelease The release object returned from a GraphQL query
*/
diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
index e5980f1e539..dabfb623f43 100644
--- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
+++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue
@@ -6,7 +6,7 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import ReportLink from '~/reports/components/report_link.vue';
-import { STATUS_SUCCESS } from '~/reports/constants';
+import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/reports/constants';
import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants';
export default {
@@ -21,7 +21,8 @@ export default {
props: {
status: {
type: String,
- required: true,
+ required: false,
+ default: STATUS_NEUTRAL,
},
issue: {
type: Object,
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 d293165ef2f..3287ba691bf 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
@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { componentNames } from '~/reports/components/issue_body';
import ReportSection from '~/reports/components/report_section.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createStore from './store';
export default {
@@ -12,26 +11,12 @@ export default {
components: {
ReportSection,
},
- mixins: [glFeatureFlagsMixin()],
props: {
- headPath: {
- type: String,
- required: true,
- },
- headBlobPath: {
- type: String,
- required: true,
- },
basePath: {
type: String,
required: false,
default: null,
},
- baseBlobPath: {
- type: String,
- required: false,
- default: null,
- },
codequalityReportsPath: {
type: String,
required: false,
@@ -55,9 +40,6 @@ export default {
created() {
this.setPaths({
basePath: this.basePath,
- headPath: this.headPath,
- baseBlobPath: this.baseBlobPath,
- headBlobPath: this.headBlobPath,
reportsPath: this.codequalityReportsPath,
helpPath: this.codequalityHelpPath,
});
diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/reports/codequality_report/store/actions.js
index ddd1747899f..e3238207af2 100644
--- a/app/assets/javascripts/reports/codequality_report/store/actions.js
+++ b/app/assets/javascripts/reports/codequality_report/store/actions.js
@@ -1,34 +1,23 @@
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
-import { parseCodeclimateMetrics, doCodeClimateComparison } from './utils/codequality_comparison';
+import { parseCodeclimateMetrics } from './utils/codequality_parser';
export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths);
-export const fetchReports = ({ state, dispatch, commit }, diffFeatureFlagEnabled) => {
+export const fetchReports = ({ state, dispatch, commit }) => {
commit(types.REQUEST_REPORTS);
- if (diffFeatureFlagEnabled) {
- return axios
- .get(state.reportsPath)
- .then(({ data }) => {
- return dispatch('receiveReportsSuccess', {
- newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath),
- resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath),
- });
- })
- .catch((error) => dispatch('receiveReportsError', error));
- }
if (!state.basePath) {
return dispatch('receiveReportsError');
}
- return Promise.all([axios.get(state.headPath), axios.get(state.basePath)])
- .then((results) =>
- doCodeClimateComparison(
- parseCodeclimateMetrics(results[0].data, state.headBlobPath),
- parseCodeclimateMetrics(results[1].data, state.baseBlobPath),
- ),
- )
- .then((data) => dispatch('receiveReportsSuccess', data))
+ return axios
+ .get(state.reportsPath)
+ .then(({ data }) => {
+ return dispatch('receiveReportsSuccess', {
+ newIssues: parseCodeclimateMetrics(data.new_errors, state.headBlobPath),
+ resolvedIssues: parseCodeclimateMetrics(data.resolved_errors, state.baseBlobPath),
+ });
+ })
.catch((error) => dispatch('receiveReportsError', error));
};
diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/reports/codequality_report/store/mutations.js
index 095e6637966..8edeb6cc976 100644
--- a/app/assets/javascripts/reports/codequality_report/store/mutations.js
+++ b/app/assets/javascripts/reports/codequality_report/store/mutations.js
@@ -3,9 +3,6 @@ import * as types from './mutation_types';
export default {
[types.SET_PATHS](state, paths) {
state.basePath = paths.basePath;
- state.headPath = paths.headPath;
- state.baseBlobPath = paths.baseBlobPath;
- state.headBlobPath = paths.headBlobPath;
state.reportsPath = paths.reportsPath;
state.helpPath = paths.helpPath;
},
diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
index b252c8c9817..a794f5f0577 100644
--- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_comparison.js
+++ b/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js
@@ -1,5 +1,3 @@
-import CodeQualityComparisonWorker from '../../workers/codequality_comparison_worker';
-
export const parseCodeclimateMetrics = (issues = [], path = '') => {
return issues.map((issue) => {
const parsedIssue = {
@@ -27,17 +25,3 @@ export const parseCodeclimateMetrics = (issues = [], path = '') => {
return parsedIssue;
});
};
-
-export const doCodeClimateComparison = (headIssues, baseIssues) => {
- // Do these comparisons in worker threads to avoid blocking the main thread
- return new Promise((resolve, reject) => {
- const worker = new CodeQualityComparisonWorker();
- worker.addEventListener('message', ({ data }) =>
- data.newIssues && data.resolvedIssues ? resolve(data) : reject(data),
- );
- worker.postMessage({
- headIssues,
- baseIssues,
- });
- });
-};
diff --git a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js b/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js
deleted file mode 100644
index ae389d266f8..00000000000
--- a/app/assets/javascripts/reports/codequality_report/workers/codequality_comparison_worker.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import { differenceBy } from 'lodash';
-
-const KEY_TO_FILTER_BY = 'fingerprint';
-
-// eslint-disable-next-line no-restricted-globals
-self.addEventListener('message', (e) => {
- const { data } = e;
-
- if (data === undefined) {
- return null;
- }
-
- const { headIssues, baseIssues } = data;
-
- if (!headIssues || !baseIssues) {
- // eslint-disable-next-line no-restricted-globals
- return self.postMessage({});
- }
-
- // eslint-disable-next-line no-restricted-globals
- self.postMessage({
- newIssues: differenceBy(headIssues, baseIssues, KEY_TO_FILTER_BY),
- resolvedIssues: differenceBy(baseIssues, headIssues, KEY_TO_FILTER_BY),
- });
-
- // eslint-disable-next-line no-restricted-globals
- return self.close();
-});
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 12b5cb9f207..7a490210f0b 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -190,10 +190,14 @@ export default {
<status-icon :status="statusIconName" :size="24" class="align-self-center" />
<div class="media-body d-flex flex-align-self-center align-items-center">
<div data-testid="report-section-code-text" class="js-code-text code-text">
- <div>
- {{ headerText }}
+ <div class="gl-display-flex gl-align-items-center">
+ <p class="gl-line-height-normal gl-m-0">{{ headerText }}</p>
<slot :name="slotName"></slot>
- <popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" />
+ <popover
+ v-if="hasPopover"
+ :options="popoverOptions"
+ class="gl-ml-2 gl-display-inline-flex"
+ />
</div>
<slot name="sub-heading"></slot>
</div>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 58b42fb7859..a9701c8f8aa 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -3,22 +3,21 @@ 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 { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
import createFlash from '~/flash';
import { __ } from '~/locale';
import blobInfoQuery from '../queries/blob_info.query.graphql';
-import projectPathQuery from '../queries/project_path.query.graphql';
+import BlobHeaderEdit from './blob_header_edit.vue';
export default {
components: {
BlobHeader,
+ BlobHeaderEdit,
BlobContent,
GlLoadingIcon,
},
apollo: {
- projectPath: {
- query: projectPathQuery,
- },
- blobInfo: {
+ project: {
query: blobInfoQuery,
variables() {
return {
@@ -26,6 +25,11 @@ export default {
filePath: this.path,
};
},
+ result() {
+ this.switchViewer(
+ this.hasRichViewer && !window.location.hash ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
+ );
+ },
error() {
createFlash({ message: __('An error occurred while loading the file. Please try again.') });
},
@@ -41,43 +45,70 @@ export default {
type: String,
required: true,
},
+ projectPath: {
+ 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: '',
+ activeViewerType: SIMPLE_BLOB_VIEWER,
+ project: {
+ repository: {
+ blobs: {
+ nodes: [
+ {
+ name: '',
+ size: '',
+ rawTextBlob: '',
+ type: '',
+ fileType: '',
+ tooLarge: false,
+ path: '',
+ editBlobPath: '',
+ ideEditPath: '',
+ storedExternally: false,
+ rawPath: '',
+ externalStorageUrl: '',
+ replacePath: '',
+ deletePath: '',
+ canLock: false,
+ isLocked: false,
+ lockLink: '',
+ canModifyBlob: true,
+ forkPath: '',
+ simpleViewer: {},
+ richViewer: null,
+ },
+ ],
+ },
+ },
},
};
},
computed: {
isLoading() {
- return this.$apollo.queries.blobInfo.loading;
+ return this.$apollo.queries.project.loading;
},
- viewer() {
- const { fileType, tooLarge, type } = this.blobInfo;
+ blobInfo() {
+ const nodes = this.project?.repository?.blobs?.nodes;
- return { fileType, tooLarge, type };
+ return nodes[0] || {};
+ },
+ viewer() {
+ const { richViewer, simpleViewer } = this.blobInfo;
+ return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer;
+ },
+ hasRichViewer() {
+ return Boolean(this.blobInfo.richViewer);
+ },
+ hasRenderError() {
+ return Boolean(this.viewer.renderError);
+ },
+ },
+ methods: {
+ switchViewer(newViewer) {
+ this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
},
},
};
@@ -86,11 +117,21 @@ export default {
<template>
<div>
<gl-loading-icon v-if="isLoading" />
- <div v-if="blobInfo && !isLoading">
- <blob-header :blob="blobInfo" />
+ <div v-if="blobInfo && !isLoading" class="file-holder">
+ <blob-header
+ :blob="blobInfo"
+ :hide-viewer-switcher="!hasRichViewer"
+ :active-viewer-type="viewer.type"
+ :has-render-error="hasRenderError"
+ @viewer-changed="switchViewer"
+ >
+ <template #actions>
+ <blob-header-edit :edit-path="blobInfo.editBlobPath" />
+ </template>
+ </blob-header>
<blob-content
:blob="blobInfo"
- :content="blobInfo.rawBlob"
+ :content="blobInfo.rawTextBlob"
:is-raw-content="true"
:active-viewer="viewer"
:loading="false"
diff --git a/app/assets/javascripts/repository/components/blob_header_edit.vue b/app/assets/javascripts/repository/components/blob_header_edit.vue
new file mode 100644
index 00000000000..f3649895736
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_header_edit.vue
@@ -0,0 +1,25 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ edit: __('Edit'),
+ },
+ components: {
+ GlButton,
+ },
+ props: {
+ editPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button category="primary" variant="confirm" class="gl-mr-3" :href="editPath">
+ {{ $options.i18n.edit }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue
index d2ff01e7fc1..aa087d4c631 100644
--- a/app/assets/javascripts/repository/components/upload_blob_modal.vue
+++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue
@@ -93,7 +93,7 @@ export default {
text: PRIMARY_OPTIONS_TEXT,
attributes: [
{
- variant: 'success',
+ variant: 'confirm',
loading: this.loading,
disabled: !this.formCompleted || this.loading,
},
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 3a9a2adb417..501ae7e9f2f 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -4,6 +4,7 @@ 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 PerformancePlugin from '~/performance/vue_performance_plugin';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue';
@@ -17,6 +18,10 @@ import createRouter from './router';
import { updateFormAction } from './utils/dom';
import { setTitle } from './utils/title';
+Vue.use(PerformancePlugin, {
+ components: ['SimpleViewer', 'BlobContent'],
+});
+
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue
index 27af398be09..2645b294096 100644
--- a/app/assets/javascripts/repository/pages/blob.vue
+++ b/app/assets/javascripts/repository/pages/blob.vue
@@ -13,10 +13,14 @@ export default {
type: String,
required: true,
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
},
};
</script>
<template>
- <blob-content-viewer :path="path" />
+ <blob-content-viewer :path="path" :project-path="projectPath" />
</template>
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index e0bbf12f3eb..07c076af54b 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -2,28 +2,32 @@ 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
+ blobs(paths: [$filePath]) {
+ nodes {
+ webPath
+ name
+ size
+ rawSize
+ rawTextBlob
+ fileType
+ path
+ editBlobPath
+ storedExternally
+ rawPath
+ replacePath
+ simpleViewer {
+ fileType
+ tooLarge
+ type
+ renderError
+ }
+ richViewer {
+ fileType
+ tooLarge
+ type
+ renderError
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index c7f7451fb55..6637d03a7a4 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -20,6 +20,7 @@ export default function createRouter(base, baseRef) {
component: BlobPage,
props: (route) => ({
path: route.params.path,
+ projectPath: base,
}),
};
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
new file mode 100644
index 00000000000..dd4fff3a77a
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+
+const badge = {
+ [INSTANCE_TYPE]: {
+ variant: 'success',
+ text: s__('Runners|shared'),
+ },
+ [GROUP_TYPE]: {
+ variant: 'success',
+ text: s__('Runners|group'),
+ },
+ [PROJECT_TYPE]: {
+ variant: 'info',
+ text: s__('Runners|specific'),
+ },
+};
+
+export default {
+ components: {
+ GlBadge,
+ },
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ variant() {
+ return badge[this.type]?.variant;
+ },
+ text() {
+ return badge[this.type]?.text;
+ },
+ },
+};
+</script>
+<template>
+ <gl-badge v-if="text" :variant="variant" v-bind="$attrs">
+ {{ text }}
+ </gl-badge>
+</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
new file mode 100644
index 00000000000..de3a3fda47e
--- /dev/null
+++ b/app/assets/javascripts/runner/constants.js
@@ -0,0 +1,11 @@
+import { s__ } from '~/locale';
+
+export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
+
+export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
+
+// CiRunnerType
+
+export const INSTANCE_TYPE = 'INSTANCE_TYPE';
+export const GROUP_TYPE = 'GROUP_TYPE';
+export const PROJECT_TYPE = 'PROJECT_TYPE';
diff --git a/app/assets/javascripts/runner/graphql/get_runner.query.graphql b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
new file mode 100644
index 00000000000..d209313d4df
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner.query.graphql
@@ -0,0 +1,6 @@
+query getRunner($id: CiRunnerID!) {
+ runner(id: $id) {
+ id
+ runnerType
+ }
+}
diff --git a/app/assets/javascripts/runner/runner_details/constants.js b/app/assets/javascripts/runner/runner_details/constants.js
deleted file mode 100644
index bb57e85fa8a..00000000000
--- a/app/assets/javascripts/runner/runner_details/constants.js
+++ /dev/null
@@ -1,3 +0,0 @@
-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
index cbf70640ef7..05e6f86869d 100644
--- a/app/assets/javascripts/runner/runner_details/index.js
+++ b/app/assets/javascripts/runner/runner_details/index.js
@@ -1,7 +1,11 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import RunnerDetailsApp from './runner_details_app.vue';
-export const initRunnerDetail = (selector = '#js-runner-detail') => {
+Vue.use(VueApollo);
+
+export const initRunnerDetail = (selector = '#js-runner-details') => {
const el = document.querySelector(selector);
if (!el) {
@@ -10,8 +14,18 @@ export const initRunnerDetail = (selector = '#js-runner-detail') => {
const { runnerId } = el.dataset;
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+ });
+
return new Vue({
el,
+ apolloProvider,
render(h) {
return h(RunnerDetailsApp, {
props: {
diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
index 1b1485bfe72..4736e547cb9 100644
--- a/app/assets/javascripts/runner/runner_details/runner_details_app.vue
+++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue
@@ -1,9 +1,15 @@
<script>
-import { I18N_TITLE } from './constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import RunnerTypeBadge from '../components/runner_type_badge.vue';
+import { I18N_DETAILS_TITLE, RUNNER_ENTITY_TYPE } from '../constants';
+import getRunnerQuery from '../graphql/get_runner.query.graphql';
export default {
+ components: {
+ RunnerTypeBadge,
+ },
i18n: {
- I18N_TITLE,
+ I18N_DETAILS_TITLE,
},
props: {
runnerId: {
@@ -11,10 +17,27 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ runner: {},
+ };
+ },
+ apollo: {
+ runner: {
+ query: getRunnerQuery,
+ variables() {
+ return {
+ id: convertToGraphQLId(RUNNER_ENTITY_TYPE, this.runnerId),
+ };
+ },
+ },
+ },
};
</script>
<template>
<h2 class="page-title">
- {{ sprintf($options.i18n.I18N_TITLE, { runner_id: runnerId }) }}
+ {{ sprintf($options.i18n.I18N_DETAILS_TITLE, { runner_id: runnerId }) }}
+
+ <runner-type-badge v-if="runner.runnerType" :type="runner.runnerType" />
</h2>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue
index 4a3f988296c..2110af1522b 100644
--- a/app/assets/javascripts/security_configuration/components/configuration_table.vue
+++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue
@@ -1,6 +1,7 @@
<script>
import { GlLink, GlTable, GlAlert } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
+import ManageViaMR from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
@@ -11,8 +12,8 @@ import {
REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
-import ManageSast from './manage_sast.vue';
-import { scanners } from './scanners_constants';
+
+import { scanners } from './constants';
import Upgrade from './upgrade.vue';
const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!';
@@ -40,7 +41,7 @@ export default {
},
getComponentForItem(item) {
const COMPONENTS = {
- [REPORT_TYPE_SAST]: ManageSast,
+ [REPORT_TYPE_SAST]: ManageViaMR,
[REPORT_TYPE_DAST]: Upgrade,
[REPORT_TYPE_DAST_PROFILES]: Upgrade,
[REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade,
@@ -49,7 +50,6 @@ export default {
[REPORT_TYPE_API_FUZZING]: Upgrade,
[REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade,
};
-
return COMPONENTS[item.type];
},
},
@@ -95,7 +95,12 @@ export default {
</template>
<template #cell(manage)="{ item }">
- <component :is="getComponentForItem(item)" :data-testid="item.type" @error="onError" />
+ <component
+ :is="getComponentForItem(item)"
+ :feature="item"
+ :data-testid="item.type"
+ @error="onError"
+ />
</template>
</gl-table>
</div>
diff --git a/app/assets/javascripts/security_configuration/components/scanners_constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 9846df0b4bf..3cdcac4c0b4 100644
--- a/app/assets/javascripts/security_configuration/components/scanners_constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -1,6 +1,7 @@
import { helpPagePath } from '~/helpers/help_page_helper';
import { __, s__ } from '~/locale';
+import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_DAST,
@@ -134,3 +135,18 @@ export const scanners = [
type: REPORT_TYPE_LICENSE_COMPLIANCE,
},
];
+
+export const featureToMutationMap = {
+ [REPORT_TYPE_SAST]: {
+ mutationId: 'configureSast',
+ getMutationPayload: (projectPath) => ({
+ mutation: configureSastMutation,
+ variables: {
+ input: {
+ projectPath,
+ configuration: { global: [], pipeline: [], analyzers: [] },
+ },
+ },
+ }),
+ },
+};
diff --git a/app/assets/javascripts/security_configuration/components/feature_card.vue b/app/assets/javascripts/security_configuration/components/feature_card.vue
new file mode 100644
index 00000000000..518a6ede3de
--- /dev/null
+++ b/app/assets/javascripts/security_configuration/components/feature_card.vue
@@ -0,0 +1,150 @@
+<script>
+import { GlButton, GlCard, GlIcon, GlLink } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlCard,
+ GlIcon,
+ GlLink,
+ ManageViaMr,
+ },
+ props: {
+ feature: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ available() {
+ return this.feature.available;
+ },
+ enabled() {
+ return this.available && this.feature.configured;
+ },
+ hasStatus() {
+ return !this.available || typeof this.feature.configured === 'boolean';
+ },
+ shortName() {
+ return this.feature.shortName ?? this.feature.name;
+ },
+ configurationButton() {
+ const button = this.enabled
+ ? {
+ text: this.$options.i18n.configureFeature,
+ category: 'secondary',
+ }
+ : {
+ text: this.$options.i18n.enableFeature,
+ category: 'primary',
+ };
+
+ button.text = sprintf(button.text, { feature: this.shortName });
+
+ return button;
+ },
+ showManageViaMr() {
+ const { available, configured, canEnableByMergeRequest } = this.feature;
+ return canEnableByMergeRequest && available && !configured;
+ },
+ cardClasses() {
+ return { 'gl-bg-gray-10': !this.available };
+ },
+ statusClasses() {
+ const { enabled } = this;
+
+ return {
+ 'gl-ml-auto': true,
+ 'gl-flex-shrink-0': true,
+ 'gl-text-gray-500': !enabled,
+ 'gl-text-green-500': enabled,
+ };
+ },
+ hasSecondary() {
+ const { name, description, configurationText } = this.feature.secondary ?? {};
+ return Boolean(name && description && configurationText);
+ },
+ },
+ i18n: {
+ enabled: s__('SecurityConfiguration|Enabled'),
+ notEnabled: s__('SecurityConfiguration|Not enabled'),
+ availableWith: s__('SecurityConfiguration|Available with Ultimate'),
+ configurationGuide: s__('SecurityConfiguration|Configuration guide'),
+ configureFeature: s__('SecurityConfiguration|Configure %{feature}'),
+ enableFeature: s__('SecurityConfiguration|Enable %{feature}'),
+ learnMore: __('Learn more'),
+ },
+};
+</script>
+
+<template>
+ <gl-card :class="cardClasses">
+ <div class="gl-display-flex gl-align-items-baseline">
+ <h3 class="gl-font-lg gl-m-0 gl-mr-3">{{ feature.name }}</h3>
+
+ <div :class="statusClasses" data-testid="feature-status">
+ <template v-if="hasStatus">
+ <template v-if="enabled">
+ <gl-icon name="check-circle-filled" />
+ <span class="gl-text-green-700">{{ $options.i18n.enabled }}</span>
+ </template>
+
+ <template v-else-if="available">
+ {{ $options.i18n.notEnabled }}
+ </template>
+
+ <template v-else>
+ {{ $options.i18n.availableWith }}
+ </template>
+ </template>
+ </div>
+ </div>
+
+ <p class="gl-mb-0 gl-mt-5">
+ {{ feature.description }}
+ <gl-link :href="feature.helpPath">{{ $options.i18n.learnMore }}</gl-link>
+ </p>
+
+ <template v-if="available">
+ <gl-button
+ v-if="feature.configurationPath"
+ :href="feature.configurationPath"
+ variant="confirm"
+ :category="configurationButton.category"
+ class="gl-mt-5"
+ >
+ {{ configurationButton.text }}
+ </gl-button>
+
+ <manage-via-mr
+ v-else-if="showManageViaMr"
+ :feature="feature"
+ variant="confirm"
+ category="primary"
+ class="gl-mt-5"
+ />
+
+ <gl-button v-else icon="external-link" :href="feature.configurationHelpPath" class="gl-mt-5">
+ {{ $options.i18n.configurationGuide }}
+ </gl-button>
+ </template>
+
+ <div v-if="hasSecondary" data-testid="secondary-feature">
+ <h4 class="gl-font-base gl-m-0 gl-mt-6">{{ feature.secondary.name }}</h4>
+
+ <p class="gl-mb-0 gl-mt-5">{{ feature.secondary.description }}</p>
+
+ <gl-button
+ v-if="available && feature.secondary.configurationPath"
+ :href="feature.secondary.configurationPath"
+ variant="confirm"
+ category="secondary"
+ class="gl-mt-5"
+ >
+ {{ feature.secondary.configurationText }}
+ </gl-button>
+ </div>
+ </gl-card>
+</template>
diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue
deleted file mode 100644
index 8a8827b41cd..00000000000
--- a/app/assets/javascripts/security_configuration/components/manage_sast.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { redirectTo } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
-import configureSastMutation from '~/security_configuration/graphql/configure_sast.mutation.graphql';
-
-export default {
- components: {
- GlButton,
- },
- inject: {
- projectPath: {
- from: 'projectPath',
- default: '',
- },
- },
- data() {
- return {
- isLoading: false,
- };
- },
- methods: {
- async mutate() {
- this.isLoading = true;
- try {
- const { data } = await this.$apollo.mutate({
- mutation: configureSastMutation,
- variables: {
- input: {
- projectPath: this.projectPath,
- configuration: { global: [], pipeline: [], analyzers: [] },
- },
- },
- });
- const { errors, successPath } = data.configureSast;
-
- if (errors.length > 0) {
- throw new Error(errors[0]);
- }
-
- if (!successPath) {
- throw new Error(s__('SecurityConfiguration|SAST merge request creation mutation failed'));
- }
-
- redirectTo(successPath);
- } catch (e) {
- this.$emit('error', e.message);
- this.isLoading = false;
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{
- s__('SecurityConfiguration|Configure via merge request')
- }}</gl-button>
-</template>
diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue
index 518eb57731d..2541c29224a 100644
--- a/app/assets/javascripts/security_configuration/components/upgrade.vue
+++ b/app/assets/javascripts/security_configuration/components/upgrade.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
-import { UPGRADE_CTA } from './scanners_constants';
+import { UPGRADE_CTA } from './constants';
export default {
components: {
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 bff90254c04..c754af5c7de 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
@@ -67,11 +67,6 @@ export default {
required: false,
default: '',
},
- canSetUserAvailability: {
- type: Boolean,
- required: false,
- default: false,
- },
currentClearStatusAfter: {
type: String,
required: false,
@@ -292,7 +287,7 @@ export default {
</button>
</span>
</div>
- <div v-if="canSetUserAvailability" class="form-group">
+ <div class="form-group">
<div class="gl-display-flex">
<gl-form-checkbox
v-model="availability"
diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js
index 467cd321fb8..3ca9288b156 100644
--- a/app/assets/javascripts/shared/milestones/form.js
+++ b/app/assets/javascripts/shared/milestones/form.js
@@ -1,11 +1,12 @@
import $ from 'jquery';
-import DueDateSelectors from '../../due_date_select';
+import initDatePicker from '~/behaviors/date_picker';
import GLForm from '../../gl_form';
import ZenMode from '../../zen_mode';
export default (initGFM = true) => {
new ZenMode(); // eslint-disable-line no-new
- new DueDateSelectors(); // eslint-disable-line no-new
+ initDatePicker();
+
// eslint-disable-next-line no-new
new GLForm($('.milestone-form'), {
emojis: true,
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
index f98798582c1..e7ef731eed8 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -1,6 +1,7 @@
<script>
-import actionCable from '~/actioncable_consumer';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import produce from 'immer';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { IssuableType } from '~/issue_show/constants';
import { assigneesQueries } from '~/sidebar/constants';
export default {
@@ -12,60 +13,62 @@ export default {
required: false,
default: null,
},
- issuableIid: {
+ issuableType: {
type: String,
required: true,
},
- projectPath: {
- type: String,
+ issuableId: {
+ type: Number,
required: true,
},
- issuableType: {
- type: String,
+ queryVariables: {
+ type: Object,
required: true,
},
},
+ computed: {
+ issuableClass() {
+ return Object.keys(IssuableType).find((key) => IssuableType[key] === this.issuableType);
+ },
+ },
apollo: {
- workspace: {
+ issuable: {
query() {
return assigneesQueries[this.issuableType].query;
},
variables() {
- return {
- iid: this.issuableIid,
- fullPath: this.projectPath,
- };
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.workspace?.issuable;
},
- result(data) {
- if (this.mediator) {
- this.handleFetchResult(data);
- }
+ subscribeToMore: {
+ document() {
+ return assigneesQueries[this.issuableType].subscription;
+ },
+ variables() {
+ return {
+ issuableId: convertToGraphQLId(this.issuableClass, this.issuableId),
+ };
+ },
+ updateQuery(prev, { subscriptionData }) {
+ if (prev && subscriptionData?.data?.issuableAssigneesUpdated) {
+ const data = produce(prev, (draftData) => {
+ draftData.workspace.issuable.assignees.nodes =
+ subscriptionData.data.issuableAssigneesUpdated.assignees.nodes;
+ });
+ if (this.mediator) {
+ this.handleFetchResult(data);
+ }
+ return data;
+ }
+ return prev;
+ },
},
},
},
- mounted() {
- this.initActionCablePolling();
- },
- beforeDestroy() {
- this.$options.subscription.unsubscribe();
- },
methods: {
- received(data) {
- if (data.event === 'updated') {
- this.$apollo.queries.workspace.refetch();
- }
- },
- initActionCablePolling() {
- this.$options.subscription = actionCable.subscriptions.create(
- {
- channel: 'IssuesChannel',
- project_path: this.projectPath,
- iid: this.issuableIid,
- },
- { received: this.received },
- );
- },
- handleFetchResult({ data }) {
+ handleFetchResult(data) {
const { nodes } = data.workspace.issuable.assignees;
const assignees = nodes.map((n) => ({
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index e93aced12f3..80caebad39d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -60,7 +60,7 @@ export default {
v-else
:users="users"
:issuable-type="issuableType"
- class="gl-mt-2 hide-collapsed"
+ class="gl-text-gray-800 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 e15ea595190..ca95599742a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -44,6 +44,10 @@ export default {
type: String,
required: true,
},
+ issuableId: {
+ type: Number,
+ required: true,
+ },
assigneeAvailabilityStatus: {
type: Object,
required: false,
@@ -61,6 +65,12 @@ export default {
// Note: Realtime is only available on issues right now, future support for MR wil be built later.
return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
},
+ queryVariables() {
+ return {
+ iid: this.issuableIid,
+ fullPath: this.projectPath,
+ };
+ },
relativeUrlRoot() {
return gon.relative_url_root ?? '';
},
@@ -121,9 +131,9 @@ export default {
<div>
<assignees-realtime
v-if="shouldEnableRealtime"
- :issuable-iid="issuableIid"
- :project-path="projectPath"
:issuable-type="issuableType"
+ :issuable-id="issuableId"
+ :query-variables="queryVariables"
: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 78cac989850..932be7addc0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -1,19 +1,17 @@
<script>
-import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import { GlDropdownItem } 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 { assigneesQueries } from '~/sidebar/constants';
+import UserSelect from '~/vue_shared/components/user_select/user_select.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,
@@ -33,23 +31,16 @@ export default {
components: {
SidebarEditableItem,
IssuableAssignees,
- MultiSelectDropdown,
GlDropdownItem,
- GlDropdownDivider,
- GlSearchBoxByType,
- GlLoadingIcon,
SidebarInviteMembers,
- SidebarParticipant,
SidebarAssigneesRealtime,
+ UserSelect,
},
mixins: [glFeatureFlagsMixin()],
inject: {
directlyInviteMembers: {
default: false,
},
- indirectlyInviteMembers: {
- default: false,
- },
},
props: {
iid: {
@@ -73,20 +64,21 @@ export default {
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
- multipleAssignees: {
- type: Boolean,
+ issuableId: {
+ type: Number,
required: false,
- default: true,
+ default: null,
+ },
+ allowMultipleAssignees: {
+ type: Boolean,
+ required: true,
},
},
data() {
return {
- search: '',
issuable: {},
- searchUsers: [],
selected: [],
isSettingAssignees: false,
- isSearching: false,
isDirty: false,
};
},
@@ -104,51 +96,13 @@ export default {
result({ data }) {
const issuable = data.workspace?.issuable;
if (issuable) {
- this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
+ this.selected = cloneDeep(issuable.assignees.nodes);
}
},
error() {
createFlash({ message: __('An error occurred while fetching participants.') });
},
},
- searchUsers: {
- query: searchUsers,
- variables() {
- return {
- fullPath: this.fullPath,
- search: this.search,
- };
- },
- update(data) {
- const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || [];
- 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;
- }, filteredParticipants);
-
- return mergedSearchResults;
- },
- debounce: ASSIGNEES_DEBOUNCE_DELAY,
- skip() {
- return this.isSearchEmpty;
- },
- error() {
- createFlash({ message: __('An error occurred while searching users.') });
- this.isSearching = false;
- },
- result() {
- this.isSearching = false;
- },
- },
},
computed: {
shouldEnableRealtime() {
@@ -167,13 +121,6 @@ export default {
: this.issuable?.assignees?.nodes;
return currentAssignees || [];
},
- participants() {
- const users =
- this.isSearchEmpty || this.isSearching
- ? this.issuable?.participants?.nodes
- : this.searchUsers;
- return this.moveCurrentUserToStart(users);
- },
assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
if (!items) {
@@ -181,28 +128,8 @@ export default {
}
return n__('Assignee', '%d Assignees', items.length);
},
- selectedFiltered() {
- if (this.isSearchEmpty || this.isSearching) {
- return this.selected;
- }
-
- const foundUsernames = this.searchUsers.map(({ username }) => username);
- return this.selected.filter(({ username }) => foundUsernames.includes(username));
- },
- unselectedFiltered() {
- return (
- this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) ||
- []
- );
- },
- selectedIsEmpty() {
- return this.selectedFiltered.length === 0;
- },
- selectedUserNames() {
- return this.selected.map(({ username }) => username);
- },
- isSearchEmpty() {
- return this.search === '';
+ isAssigneesLoading() {
+ return !this.initialAssignees && this.$apollo.queries.issuable.loading;
},
currentUser() {
return {
@@ -211,35 +138,9 @@ export default {
avatarUrl: gon?.current_user_avatar_url,
};
},
- isAssigneesLoading() {
- return !this.initialAssignees && this.$apollo.queries.issuable.loading;
- },
- isCurrentUserInParticipants() {
- const isCurrentUser = (user) => user.username === this.currentUser.username;
- return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
- },
- noUsersFound() {
- return !this.isSearchEmpty && this.searchUsers.length === 0;
- },
signedIn() {
return this.currentUser.username !== undefined;
},
- showCurrentUser() {
- return (
- this.signedIn &&
- !this.isCurrentUserInParticipants &&
- (this.isSearchEmpty || this.isSearching)
- );
- },
- },
- watch: {
- // We need to add this watcher to track the moment when user is alredy typing
- // but query is still not started due to debounce
- search(newVal) {
- if (newVal) {
- this.isSearching = true;
- }
- },
},
created() {
assigneesWidget.updateAssignees = this.updateAssignees;
@@ -269,59 +170,15 @@ export default {
this.isSettingAssignees = false;
});
},
- selectAssignee(name) {
- this.isDirty = true;
-
- if (!this.multipleAssignees) {
- this.selected = name ? [name] : [];
- this.collapseWidget();
- 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();
- }
- },
assignSelf() {
- this.updateAssignees(this.currentUser.username);
- },
- clearSelected() {
- this.selected = [];
+ this.updateAssignees([this.currentUser.username]);
},
saveAssignees() {
- this.isDirty = false;
- this.updateAssignees(this.selectedUserNames);
- this.$el.dispatchEvent(hideDropdownEvent);
- },
- isChecked(id) {
- return this.selectedUserNames.includes(id);
- },
- async focusSearch() {
- await this.$nextTick();
- this.$refs.search.focusInput();
- },
- moveCurrentUserToStart(users) {
- if (!users) {
- return [];
- }
- const usersCopy = [...users];
- const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
-
- if (currentUser) {
- const index = usersCopy.indexOf(currentUser);
- usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ if (this.isDirty) {
+ this.isDirty = false;
+ this.updateAssignees(this.selected.map(({ username }) => username));
}
-
- return usersCopy;
+ this.$el.dispatchEvent(hideDropdownEvent);
},
collapseWidget() {
this.$refs.toggle.collapse();
@@ -329,8 +186,17 @@ export default {
expandWidget() {
this.$refs.toggle.expand();
},
- showDivider(list) {
- return list.length > 0 && this.isSearchEmpty;
+ focusSearch() {
+ this.$refs.userSelect.focusSearch();
+ },
+ showError() {
+ createFlash({ message: __('An error occurred while fetching participants.') });
+ },
+ setDirtyState() {
+ this.isDirty = true;
+ if (!this.allowMultipleAssignees) {
+ this.collapseWidget();
+ }
},
},
};
@@ -340,9 +206,9 @@ export default {
<div data-testid="assignees-widget">
<sidebar-assignees-realtime
v-if="shouldEnableRealtime"
- :project-path="fullPath"
- :issuable-iid="iid"
:issuable-type="issuableType"
+ :issuable-id="issuableId"
+ :query-variables="queryVariables"
/>
<sidebar-editable-item
ref="toggle"
@@ -363,86 +229,27 @@ export default {
@expand-widget="expandWidget"
/>
</template>
-
<template #default>
- <multi-select-dropdown
- class="gl-w-full dropdown-menu-user"
+ <user-select
+ ref="userSelect"
+ v-model="selected"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
+ :iid="iid"
+ :full-path="fullPath"
+ :allow-multiple-assignees="allowMultipleAssignees"
+ :current-user="currentUser"
+ :issuable-type="issuableType"
+ class="gl-w-full dropdown-menu-user"
@toggle="collapseWidget"
+ @error="showError"
+ @input="setDirtyState"
>
- <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
- 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)"
- >
- <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)"
- >
- <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="noUsersFound && !isSearching"
- data-testid="empty-results"
- class="gl-pl-6!"
- >
- {{ __('No matching results') }}
- </gl-dropdown-item>
- </template>
- </template>
<template #footer>
- <gl-dropdown-item>
- <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" />
- </gl-dropdown-item>
- </template>
- </multi-select-dropdown>
+ <gl-dropdown-item v-if="directlyInviteMembers">
+ <sidebar-invite-members />
+ </gl-dropdown-item> </template
+ ></user-select>
</template>
</sidebar-editable-item>
</div>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
index 9952c6db582..5c32d03e0d4 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue
@@ -1,51 +1,23 @@
<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',
+ dataTrackEvent: 'click_invite_members',
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>
+ <invite-members-trigger
+ trigger-element="anchor"
+ :display-text="$options.displayText"
+ :event="$options.dataTrackEvent"
+ :label="$options.dataTrackLabel"
+ classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!"
+ />
</template>
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
new file mode 100644
index 00000000000..6a68e914b84
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -0,0 +1,296 @@
+<script>
+import { GlIcon, GlDatepicker, GlTooltipDirective, GlLink, GlPopover } 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 { dateFields, dateTypes, dueDateQueries, startDateQueries } from '~/sidebar/constants';
+import SidebarFormattedDate from './sidebar_formatted_date.vue';
+import SidebarInheritDate from './sidebar_inherit_date.vue';
+
+const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', {
+ bubbles: true,
+});
+
+export default {
+ tracking: {
+ event: 'click_edit_button',
+ label: 'right_sidebar',
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ GlDatepicker,
+ GlLink,
+ GlPopover,
+ SidebarEditableItem,
+ SidebarFormattedDate,
+ SidebarInheritDate,
+ },
+ inject: ['canUpdate'],
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ dateType: {
+ type: String,
+ required: false,
+ default: dateTypes.due,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ canInherit: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ issuable: {},
+ loading: false,
+ tracking: {
+ ...this.$options.tracking,
+ property: this.dateType === dateTypes.start ? 'startDate' : 'dueDate',
+ },
+ };
+ },
+ apollo: {
+ issuable: {
+ query() {
+ return this.dateQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable || {};
+ },
+ result({ data }) {
+ this.$emit(`${this.dateType}Updated`, data.workspace?.issuable?.[this.dateType]);
+ },
+ error() {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} %{dateType} date.'),
+ {
+ issuableType: this.issuableType,
+ dateType: this.dateType === dateTypes.start ? 'start' : 'due',
+ },
+ ),
+ });
+ },
+ },
+ },
+ computed: {
+ dateQueries() {
+ return this.dateType === dateTypes.start ? startDateQueries : dueDateQueries;
+ },
+ dateLabel() {
+ return this.dateType === dateTypes.start
+ ? this.$options.i18n.startDate
+ : this.$options.i18n.dueDate;
+ },
+ removeDateLabel() {
+ return this.dateType === dateTypes.start
+ ? this.$options.i18n.removeStartDate
+ : this.$options.i18n.removeDueDate;
+ },
+ dateValue() {
+ return this.issuable?.[this.dateType] || null;
+ },
+ isLoading() {
+ return this.$apollo.queries.issuable.loading || this.loading;
+ },
+ hasDate() {
+ return this.dateValue !== null;
+ },
+ parsedDate() {
+ if (!this.hasDate) {
+ return null;
+ }
+
+ return parsePikadayDate(this.dateValue);
+ },
+ formattedDate() {
+ if (!this.hasDate) {
+ return this.$options.i18n.noDate;
+ }
+
+ return dateInWords(this.parsedDate, true);
+ },
+ workspacePath() {
+ return this.issuableType === IssuableType.Issue
+ ? {
+ projectPath: this.fullPath,
+ }
+ : {
+ groupPath: this.fullPath,
+ };
+ },
+ dataTestId() {
+ return this.dateType === dateTypes.start ? 'start-date' : 'due-date';
+ },
+ },
+ methods: {
+ closeForm() {
+ this.$refs.editable.collapse();
+ this.$el.dispatchEvent(hideDropdownEvent);
+ this.$emit('closeForm');
+ },
+ openDatePicker() {
+ this.$refs.datePicker.calendar.show();
+ },
+ setFixedDate(isFixed) {
+ const date = this.issuable[dateFields[this.dateType].dateFixed];
+ this.setDate(date, isFixed);
+ },
+ setDate(date, isFixed = true) {
+ const formattedDate = date ? formatDate(date, 'yyyy-mm-dd') : null;
+ this.loading = true;
+ this.$refs.editable.collapse();
+ this.$apollo
+ .mutate({
+ mutation: this.dateQueries[this.issuableType].mutation,
+ variables: {
+ input: {
+ ...this.workspacePath,
+ iid: this.iid,
+ ...(this.canInherit
+ ? {
+ [dateFields[this.dateType].dateFixed]: isFixed ? formattedDate : undefined,
+ [dateFields[this.dateType].isDateFixed]: isFixed,
+ }
+ : {
+ [this.dateType]: formattedDate,
+ }),
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ issuableSetDate: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ } else {
+ this.$emit('closeForm');
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} %{dateType} date.'),
+ {
+ issuableType: this.issuableType,
+ dateType: this.dateType === dateTypes.start ? 'start' : 'due',
+ },
+ ),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+ i18n: {
+ dueDate: __('Due date'),
+ startDate: __('Start date'),
+ noDate: __('None'),
+ removeDueDate: __('remove due date'),
+ removeStartDate: __('remove start date'),
+ dateHelpValidMessage: __(
+ 'These dates affect how your epics appear in the roadmap. Set a fixed date or one inherited from the milestones assigned to issues in this epic.',
+ ),
+ help: __('Help'),
+ learnMore: __('Learn more'),
+ },
+ dateHelpUrl: '/help/user/group/epics/index.md#start-date-and-due-date',
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="dateLabel"
+ :tracking="tracking"
+ :loading="isLoading"
+ class="block"
+ :data-testid="dataTestId"
+ @open="openDatePicker"
+ >
+ <template v-if="canInherit" #title-extra>
+ <gl-icon
+ ref="epicDatePopover"
+ name="question-o"
+ class="gl-ml-3 gl-cursor-pointer gl-text-blue-600 hide-collapsed"
+ tabindex="0"
+ :aria-label="$options.i18n.help"
+ data-testid="inherit-date-popover"
+ />
+ <gl-popover
+ :target="() => $refs.epicDatePopover.$el"
+ triggers="focus"
+ placement="left"
+ boundary="viewport"
+ >
+ <p>{{ $options.i18n.dateHelpValidMessage }}</p>
+ <gl-link :href="$options.dateHelpUrl" target="_blank">{{
+ $options.i18n.learnMore
+ }}</gl-link>
+ </gl-popover>
+ </template>
+ <template #collapsed>
+ <div v-gl-tooltip :title="dateLabel" class="sidebar-collapsed-icon">
+ <gl-icon :size="16" name="calendar" />
+ <span class="collapse-truncated-title">{{ formattedDate }}</span>
+ </div>
+ <sidebar-inherit-date
+ v-if="canInherit"
+ :issuable="issuable"
+ :is-loading="isLoading"
+ :date-type="dateType"
+ @reset-date="setDate(null)"
+ @set-date="setFixedDate"
+ />
+ <sidebar-formatted-date
+ v-else
+ :has-date="hasDate"
+ :formatted-date="formattedDate"
+ :reset-text="removeDateLabel"
+ :is-loading="isLoading"
+ @reset-date="setDate(null)"
+ />
+ </template>
+ <template #default>
+ <gl-datepicker
+ v-if="!isLoading"
+ ref="datePicker"
+ class="gl-relative"
+ :default-date="parsedDate"
+ show-clear-button
+ autocomplete="off"
+ @input="setDate"
+ @clear="setDate(null)"
+ />
+ </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue
new file mode 100644
index 00000000000..87cf1c29fb0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_formatted_date.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ },
+ inject: ['canUpdate'],
+ props: {
+ formattedDate: {
+ required: true,
+ type: String,
+ },
+ hasDate: {
+ required: true,
+ type: Boolean,
+ },
+ resetText: {
+ required: true,
+ type: String,
+ },
+ isLoading: {
+ required: true,
+ type: Boolean,
+ },
+ canDelete: {
+ required: false,
+ type: Boolean,
+ default: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center hide-collapsed">
+ <span
+ :class="hasDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'"
+ data-testid="sidebar-date-value"
+ >
+ {{ formattedDate }}
+ </span>
+ <div v-if="hasDate && canUpdate && canDelete" 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="$emit('reset-date', $event)"
+ >
+ {{ resetText }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue
new file mode 100644
index 00000000000..b6bfacb2e47
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_inherit_date.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlFormRadio } from '@gitlab/ui';
+import { dateInWords, parsePikadayDate } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
+import { dateFields } from '../../constants';
+import SidebarFormattedDate from './sidebar_formatted_date.vue';
+
+export default {
+ components: {
+ GlFormRadio,
+ SidebarFormattedDate,
+ },
+ inject: ['canUpdate'],
+ props: {
+ issuable: {
+ required: true,
+ type: Object,
+ },
+ isLoading: {
+ required: true,
+ type: Boolean,
+ },
+ dateType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ dateIsFixed: {
+ get() {
+ return this.issuable?.[dateFields[this.dateType].isDateFixed] || false;
+ },
+ set(fixed) {
+ this.$emit('set-date', fixed);
+ },
+ },
+ hasFixedDate() {
+ return this.issuable[dateFields[this.dateType].dateFixed] !== null;
+ },
+ formattedFixedDate() {
+ const dateFixed = this.issuable[dateFields[this.dateType].dateFixed];
+ if (!dateFixed) {
+ return this.$options.i18n.noDate;
+ }
+
+ return dateInWords(parsePikadayDate(dateFixed), true);
+ },
+ formattedInheritedDate() {
+ const dateFromMilestones = this.issuable[dateFields[this.dateType].dateFromMilestones];
+ if (!dateFromMilestones) {
+ return this.$options.i18n.noDate;
+ }
+
+ return dateInWords(parsePikadayDate(dateFromMilestones), true);
+ },
+ },
+ i18n: {
+ fixed: __('Fixed:'),
+ inherited: __('Inherited:'),
+ remove: __('remove'),
+ noDate: __('None'),
+ },
+};
+</script>
+
+<template>
+ <div class="hide-collapsed gl-mt-3">
+ <div class="gl-display-flex gl-align-items-baseline" data-testid="sidebar-fixed-date">
+ <gl-form-radio
+ v-model="dateIsFixed"
+ :value="true"
+ :disabled="!canUpdate || isLoading"
+ class="gl-pr-2"
+ >
+ <span :class="dateIsFixed ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'">
+ {{ $options.i18n.fixed }}
+ </span>
+ </gl-form-radio>
+ <sidebar-formatted-date
+ :has-date="dateIsFixed"
+ :formatted-date="formattedFixedDate"
+ :reset-text="$options.i18n.remove"
+ :is-loading="isLoading"
+ :can-delete="dateIsFixed && hasFixedDate"
+ class="gl-line-height-normal"
+ @reset-date="$emit('reset-date', $event)"
+ />
+ </div>
+ <div class="gl-display-flex gl-align-items-baseline" data-testid="sidebar-inherited-date">
+ <gl-form-radio
+ v-model="dateIsFixed"
+ :value="false"
+ :disabled="!canUpdate || isLoading"
+ class="gl-pr-2"
+ >
+ <span :class="!dateIsFixed ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'">
+ {{ $options.i18n.inherited }}
+ </span>
+ </gl-form-radio>
+ <sidebar-formatted-date
+ :has-date="!dateIsFixed"
+ :formatted-date="formattedInheritedDate"
+ :reset-text="$options.i18n.remove"
+ :is-loading="isLoading"
+ :can-delete="false"
+ class="gl-line-height-normal"
+ />
+ </div>
+ </div>
+</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
deleted file mode 100644
index 141c2b3aae9..00000000000
--- a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue
+++ /dev/null
@@ -1,203 +0,0 @@
-<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/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index c3a08f760a0..e85e416881c 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -95,7 +95,7 @@ export default {
<gl-loading-icon v-if="loading" />
<span v-else data-testid="collapsed-count"> {{ participantCount }} </span>
</div>
- <div v-if="showParticipantLabel" class="title hide-collapsed">
+ <div v-if="showParticipantLabel" class="title hide-collapsed gl-mb-2">
<gl-loading-icon v-if="loading" :inline="true" />
{{ participantLabel }}
</div>
@@ -105,10 +105,10 @@ export default {
:key="participant.id"
class="participants-author"
>
- <a :href="participant.web_url" class="author-link">
+ <a :href="participant.web_url || participant.webUrl" class="author-link">
<user-avatar-image
:lazy="true"
- :img-src="participant.avatar_url"
+ :img-src="participant.avatar_url || participant.avatarUrl"
:size="24"
:tooltip-text="participant.name"
css-classes="avatar-inline"
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
new file mode 100644
index 00000000000..d3043e6f6aa
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
@@ -0,0 +1,68 @@
+<script>
+import { __ } from '~/locale';
+import { participantsQueries } from '~/sidebar/constants';
+import Participants from './participants.vue';
+
+export default {
+ i18n: {
+ fetchingError: __('An error occurred while fetching participants'),
+ },
+ components: {
+ Participants,
+ },
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ participants: [],
+ };
+ },
+ apollo: {
+ participants: {
+ query() {
+ return participantsQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.participants.nodes || [];
+ },
+ error(error) {
+ this.$emit('fetch-error', {
+ message: this.$options.i18n.fetchingError,
+ error,
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.participants.loading;
+ },
+ },
+};
+</script>
+
+<template>
+ <participants
+ :loading="isLoading"
+ :participants="participants"
+ :number-of-less-participants="7"
+ />
+</template>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index caf1c92c28a..0fb8d762c7c 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -3,6 +3,9 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
+ i18n: {
+ unassigned: __('Unassigned'),
+ },
components: { GlButton, GlLoadingIcon },
inject: {
canUpdate: {},
@@ -40,6 +43,11 @@ export default {
property: null,
}),
},
+ canEdit: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -103,14 +111,16 @@ export default {
<div>
<div class="gl-display-flex gl-align-items-center" @click.self="collapse">
<span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span>
+ <slot name="title-extra"></slot>
<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"
/>
+ <slot name="collapsed-right"></slot>
<gl-button
- v-if="canUpdate && !initialLoading"
+ v-if="canUpdate && !initialLoading && canEdit"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed"
data-testid="edit-button"
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
deleted file mode 100644
index 3ad097138a3..00000000000
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-import { deprecatedCreateFlash as Flash } from '../../../flash';
-import { __ } from '../../../locale';
-import Store from '../../stores/sidebar_store';
-import subscriptions from './subscriptions.vue';
-
-export default {
- components: {
- subscriptions,
- },
- props: {
- mediator: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- store: new Store(),
- };
- },
- methods: {
- onToggleSubscription() {
- this.mediator.toggleSubscription().catch(() => {
- Flash(__('Error occurred when toggling the notification subscription'));
- });
- },
- },
-};
-</script>
-
-<template>
- <div class="block subscriptions">
- <subscriptions
- :loading="store.isFetching.subscriptions"
- :project-emails-disabled="store.projectEmailsDisabled"
- :subscribe-disabled-description="store.subscribeDisabledDescription"
- :subscribed="store.subscribed"
- @toggleSubscription="onToggleSubscription"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
new file mode 100644
index 00000000000..ee7502e3457
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -0,0 +1,202 @@
+<script>
+import { GlIcon, GlLoadingIcon, GlToggle, GlTooltipDirective } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { IssuableType } from '~/issue_show/constants';
+import { __, sprintf } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { subscribedQueries } from '~/sidebar/constants';
+
+const ICON_ON = 'notifications';
+const ICON_OFF = 'notifications-off';
+
+export default {
+ tracking: {
+ event: 'click_edit_button',
+ label: 'right_sidebar',
+ property: 'subscriptions',
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlToggle,
+ SidebarEditableItem,
+ },
+ inject: ['canUpdate'],
+ props: {
+ iid: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ subscribed: false,
+ loading: false,
+ emailsDisabled: false,
+ };
+ },
+ apollo: {
+ subscribed: {
+ query() {
+ return subscribedQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.subscribed || false;
+ },
+ result({ data }) {
+ this.emailsDisabled = this.parentIsGroup
+ ? data.workspace?.emailsDisabled
+ : data.workspace?.issuable?.emailsDisabled;
+ this.$emit('subscribedUpdated', data.workspace?.issuable?.subscribed);
+ },
+ error() {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} notifications.'),
+ {
+ issuableType: this.issuableType,
+ },
+ ),
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries?.subscribed?.loading || this.loading;
+ },
+ notificationTooltip() {
+ if (this.emailsDisabled) {
+ return this.subscribeDisabledDescription;
+ }
+ return this.subscribed ? this.$options.i18n.labelOn : this.$options.i18n.labelOff;
+ },
+ notificationIcon() {
+ if (this.emailsDisabled || !this.subscribed) {
+ return ICON_OFF;
+ }
+ return ICON_ON;
+ },
+ parentIsGroup() {
+ return this.issuableType === IssuableType.Epic;
+ },
+ subscribeDisabledDescription() {
+ return sprintf(__('Disabled by %{parent} owner'), {
+ parent: this.parentIsGroup ? 'group' : 'project',
+ });
+ },
+ },
+ methods: {
+ setSubscribed(subscribed) {
+ this.loading = true;
+ this.$apollo
+ .mutate({
+ mutation: subscribedQueries[this.issuableType].mutation,
+ variables: {
+ fullPath: this.fullPath,
+ iid: this.iid,
+ subscribedState: subscribed,
+ },
+ })
+ .then(
+ ({
+ data: {
+ updateIssuableSubscription: { errors },
+ },
+ }) => {
+ if (errors.length) {
+ createFlash({
+ message: errors[0],
+ });
+ }
+ },
+ )
+ .catch(() => {
+ createFlash({
+ message: sprintf(
+ __('Something went wrong while setting %{issuableType} notifications.'),
+ {
+ issuableType: this.issuableType,
+ },
+ ),
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ toggleSubscribed() {
+ if (this.emailsDisabled) {
+ this.expandSidebar();
+ } else {
+ this.setSubscribed(!this.subscribed);
+ }
+ },
+ expandSidebar() {
+ this.$emit('expandSidebar');
+ },
+ },
+ i18n: {
+ notifications: __('Notifications'),
+ labelOn: __('Notifications on'),
+ labelOff: __('Notifications off'),
+ },
+};
+</script>
+
+<template>
+ <sidebar-editable-item
+ ref="editable"
+ :title="$options.i18n.notifications"
+ :tracking="$options.tracking"
+ :loading="isLoading"
+ :can-edit="false"
+ class="block subscriptions"
+ >
+ <template #collapsed-right>
+ <gl-toggle
+ :value="subscribed"
+ :is-loading="isLoading"
+ :disabled="emailsDisabled || !canUpdate"
+ class="hide-collapsed gl-ml-auto"
+ data-testid="subscription-toggle"
+ :label="$options.i18n.notifications"
+ label-position="hidden"
+ @change="setSubscribed"
+ />
+ </template>
+ <template #collapsed>
+ <span
+ ref="tooltip"
+ v-gl-tooltip.viewport.left
+ :title="notificationTooltip"
+ class="sidebar-collapsed-icon"
+ @click="toggleSubscribed"
+ >
+ <gl-loading-icon v-if="isLoading" class="sidebar-item-icon is-active" />
+ <gl-icon v-else :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" />
+ </span>
+ <div v-show="emailsDisabled" class="gl-mt-3 hide-collapsed gl-text-gray-500">
+ {{ subscribeDisabledDescription }}
+ </div>
+ </template>
+ <template #default> </template>
+ </sidebar-editable-item>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
new file mode 100644
index 00000000000..67242b3b5b7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
+import { timelogQueries } from '~/sidebar/constants';
+
+const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlTable,
+ },
+ inject: ['issuableId', 'issuableType'],
+ props: {
+ limitToHours: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return { report: [], isLoading: true };
+ },
+ apollo: {
+ report: {
+ query() {
+ return timelogQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ id: convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId),
+ };
+ },
+ update(data) {
+ this.isLoading = false;
+ return this.extractTimelogs(data);
+ },
+ error() {
+ createFlash({ message: __('Something went wrong. Please try again.') });
+ },
+ },
+ },
+ methods: {
+ isIssue() {
+ return this.issuableType === 'issue';
+ },
+ getGraphQLEntityType() {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return this.isIssue() ? 'Issue' : 'MergeRequest';
+ },
+ extractTimelogs(data) {
+ const timelogs = data?.issuable?.timelogs?.nodes || [];
+ return timelogs.slice().sort((a, b) => new Date(a.spentAt) - new Date(b.spentAt));
+ },
+ formatDate(date) {
+ return formatDate(date, TIME_DATE_FORMAT);
+ },
+ getNote(note) {
+ return note?.body;
+ },
+ getTotalTimeSpent() {
+ const seconds = this.report.reduce((acc, item) => acc + item.timeSpent, 0);
+ return this.formatTimeSpent(seconds);
+ },
+ formatTimeSpent(seconds) {
+ const negative = seconds < 0;
+ return (
+ (negative ? '- ' : '') +
+ stringifyTime(parseSeconds(seconds, { limitToHours: this.limitToHours }))
+ );
+ },
+ },
+ fields: [
+ { key: 'spentAt', label: __('Spent At'), sortable: true },
+ { key: 'user', label: __('User'), sortable: true },
+ { key: 'timeSpent', label: __('Time Spent'), sortable: true },
+ { key: 'note', label: __('Note'), sortable: true },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="isLoading"><gl-loading-icon size="md" /></div>
+ <gl-table v-else :items="report" :fields="$options.fields" foot-clone>
+ <template #cell(spentAt)="{ item: { spentAt } }">
+ <div>{{ formatDate(spentAt) }}</div>
+ </template>
+ <template #foot(spentAt)>&nbsp;</template>
+
+ <template #cell(user)="{ item: { user } }">
+ <div>{{ user.name }}</div>
+ </template>
+ <template #foot(user)>&nbsp;</template>
+
+ <template #cell(timeSpent)="{ item: { timeSpent } }">
+ <div>{{ formatTimeSpent(timeSpent) }}</div>
+ </template>
+ <template #foot(timeSpent)>
+ <div>{{ getTotalTimeSpent() }}</div>
+ </template>
+
+ <template #cell(note)="{ item: { note } }">
+ <div>{{ getNote(note) }}</div>
+ </template>
+ <template #foot(note)>&nbsp;</template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index 4c095006dd7..64f2ddc1d16 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,10 +1,11 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlLink, GlModal, GlModalDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue';
import TimeTrackingComparisonPane from './comparison_pane.vue';
import TimeTrackingHelpState from './help_state.vue';
+import TimeTrackingReport from './report.vue';
import TimeTrackingSpentOnlyPane from './spent_only_pane.vue';
export default {
@@ -15,10 +16,16 @@ export default {
},
components: {
GlIcon,
+ GlLink,
+ GlModal,
TimeTrackingCollapsedState,
TimeTrackingSpentOnlyPane,
TimeTrackingComparisonPane,
TimeTrackingHelpState,
+ TimeTrackingReport,
+ },
+ directives: {
+ GlModal: GlModalDirective,
},
props: {
timeEstimate: {
@@ -160,6 +167,21 @@ export default {
:time-estimate-human-readable="humanTimeEstimate"
:limit-to-hours="limitToHours"
/>
+ <gl-link
+ v-if="hasTimeSpent"
+ v-gl-modal="'time-tracking-report'"
+ data-testid="reportLink"
+ href="#"
+ class="btn-link"
+ >{{ __('Time tracking report') }}</gl-link
+ >
+ <gl-modal
+ modal-id="time-tracking-report"
+ :title="__('Time tracking report')"
+ :hide-footer="true"
+ >
+ <time-tracking-report :limit-to-hours="limitToHours" />
+ </gl-modal>
<transition name="help-state-toggle">
<time-tracking-help-state v-if="showHelpState" />
</transition>
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 80e07d556bf..a4e6d8854d1 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,27 +1,56 @@
import { IssuableType } from '~/issue_show/constants';
import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql';
+import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql';
+import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql';
+import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql';
+import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql';
+import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.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 issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql';
import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql';
-import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
+import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql';
+import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql';
+import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql';
+import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql';
+import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.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 updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql';
+import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
+import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
+import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
+import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql';
+import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.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';
-import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
+import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql';
+import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
+import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const ASSIGNEES_DEBOUNCE_DELAY = 250;
export const assigneesQueries = {
[IssuableType.Issue]: {
- query: getIssueParticipants,
- mutation: updateAssigneesMutation,
+ query: getIssueAssignees,
+ subscription: issuableAssigneesSubscription,
+ mutation: updateIssueAssigneesMutation,
+ },
+ [IssuableType.MergeRequest]: {
+ query: getMergeRequestAssignees,
+ mutation: updateMergeRequestAssigneesMutation,
+ },
+};
+
+export const participantsQueries = {
+ [IssuableType.Issue]: {
+ query: issueParticipantsQuery,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
- mutation: updateMergeRequestParticipantsMutation,
+ },
+ [IssuableType.Epic]: {
+ query: epicParticipantsQuery,
},
};
@@ -32,7 +61,7 @@ export const confidentialityQueries = {
},
[IssuableType.Epic]: {
query: epicConfidentialQuery,
- mutation: updateEpicMutation,
+ mutation: updateEpicConfidentialMutation,
},
};
@@ -45,9 +74,62 @@ export const referenceQueries = {
},
};
+export const dateTypes = {
+ start: 'startDate',
+ due: 'dueDate',
+};
+
+export const dateFields = {
+ [dateTypes.start]: {
+ isDateFixed: 'startDateIsFixed',
+ dateFixed: 'startDateFixed',
+ dateFromMilestones: 'startDateFromMilestones',
+ },
+ [dateTypes.due]: {
+ isDateFixed: 'dueDateIsFixed',
+ dateFixed: 'dueDateFixed',
+ dateFromMilestones: 'dueDateFromMilestones',
+ },
+};
+
+export const subscribedQueries = {
+ [IssuableType.Issue]: {
+ query: issueSubscribedQuery,
+ mutation: updateIssueSubscriptionMutation,
+ },
+ [IssuableType.Epic]: {
+ query: epicSubscribedQuery,
+ mutation: updateEpicSubscriptionMutation,
+ },
+ [IssuableType.MergeRequest]: {
+ query: mergeRequestSubscribed,
+ mutation: updateMergeRequestSubscriptionMutation,
+ },
+};
+
export const dueDateQueries = {
[IssuableType.Issue]: {
query: issueDueDateQuery,
mutation: updateIssueDueDateMutation,
},
+ [IssuableType.Epic]: {
+ query: epicDueDateQuery,
+ mutation: updateEpicDueDateMutation,
+ },
+};
+
+export const startDateQueries = {
+ [IssuableType.Epic]: {
+ query: epicStartDateQuery,
+ mutation: updateEpicStartDateMutation,
+ },
+};
+
+export const timelogQueries = {
+ [IssuableType.Issue]: {
+ query: getIssueTimelogsQuery,
+ },
+ [IssuableType.MergeRequest]: {
+ query: getMrTimelogsQuery,
+ },
};
diff --git a/app/assets/javascripts/sidebar/fragmentTypes.json b/app/assets/javascripts/sidebar/fragmentTypes.json
new file mode 100644
index 00000000000..a1c68bba454
--- /dev/null
+++ b/app/assets/javascripts/sidebar/fragmentTypes.json
@@ -0,0 +1 @@
+{"__schema":{"types":[{"kind":"UNION","name":"Issuable","possibleTypes":[{"name":"Issue"},{"name":"MergeRequest"}]}, {"kind":"INTERFACE","name":"User","possibleTypes":[{"name":"UserCore"}]}]}}
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index aa139540a51..8615b52f1b8 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -1,7 +1,21 @@
+import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import introspectionQueryResultData from './fragmentTypes.json';
-export const defaultClient = createDefaultClient();
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
+export const defaultClient = createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ fragmentMatcher,
+ },
+ assumeImmutableResults: true,
+ },
+);
export const apolloProvider = new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 1304e84814b..3f24fdc75dc 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -13,7 +13,7 @@ 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 SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue';
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import { apolloProvider } from '~/sidebar/graphql';
import Translate from '../vue_shared/translate';
@@ -24,7 +24,7 @@ import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue';
-import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
+import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
@@ -53,7 +53,7 @@ function mountAssigneesComponentDeprecated(mediator) {
if (!el) return;
- const { iid, fullPath } = getSidebarOptions();
+ const { id, iid, fullPath } = getSidebarOptions();
const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new
new Vue({
@@ -74,6 +74,7 @@ function mountAssigneesComponentDeprecated(mediator) {
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
+ issuableId: id,
assigneeAvailabilityStatus,
},
}),
@@ -85,7 +86,7 @@ function mountAssigneesComponent() {
if (!el) return;
- const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions();
+ const { id, iid, fullPath, editable } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
@@ -95,9 +96,7 @@ function mountAssigneesComponent() {
},
provide: {
canUpdate: editable,
- projectMembersPath,
directlyInviteMembers: el.hasAttribute('data-directly-invite-members'),
- indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'),
},
render: (createElement) =>
createElement('sidebar-assignees-widget', {
@@ -108,7 +107,8 @@ function mountAssigneesComponent() {
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
- multipleAssignees: !el.dataset.maxAssignees,
+ issuableId: id,
+ allowMultipleAssignees: !el.dataset.maxAssignees,
},
scopedSlots: {
collapsed: ({ users, onClick }) =>
@@ -223,14 +223,14 @@ function mountDueDateComponent() {
SidebarDueDateWidget,
},
provide: {
- iid: String(iid),
- fullPath,
canUpdate: editable,
},
render: (createElement) =>
createElement('sidebar-due-date-widget', {
props: {
+ iid: String(iid),
+ fullPath,
issuableType: IssuableType.Issue,
},
}),
@@ -334,21 +334,32 @@ function mountParticipantsComponent(mediator) {
});
}
-function mountSubscriptionsComponent(mediator) {
+function mountSubscriptionsComponent() {
const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
if (!el) return;
+ const { fullPath, iid, editable } = getSidebarOptions();
+
// eslint-disable-next-line no-new
new Vue({
el,
+ apolloProvider,
components: {
- sidebarSubscriptions,
+ SidebarSubscriptionsWidget,
+ },
+ provide: {
+ canUpdate: editable,
},
render: (createElement) =>
- createElement('sidebar-subscriptions', {
+ createElement('sidebar-subscriptions-widget', {
props: {
- mediator,
+ iid: String(iid),
+ fullPath,
+ issuableType:
+ isInIssuePage() || isInIncidentPage() || isInDesignPage()
+ ? IssuableType.Issue
+ : IssuableType.MergeRequest,
},
}),
});
@@ -356,16 +367,16 @@ function mountSubscriptionsComponent(mediator) {
function mountTimeTrackingComponent() {
const el = document.getElementById('issuable-time-tracker');
+ const { id, issuableType } = getSidebarOptions();
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
- components: {
- SidebarTimeTracking,
- },
- render: (createElement) => createElement('sidebar-time-tracking', {}),
+ apolloProvider,
+ provide: { issuableId: id, issuableType },
+ render: (createElement) => createElement(SidebarTimeTracking, {}),
});
}
@@ -425,7 +436,7 @@ export function mountSidebar(mediator) {
mountReferenceComponent(mediator);
mountLockComponent();
mountParticipantsComponent(mediator);
- mountSubscriptionsComponent(mediator);
+ mountSubscriptionsComponent();
mountCopyEmailComponent();
new SidebarMoveIssue(
diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
new file mode 100644
index 00000000000..f60f44abebd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
@@ -0,0 +1,13 @@
+query epicDueDate($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ dueDate
+ dueDateIsFixed
+ dueDateFixed
+ dueDateFromMilestones
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
new file mode 100644
index 00000000000..fbebc50ab08
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
@@ -0,0 +1,18 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query epicParticipants($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ participants {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
new file mode 100644
index 00000000000..c6c24fd3d95
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
@@ -0,0 +1,13 @@
+query epicStartDate($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ startDate
+ startDateIsFixed
+ startDateFixed
+ startDateFromMilestones
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
new file mode 100644
index 00000000000..9f1967e1685
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
@@ -0,0 +1,11 @@
+query epicSubscribed($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ __typename
+ emailsDisabled
+ issuable: epic(iid: $iid) {
+ __typename
+ id
+ subscribed
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql
new file mode 100644
index 00000000000..47ce094418c
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issuable_assignees.subscription.graphql
@@ -0,0 +1,16 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+
+subscription issuableAssigneesUpdated($issuableId: IssuableID!) {
+ issuableAssigneesUpdated(issuableId: $issuableId) {
+ ... on Issue {
+ assignees {
+ nodes {
+ ...User
+ status {
+ availability
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
new file mode 100644
index 00000000000..7d38b5d3bd8
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
@@ -0,0 +1,11 @@
+query issueSubscribed($fullPath: ID!, $iid: String) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ subscribed
+ emailsDisabled
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
new file mode 100644
index 00000000000..3b54a2e529b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
@@ -0,0 +1,10 @@
+query mergeRequestSubscribed($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: mergeRequest(iid: $iid) {
+ __typename
+ id
+ subscribed
+ }
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
new file mode 100644
index 00000000000..9b0a8b4a8f7
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_due_date.mutation.graphql
@@ -0,0 +1,11 @@
+mutation updateEpicDueDate($input: UpdateEpicInput!) {
+ issuableSetDate: updateEpic(input: $input) {
+ issuable: epic {
+ id
+ dueDateIsFixed
+ dueDateFixed
+ dueDateFromMilestones
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
new file mode 100644
index 00000000000..9b4bb9159c3
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_epic_start_date.mutation.graphql
@@ -0,0 +1,11 @@
+mutation updateEpicStartDate($input: UpdateEpicInput!) {
+ issuableSetDate: updateEpic(input: $input) {
+ issuable: epic {
+ id
+ startDateIsFixed
+ startDateFixed
+ startDateFromMilestones
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
index f2b806102f4..af43766aed5 100644
--- a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql
@@ -1,6 +1,9 @@
-mutation epicSetSubscription($input: EpicSetSubscriptionInput!) {
- updateIssuableSubscription: epicSetSubscription(input: $input) {
- epic {
+mutation epicSetSubscription($fullPath: ID!, $iid: ID!, $subscribedState: Boolean!) {
+ updateIssuableSubscription: epicSetSubscription(
+ input: { groupPath: $fullPath, iid: $iid, subscribedState: $subscribedState }
+ ) {
+ issuable: epic {
+ id
subscribed
}
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
index cf7eccd61c7..4765b0b08cc 100644
--- a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql
@@ -1,5 +1,5 @@
mutation updateIssueDueDate($input: UpdateIssueInput!) {
- issuableSetDueDate: updateIssue(input: $input) {
+ issuableSetDate: updateIssue(input: $input) {
issuable: issue {
id
dueDate
diff --git a/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql
new file mode 100644
index 00000000000..81891fb601f
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_issue_subscription.mutation.graphql
@@ -0,0 +1,11 @@
+mutation issueSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) {
+ updateIssuableSubscription: issueSetSubscription(
+ input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState }
+ ) {
+ issuable: issue {
+ id
+ subscribed
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql
new file mode 100644
index 00000000000..69944ff9a13
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_subscription.mutation.graphql
@@ -0,0 +1,11 @@
+mutation mergeRequestSetSubscription($fullPath: ID!, $iid: String!, $subscribedState: Boolean!) {
+ updateIssuableSubscription: mergeRequestSetSubscription(
+ input: { projectPath: $fullPath, iid: $iid, subscribedState: $subscribedState }
+ ) {
+ issuable: mergeRequest {
+ id
+ subscribed
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index 5fb20b00705..b08bf26e1dc 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const BRANCH_SUFFIX_COUNT = 8;
-export const DEFAULT_TARGET_BRANCH = 'master';
export const ISSUABLE_TYPE = 'merge_request';
export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.');
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
index 0b74c99b319..e9f1828bff8 100644
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -9,6 +9,7 @@ const submitContentChangesResolver = (
project: projectId,
username,
sourcePath,
+ targetBranch,
content,
images,
mergeRequestMeta,
@@ -21,6 +22,7 @@ const submitContentChangesResolver = (
projectId,
username,
sourcePath,
+ targetBranch,
content,
images,
mergeRequestMeta,
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index 22f80ead74b..49a2ca03ace 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -111,6 +111,7 @@ export default {
project: this.appData.project,
username: this.appData.username,
sourcePath: this.appData.sourcePath,
+ targetBranch: this.appData.branch,
content: this.content,
formattedMarkdown: this.formattedMarkdown,
images: this.images,
diff --git a/app/assets/javascripts/static_site_editor/services/generate_branch_name.js b/app/assets/javascripts/static_site_editor/services/generate_branch_name.js
index f45ad616332..cbf03a41ce2 100644
--- a/app/assets/javascripts/static_site_editor/services/generate_branch_name.js
+++ b/app/assets/javascripts/static_site_editor/services/generate_branch_name.js
@@ -1,8 +1,8 @@
-import { BRANCH_SUFFIX_COUNT, DEFAULT_TARGET_BRANCH } from '../constants';
+import { BRANCH_SUFFIX_COUNT } from '../constants';
const generateBranchSuffix = () => `${Date.now()}`.substr(BRANCH_SUFFIX_COUNT);
-const generateBranchName = (username, targetBranch = DEFAULT_TARGET_BRANCH) =>
+const generateBranchName = (username, targetBranch) =>
`${username}-${targetBranch}-patch-${generateBranchSuffix()}`;
export default generateBranchName;
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index 6391cfd6cc2..ecb7f60a421 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -4,7 +4,6 @@ import generateBranchName from '~/static_site_editor/services/generate_branch_na
import Tracking from '~/tracking';
import {
- DEFAULT_TARGET_BRANCH,
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
@@ -16,9 +15,9 @@ import {
DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION,
} from '../constants';
-const createBranch = (projectId, branch) =>
+const createBranch = (projectId, branch, targetBranch) =>
Api.createBranch(projectId, {
- ref: DEFAULT_TARGET_BRANCH,
+ ref: targetBranch,
branch,
}).catch(() => {
throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
@@ -73,13 +72,7 @@ const commit = (projectId, message, branch, actions) => {
});
};
-const createMergeRequest = (
- projectId,
- title,
- description,
- sourceBranch,
- targetBranch = DEFAULT_TARGET_BRANCH,
-) => {
+const createMergeRequest = (projectId, title, description, sourceBranch, targetBranch) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST);
Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST);
@@ -100,16 +93,17 @@ const submitContentChanges = ({
username,
projectId,
sourcePath,
+ targetBranch,
content,
images,
mergeRequestMeta,
formattedMarkdown,
}) => {
- const branch = generateBranchName(username);
+ const branch = generateBranchName(username, targetBranch);
const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta;
const meta = {};
- return createBranch(projectId, branch)
+ return createBranch(projectId, branch, targetBranch)
.then(({ data: { web_url: url } }) => {
const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`;
@@ -133,7 +127,13 @@ const submitContentChanges = ({
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
- return createMergeRequest(projectId, mergeRequestTitle, mergeRequestDescription, branch);
+ return createMergeRequest(
+ projectId,
+ mergeRequestTitle,
+ mergeRequestDescription,
+ branch,
+ targetBranch,
+ );
})
.then(({ data: { iid: label, web_url: url } }) => {
Object.assign(meta, { mergeRequest: { label: label.toString(), url } });
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index bc8a8e425dd..3b2210b9ef2 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -40,20 +40,37 @@ export default class TaskList {
taskListField.value = taskListField.dataset.value;
});
- $(this.taskListContainerSelector).taskList('enable');
- $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
+ this.enable();
}
getTaskListTarget(e) {
return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector);
}
+ // Disable any task items that don't have a data-sourcepos attribute, on the
+ // assumption that if it doesn't then it wasn't generated from our markdown parser.
+ // This covers the case of markdown not being able to handle task lists inside
+ // markdown tables. It also includes hand coded HTML lists.
+ disableNonMarkdownTaskListItems(e) {
+ this.getTaskListTarget(e)
+ .find('.task-list-item')
+ .not('[data-sourcepos]')
+ .find('.task-list-item-checkbox')
+ .prop('disabled', true);
+ }
+
disableTaskListItems(e) {
this.getTaskListTarget(e).taskList('disable');
}
enableTaskListItems(e) {
this.getTaskListTarget(e).taskList('enable');
+ this.disableNonMarkdownTaskListItems(e);
+ }
+
+ enable() {
+ this.enableTaskListItems();
+ $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
}
disable() {
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index cdfecceb78a..d2e69bc06cf 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -153,6 +153,21 @@ export default class Tracking {
return loadEvents;
}
+ static enableFormTracking(config, contexts = []) {
+ if (!this.enabled()) return;
+
+ if (!config?.forms?.whitelist?.length && !config?.fields?.whitelist?.length) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Unable to enable form event tracking without whitelist rules.');
+ }
+
+ contexts.unshift(STANDARD_CONTEXT);
+ const enabler = () => window.snowplow('enableFormTracking', config, contexts);
+
+ if (document.readyState !== 'loading') enabler();
+ else document.addEventListener('DOMContentLoaded', enabler);
+ }
+
static mixin(opts = {}) {
return {
computed: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index abc831c8abe..a5d165ebd49 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -1,5 +1,12 @@
<script>
-import { GlButtonGroup, GlDropdown, GlDropdownItem, GlLink, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlLink,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import ReviewAppLink from '../review_app_link.vue';
@@ -9,6 +16,7 @@ export default {
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlIcon,
GlLink,
GlSearchBoxByType,
ReviewAppLink,
@@ -71,7 +79,14 @@ export default {
size="small"
css-class="deploy-link js-deploy-url inline"
/>
- <gl-dropdown size="small" class="js-mr-wigdet-deployment-dropdown">
+ <gl-dropdown toggle-class="gl-px-2!" size="small" class="js-mr-wigdet-deployment-dropdown">
+ <template #button-content>
+ <gl-icon
+ class="dropdown-chevron gl-mx-0!"
+ name="chevron-down"
+ data-testid="mr-wigdet-deployment-dropdown-icon"
+ />
+ </template>
<gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
<gl-dropdown-item
v-for="change in filteredChanges"
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 1248a891ed9..fa46b4b1364 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
@@ -107,9 +107,6 @@ export default {
hasCommitInfo() {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
},
- hasArtifacts() {
- return this.pipeline?.details?.artifacts?.length > 0;
- },
isMergeRequestPipeline() {
return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
},
@@ -288,11 +285,7 @@ export default {
/>
</span>
<linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
- <pipeline-artifacts
- v-if="hasArtifacts"
- :artifacts="pipeline.details.artifacts"
- class="gl-ml-3"
- />
+ <pipeline-artifacts :pipeline-id="pipeline.id" class="gl-ml-3" />
</span>
</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 6d68c15cf2d..0cd280c42d2 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
@@ -154,7 +154,7 @@ export default {
<status-icon status="success" />
<div class="media-body">
<h4 class="gl-display-flex">
- <span class="gl-mr-3" data-qa-selector="merge_request_status_content">
+ <span class="gl-mr-3">
<span class="js-status-text-before-author" data-testid="beforeStatusText">{{
statusTextBeforeAuthor
}}</span>
@@ -169,6 +169,7 @@ export default {
role="button"
href="#"
class="btn btn-sm btn-default js-cancel-auto-merge"
+ data-qa-selector="cancel_auto_merge_button"
data-testid="cancelAutomaticMergeButton"
@click.prevent="cancelAutomaticMerge"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index 0655eef6504..32749b8b018 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,4 +1,5 @@
<script>
+import { MERGE_ACTIVE_STATUS_PHRASES } from '../../constants';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -13,13 +14,23 @@ export default {
default: () => ({}),
},
},
+ data() {
+ const statusCount = MERGE_ACTIVE_STATUS_PHRASES.length;
+
+ return {
+ mergeStatus: MERGE_ACTIVE_STATUS_PHRASES[Math.floor(Math.random() * statusCount)],
+ };
+ },
};
</script>
<template>
<div class="mr-widget-body mr-state-locked media">
<status-icon status="loading" />
<div class="media-body">
- <h4>{{ s__('mrWidget|This merge request is in the process of being merged') }}</h4>
+ <h4>
+ {{ mergeStatus.message }}
+ <gl-emoji :data-name="mergeStatus.emoji" />
+ </h4>
<section class="mr-info-list">
<p>
{{ s__('mrWidget|The changes will be merged into') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 33ca582583b..a82a8a22873 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -175,7 +175,7 @@ export default {
>
<gl-button
:loading="isMakingRequest"
- variant="success"
+ variant="confirm"
data-qa-selector="mr_rebase_button"
@click="rebase"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 329964d009a..c6ce29acb09 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -28,7 +28,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body">
<span class="gl-ml-3 gl-font-weight-bold gl-display-block gl-w-100">{{
- s__('mrWidget|Before this can be merged, one or more threads must be resolved.')
+ s__('mrWidget|Merge blocked: all threads must be resolved.')
}}</span>
<gl-button
data-testid="jump-to-first"
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 77dfbf9d385..822fb58db60 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -25,3 +25,30 @@ export const SP_HELP_CONTENT = s__(
);
export const SP_HELP_URL = 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/';
export const SP_ICON_NAME = 'status_notfound';
+
+export const MERGE_ACTIVE_STATUS_PHRASES = [
+ {
+ message: s__('mrWidget|Merging! Drum roll, please…'),
+ emoji: 'drum',
+ },
+ {
+ message: s__("mrWidget|Merging! We're almost there…"),
+ emoji: 'sparkles',
+ },
+ {
+ message: s__('mrWidget|Merging! Changes will land soon…'),
+ emoji: 'airplane_arriving',
+ },
+ {
+ message: s__('mrWidget|Merging! Changes are being shipped…'),
+ emoji: 'ship',
+ },
+ {
+ message: s__("mrWidget|Merging! Everything's good…"),
+ emoji: 'relieved',
+ },
+ {
+ message: s__('mrWidget|Merging! This is going to be great…'),
+ emoji: 'heart_eyes',
+ },
+];
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index c1c491f6fe0..3a3a1329483 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -32,6 +32,10 @@ export default () => {
const vm = new Vue({
el: '#js-vue-mr-widget',
+ provide: {
+ artifactsEndpoint: gl.mrWidgetData.artifacts_endpoint,
+ artifactsEndpointPlaceholder: gl.mrWidgetData.artifacts_endpoint_placeholder,
+ },
...MrWidgetOptions,
apolloProvider,
});
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js
index 23e140623cc..67d9892d9c6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/auto_merge.js
@@ -9,7 +9,7 @@ export default {
return s__('mrWidget|to be merged automatically when the pipeline succeeds');
},
cancelButtonText() {
- return s__('mrWidget|Cancel automatic merge');
+ return s__('mrWidget|Cancel');
},
},
};
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 264ea36137f..0cfb059b0ce 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
@@ -460,9 +460,6 @@ export default {
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality"
:base-path="mr.codeclimate.base_path"
- :head-path="mr.codeclimate.head_path"
- :head-blob-path="mr.headBlobPath"
- :base-blob-path="mr.baseBlobPath"
:codequality-reports-path="mr.codequalityReportsPath"
:codequality-help-path="mr.codequalityHelpPath"
/>
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 f57b638dd81..9f85140bab8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,6 +1,6 @@
import { format } from 'timeago.js';
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
-import mrEventHub from '~/merge_request/eventhub';
+import { statusBoxState } from '~/issuable/components/status_box.vue';
import { formatDate } from '../../lib/utils/datetime_utility';
import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants';
import { stateKey } from './state_maps';
@@ -23,6 +23,8 @@ export default class MergeRequestStore {
setData(data, isRebased) {
this.initApprovals();
+ this.updateStatusState(data.state);
+
if (isRebased) {
this.sha = data.diff_head_sha;
}
@@ -156,16 +158,14 @@ export default class MergeRequestStore {
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
this.setState();
-
- if (!window.gon?.features?.mergeRequestWidgetGraphql) {
- this.emitUpdatedState();
- }
}
setGraphqlData(project) {
const { mergeRequest } = project;
const pipeline = mergeRequest.headPipeline;
+ this.updateStatusState(mergeRequest.state);
+
this.projectArchived = project.archived;
this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
@@ -190,10 +190,15 @@ export default class MergeRequestStore {
this.workInProgress = mergeRequest.workInProgress;
this.mergeRequestState = mergeRequest.state;
- this.emitUpdatedState();
this.setState();
}
+ updateStatusState(state) {
+ if (this.mergeRequestState !== state && statusBoxState.updateStatus) {
+ statusBoxState.updateStatus();
+ }
+ }
+
setState() {
if (this.mergeOngoing) {
this.state = 'merging';
@@ -216,12 +221,6 @@ export default class MergeRequestStore {
}
}
- emitUpdatedState() {
- mrEventHub.$emit('mr.state.updated', {
- state: this.mergeRequestState,
- });
- }
-
setPaths(data) {
// Paths are set on the first load of the page and not auto-refreshed
this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path;
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 554c7a573fe..ca42cb0b1b5 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
@@ -64,6 +64,9 @@ export default {
<sidebar-status
:project-path="projectPath"
:alert="alert"
+ :sidebar-collapsed="sidebarStatus"
+ text-class="gl-text-gray-500"
+ class="gl-w-70p"
@toggle-sidebar="$emit('toggle-sidebar')"
@alert-error="$emit('alert-error', $event)"
/>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
index 2a999b908f9..ef31106b709 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
@@ -192,21 +192,33 @@ export default {
</script>
<template>
- <div class="block alert-assignees">
- <div ref="assignees" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
- <gl-icon name="user" :size="14" />
- <gl-loading-icon v-if="isUpdating" />
- </div>
- <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
- <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
- <template #assignees>
- {{ userName }}
- </template>
- </gl-sprintf>
- </gl-tooltip>
+ <div
+ class="alert-assignees gl-py-5 gl-w-70p"
+ :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': !sidebarCollapsed }"
+ >
+ <template v-if="sidebarCollapsed">
+ <div
+ ref="assignees"
+ class="gl-mb-6 gl-ml-6"
+ data-testid="assignees-icon"
+ @click="$emit('toggle-sidebar')"
+ >
+ <gl-icon name="user" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
+ <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
+ <template #assignees>
+ {{ userName }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </template>
- <div class="hide-collapsed">
- <p class="title gl-display-flex gl-justify-content-space-between">
+ <div v-else>
+ <p
+ class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between"
+ >
{{ __('Assignee') }}
<a
v-if="isEditable"
@@ -264,7 +276,11 @@ export default {
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
- <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
+ <div
+ v-else-if="!isDropdownShowing"
+ class="hide-collapsed value gl-m-0"
+ :class="{ 'no-value': !userName }"
+ >
<div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users">
<span class="gl-relative gl-mr-4">
<img
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
index fd40b5d9f65..832b154b312 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
@@ -25,7 +25,7 @@ export default {
</script>
<template>
- <div class="block gl-display-flex gl-justify-content-space-between">
+ <div class="block gl-display-flex gl-justify-content-space-between gl-border-b-gray-100!">
<span class="issuable-header-text hide-collapsed">
{{ __('To Do') }}
</span>
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 3822b9153a4..8715eb99518 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
@@ -30,6 +30,15 @@ export default {
required: false,
default: true,
},
+ sidebarCollapsed: {
+ type: Boolean,
+ required: false,
+ },
+ textClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -48,34 +57,44 @@ export default {
},
toggleFormDropdown() {
this.isDropdownShowing = !this.isDropdownShowing;
- const { dropdown } = this.$children[2].$refs.dropdown.$refs;
+ const { dropdown } = this.$refs.status.$refs.dropdown.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
},
- handleUpdating(updating) {
- this.isUpdating = updating;
+ handleUpdating(isMutationInProgress) {
+ if (!isMutationInProgress) {
+ this.$emit('alert-update');
+ }
+ this.isUpdating = isMutationInProgress;
},
},
};
</script>
<template>
- <div class="block alert-status">
- <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
- <gl-icon name="status" :size="14" />
- <gl-loading-icon v-if="isUpdating" />
- </div>
- <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
- <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
- <template #status>
- {{ alert.status.toLowerCase() }}
- </template>
- </gl-sprintf>
- </gl-tooltip>
+ <div
+ class="alert-status gl-py-5"
+ :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': !sidebarCollapsed }"
+ >
+ <template v-if="sidebarCollapsed">
+ <div ref="status" class="gl-ml-6" data-testid="status-icon" @click="$emit('toggle-sidebar')">
+ <gl-icon name="status" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
+ <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
+ <template #status>
+ {{ alert.status.toLowerCase() }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </template>
- <div class="hide-collapsed">
- <p class="title gl-display-flex justify-content-between">
+ <div v-else>
+ <p
+ class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between"
+ >
{{ s__('AlertManagement|Status') }}
<a
v-if="isEditable"
@@ -90,6 +109,7 @@ export default {
</p>
<alert-status
+ ref="status"
:alert="alert"
:project-path="projectPath"
:is-dropdown-showing="isDropdownShowing"
@@ -106,7 +126,7 @@ export default {
class="value gl-m-0"
:class="{ 'no-value': !statuses[alert.status] }"
>
- <span v-if="statuses[alert.status]" class="gl-text-gray-500" data-testid="status">
+ <span v-if="statuses[alert.status]" :class="textClass" data-testid="status">
{{ statuses[alert.status] }}
</span>
<span v-else>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index 271f0b4e4bb..a2a4046ab81 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -134,7 +134,12 @@ export default {
</script>
<template>
- <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }">
+ <div
+ :class="{
+ 'block todo': sidebarCollapsed,
+ 'gl-ml-auto': !sidebarCollapsed,
+ }"
+ >
<todo
data-testid="alert-todo-button"
:collapsed="sidebarCollapsed"
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
index bc4d91a51d1..f0095abfca1 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
@@ -3,6 +3,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) {
errors
issue {
iid
+ webUrl
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
new file mode 100644
index 00000000000..1f293b2150f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['hasManagedPrometheus'],
+ i18n: {
+ alertsDeprecationText: s__(
+ 'Metrics|GitLab-managed Prometheus is deprecated and %{linkStart}scheduled for removal%{linkEnd}. Following this removal, your existing alerts will continue to function as part of the new cluster integration. However, you will no longer be able to add new alerts or edit existing alerts from the metrics dashboard.',
+ ),
+ },
+ methods: {
+ helpPagePath,
+ },
+};
+</script>
+
+<template>
+ <gl-alert v-if="hasManagedPrometheus" variant="warning" class="my-2">
+ <gl-sprintf :message="$options.i18n.alertsDeprecationText">
+ <template #link="{ content }">
+ <gl-link
+ :href="
+ helpPagePath('operations/metrics/alerts.html', {
+ anchor: 'managed-prometheus-instances',
+ })
+ "
+ target="_blank"
+ >
+ <span>{{ content }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index f477610ff1d..f6ab3cac536 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
@@ -6,6 +6,7 @@ import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
export default {
+ name: 'SimpleViewer',
components: {
GlIcon,
EditorLite: () =>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
index ad3e6713e45..2552236a073 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -1,7 +1,7 @@
<script>
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
-import { CHART_CONTAINER_HEIGHT } from '../constants';
+import { CHART_CONTAINER_HEIGHT } from './constants';
export default {
name: 'CiCdAnalyticsAreaChart',
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
index f4fd57e4cdc..f4fd57e4cdc 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
new file mode 100644
index 00000000000..1561674c0ad
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
@@ -0,0 +1 @@
+export const CHART_CONTAINER_HEIGHT = 300;
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index eb8400e81c7..a1c7c4dd142 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -28,6 +28,7 @@ export default {
</script>
<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<button
:disabled="isDisabled || isLoading"
class="dropdown-menu-toggle dropdown-menu-full-width"
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 e622b505570..e1e71639115 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
@@ -93,6 +93,7 @@ const fileExtensionIcons = {
pdf: 'pdf',
xlsx: 'table',
xls: 'table',
+ ods: 'table',
csv: 'table',
tsv: 'table',
vscodeignore: 'vscode',
@@ -154,6 +155,7 @@ const fileExtensionIcons = {
gradle: 'gradle',
doc: 'word',
docx: 'word',
+ odt: 'word',
rtf: 'word',
cer: 'certificate',
cert: 'certificate',
@@ -204,6 +206,7 @@ const fileExtensionIcons = {
pps: 'powerpoint',
ppam: 'powerpoint',
ppa: 'powerpoint',
+ odp: 'powerpoint',
webm: 'movie',
mkv: 'movie',
flv: 'movie',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 3d8afd162cb..2cb1b6a195f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -1,24 +1,46 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { __ } from '~/locale';
-const DEFAULT_LABEL_NO_LABEL = { value: 'No label', text: __('No label') };
-export const DEFAULT_LABEL_NONE = { value: 'None', text: __('None') };
-export const DEFAULT_LABEL_ANY = { value: 'Any', text: __('Any') };
+export const DEBOUNCE_DELAY = 200;
+export const MAX_RECENT_TOKENS_SIZE = 3;
-export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL];
+export const FILTER_NONE = 'None';
+export const FILTER_ANY = 'Any';
+export const FILTER_CURRENT = 'Current';
-export const DEBOUNCE_DELAY = 200;
+export const OPERATOR_IS = '=';
+export const OPERATOR_IS_TEXT = __('is');
+export const OPERATOR_IS_NOT = '!=';
+
+export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }];
+
+export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) };
+export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) };
+export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+
+export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
+ { value: FILTER_CURRENT, text: __(FILTER_CURRENT) },
+]);
+
+export const DEFAULT_LABELS = [{ value: 'No label', text: __('No label') }]; // eslint-disable-line @gitlab/require-i18n-strings
+
+export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
+ { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings
+ { value: 'Started', text: __('Started') }, // eslint-disable-line @gitlab/require-i18n-strings
+]);
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
-export const DEFAULT_MILESTONES = [
- DEFAULT_LABEL_NONE,
- DEFAULT_LABEL_ANY,
- { value: 'Upcoming', text: __('Upcoming') },
- { value: 'Started', text: __('Started') },
-];
+export const FILTERED_SEARCH_TERM = 'filtered-search-term';
-/* eslint-enable @gitlab/require-i18n-strings */
+export const TOKEN_TITLE_AUTHOR = __('Author');
+export const TOKEN_TITLE_ASSIGNEE = __('Assignee');
+export const TOKEN_TITLE_MILESTONE = __('Milestone');
+export const TOKEN_TITLE_LABEL = __('Label');
+export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
+export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
+export const TOKEN_TITLE_ITERATION = __('Iteration');
+export const TOKEN_TITLE_EPIC = __('Epic');
+export const TOKEN_TITLE_WEIGHT = __('Weight');
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 107ced550c1..3e7feb91b27 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
@@ -93,9 +93,9 @@ export default {
sortBy.sortDirection.descending === this.initialSortBy,
)
.pop();
- selectedSortDirection = this.initialSortBy.endsWith('_desc')
- ? SortDirection.descending
- : SortDirection.ascending;
+ selectedSortDirection = Object.keys(selectedSortOption.sortDirection).find(
+ (key) => selectedSortOption.sortDirection[key] === this.initialSortBy,
+ );
}
return {
@@ -324,7 +324,9 @@ export default {
class="gl-align-self-center"
:checked="checkboxChecked"
@input="$emit('checked-input', $event)"
- />
+ >
+ <span class="gl-sr-only">{{ __('Select all') }}</span>
+ </gl-form-checkbox>
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index a15cf220ee5..e5c8d29e09b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -1,6 +1,9 @@
-import { isEmpty } from 'lodash';
+import { isEmpty, uniqWith, isEqual } from 'lodash';
+import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility';
+import { MAX_RECENT_TOKENS_SIZE } from './constants';
+
/**
* Strips enclosing quotations from a string if it has one.
*
@@ -162,3 +165,38 @@ export function urlQueryToFilter(query = '') {
return { ...memo, [filterName]: { value, operator } };
}, {});
}
+
+/**
+ * Returns array of token values from localStorage
+ * based on provided recentTokenValuesStorageKey
+ *
+ * @param {String} recentTokenValuesStorageKey
+ * @returns
+ */
+export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) {
+ let recentlyUsedTokenValues = [];
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || [];
+ }
+ return recentlyUsedTokenValues;
+}
+
+/**
+ * Sets provided token value to recently used array
+ * within localStorage for provided recentTokenValuesStorageKey
+ *
+ * @param {String} recentTokenValuesStorageKey
+ * @param {Object} tokenValue
+ */
+export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) {
+ const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey);
+
+ recentlyUsedTokenValues.splice(0, 0, { ...tokenValue });
+
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ localStorage.setItem(
+ recentTokenValuesStorageKey,
+ JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
+ );
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
new file mode 100644
index 00000000000..6ebc5431012
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -0,0 +1,167 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+
+import { DEBOUNCE_DELAY } from '../constants';
+import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ },
+ props: {
+ tokenConfig: {
+ type: Object,
+ required: true,
+ },
+ tokenValue: {
+ type: Object,
+ required: true,
+ },
+ tokenActive: {
+ type: Boolean,
+ required: true,
+ },
+ tokensListLoading: {
+ type: Boolean,
+ required: true,
+ },
+ tokenValues: {
+ type: Array,
+ required: true,
+ },
+ fnActiveTokenValue: {
+ type: Function,
+ required: true,
+ },
+ defaultTokenValues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ recentTokenValuesStorageKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ valueIdentifier: {
+ type: String,
+ required: false,
+ default: 'id',
+ },
+ fnCurrentTokenValue: {
+ type: Function,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ searchKey: '',
+ recentTokenValues: this.recentTokenValuesStorageKey
+ ? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey)
+ : [],
+ loading: false,
+ };
+ },
+ computed: {
+ isRecentTokenValuesEnabled() {
+ return Boolean(this.recentTokenValuesStorageKey);
+ },
+ recentTokenIds() {
+ return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name);
+ },
+ currentTokenValue() {
+ if (this.fnCurrentTokenValue) {
+ return this.fnCurrentTokenValue(this.tokenValue.data);
+ }
+ return this.tokenValue.data.toLowerCase();
+ },
+ activeTokenValue() {
+ return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue);
+ },
+ /**
+ * Return all the tokenValues when searchKey is present
+ * otherwise return only the tokenValues which aren't
+ * present in "Recently used"
+ */
+ availableTokenValues() {
+ return this.searchKey
+ ? this.tokenValues
+ : this.tokenValues.filter(
+ (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]),
+ );
+ },
+ },
+ watch: {
+ tokenActive: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.tokenValues.length) {
+ this.$emit('fetch-token-values', this.tokenValue.data);
+ }
+ },
+ },
+ },
+ methods: {
+ handleInput({ data }) {
+ this.searchKey = data;
+ setTimeout(() => {
+ if (!this.tokensListLoading) this.$emit('fetch-token-values', data);
+ }, DEBOUNCE_DELAY);
+ },
+ handleTokenValueSelected(activeTokenValue) {
+ if (this.isRecentTokenValuesEnabled) {
+ setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="tokenConfig"
+ v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }"
+ v-on="this.$parent.$listeners"
+ @input="handleInput"
+ @select="handleTokenValueSelected(activeTokenValue)"
+ >
+ <template #view-token="viewTokenProps">
+ <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
+ </template>
+ <template #view="viewTokenProps">
+ <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
+ </template>
+ <template #suggestions>
+ <template v-if="defaultTokenValues.length">
+ <gl-filtered-search-suggestion
+ v-for="token in defaultTokenValues"
+ :key="token.value"
+ :value="token.value"
+ >
+ {{ token.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+ </template>
+ <template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey">
+ <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header>
+ <slot name="token-values-list" :token-values="recentTokenValues"></slot>
+ <gl-dropdown-divider />
+ </template>
+ <gl-loading-icon v-if="tokensListLoading" />
+ <template v-else>
+ <slot name="token-values-list" :token-values="availableTokenValues"></slot>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
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
index 98190d716c9..f2f4787d80b 100644
--- 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
@@ -10,7 +10,7 @@ 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 { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default {
@@ -33,7 +33,7 @@ export default {
data() {
return {
emojis: this.config.initialEmojis || [],
- defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
+ defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY,
loading: true,
};
},
@@ -47,6 +47,16 @@ export default {
);
},
},
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.emojis.length) {
+ this.fetchEmojiBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
methods: {
fetchEmojiBySearchTerm(searchTerm) {
this.loading = true;
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
index 101c7150c55..1450807b11d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -1,15 +1,18 @@
<script>
-import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ 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';
+import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
export default {
components: {
+ GlDropdownDivider,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
@@ -32,29 +35,16 @@ export default {
},
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;
+ return Number(this.value.data);
+ },
+ defaultEpics() {
+ return this.config.defaultEpics || DEFAULT_NONE_ANY;
+ },
+ idProperty() {
+ return this.config.idProperty || 'id';
},
activeEpic() {
- const currentValueIsString = typeof this.currentValue === 'string';
- return this.epics.find(
- (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
- );
+ return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
},
},
watch: {
@@ -72,20 +62,8 @@ export default {
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];
+ .then((response) => {
+ this.epics = Array.isArray(response) ? response : response.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
@@ -93,17 +71,13 @@ export default {
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
- if (isNumeric(data)) {
- return this.fetchSingleEpic(data);
- }
- return this.fetchEpicsBySearchTerm(data);
+ this.fetchEpicsBySearchTerm(data);
}, DEBOUNCE_DELAY),
- getEpicValue(epic) {
- return `${epic.title}::&${epic.iid}`;
+ getEpicDisplayText(epic) {
+ return `${epic.title}::&${epic[this.idProperty]}`;
},
},
- stripQuotes,
};
</script>
@@ -115,17 +89,25 @@ export default {
@input="searchEpics"
>
<template #view="{ inputValue }">
- <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
+ {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }}
</template>
<template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="epic in defaultEpics"
+ :key="epic.value"
+ :value="epic.value"
+ >
+ {{ epic.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultEpics.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="epic in epics"
- :key="epic.id"
- :value="getEpicValue(epic)"
+ :key="epic[idProperty]"
+ :value="String(epic[idProperty])"
>
- <div>{{ epic.title }}</div>
+ {{ epic.title }}
</gl-filtered-search-suggestion>
</template>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
new file mode 100644
index 00000000000..7b6a590279a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
@@ -0,0 +1,110 @@
+<script>
+import {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants';
+
+export default {
+ components: {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ iterations: this.config.initialIterations || [],
+ defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS,
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data;
+ },
+ activeIteration() {
+ return this.iterations.find((iteration) => iteration.title === this.currentValue);
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.iterations.length) {
+ this.fetchIterationBySearchTerm(this.currentValue);
+ }
+ },
+ },
+ },
+ methods: {
+ fetchIterationBySearchTerm(searchTerm) {
+ const fetchPromise = this.config.fetchPath
+ ? this.config.fetchIterations(this.config.fetchPath, searchTerm)
+ : this.config.fetchIterations(searchTerm);
+
+ this.loading = true;
+
+ fetchPromise
+ .then((response) => {
+ this.iterations = Array.isArray(response) ? response : response.data;
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching iterations.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchIterations: debounce(function debouncedSearch({ data }) {
+ this.fetchIterationBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchIterations"
+ >
+ <template #view="{ inputValue }">
+ {{ activeIteration ? activeIteration.title : inputValue }}
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="iteration in defaultIterations"
+ :key="iteration.value"
+ :value="iteration.value"
+ >
+ {{ iteration.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultIterations.length" />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="iteration in iterations"
+ :key="iteration.title"
+ :value="iteration.title"
+ >
+ {{ iteration.title }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
new file mode 100644
index 00000000000..72116f0e991
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui';
+import { DEFAULT_NONE_ANY } from '../constants';
+
+export default {
+ baseWeights: ['0', '1', '2', '3', '4', '5'],
+ components: {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ weights: this.$options.baseWeights,
+ defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY,
+ };
+ },
+ methods: {
+ updateWeights({ data }) {
+ const weight = parseInt(data, 10);
+ this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="updateWeights"
+ >
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="weight in defaultWeights"
+ :key="weight.value"
+ :value="weight.value"
+ >
+ {{ weight.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultWeights.length" />
+ <gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight">
+ {{ weight }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index be0c843ef00..ccdb47e3144 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -146,6 +146,7 @@ export default {
<span v-if="dueDate" class="order-md-1">
<issue-due-date
:date="dueDate"
+ :closed="Boolean(closedAt)"
tooltip-placement="top"
css-class="item-due-date gl-display-flex gl-align-items-center"
/>
diff --git a/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue
new file mode 100644
index 00000000000..d68c4399275
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue
@@ -0,0 +1,51 @@
+<script>
+export default {
+ props: {
+ slotKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ aliveSlotsLookup: {},
+ };
+ },
+ computed: {
+ aliveSlots() {
+ return Object.keys(this.aliveSlotsLookup);
+ },
+ },
+ watch: {
+ slotKey: {
+ handler(val) {
+ if (!val) {
+ return;
+ }
+
+ this.$set(this.aliveSlotsLookup, val, true);
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ isCurrentSlot(key) {
+ return key === this.slotKey;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-for="slot in aliveSlots"
+ v-show="isCurrentSlot(slot)"
+ :key="slot"
+ class="gl-h-full gl-w-full"
+ >
+ <slot :name="slot"></slot>
+ </div>
+ </div>
+</template>
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 90ac20fe748..d6a20984ad1 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -34,7 +34,7 @@ export default {
boundary="window"
right
menu-class="gl-w-full!"
- data-qa-selector="apply_suggestion_button"
+ data-qa-selector="apply_suggestion_dropdown"
@shown="$refs.commitMessage.$el.focus()"
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
@@ -45,7 +45,7 @@ export default {
v-model="message"
:placeholder="defaultCommitMessage"
submit-on-enter
- data-qa-selector="commit_message_textbox"
+ data-qa-selector="commit_message_field"
@submit="onApply"
/>
<gl-button
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 01cf0beea3a..d343ba700ab 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -63,6 +63,9 @@ export default {
'\n',
);
},
+ mdCollapsibleSection() {
+ return ['<details><summary>Click to expand</summary>', `{text}`, '</details>'].join('\n');
+ },
isMac() {
// Accessing properties using ?. to allow tests to use
// this component without setting up window.gl.client.
@@ -245,6 +248,13 @@ export default {
icon="list-task"
/>
<toolbar-button
+ :tag="mdCollapsibleSection"
+ :prepend="true"
+ tag-select="Click to expand"
+ :button-title="__('Add a collapsible section')"
+ icon="details-block"
+ />
+ <toolbar-button
:tag="mdTable"
:prepend="true"
:button-title="__('Add a table')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index bcd8c02e968..9c954fce322 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -70,7 +70,7 @@ export default {
<template>
<div class="md-suggestion">
<suggestion-diff-header
- class="qa-suggestion-diff-header js-suggestion-diff-header"
+ class="js-suggestion-diff-header"
:suggestions-count="suggestionsCount"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index e2591362611..d05e45e90b3 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -4,6 +4,7 @@ import Api from '~/api';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { initialPaginationState, defaultI18n, defaultPageSize } from './constants';
@@ -105,7 +106,7 @@ export default {
unique: true,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
@@ -116,7 +117,7 @@ export default {
unique: true,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 4ade75e705e..b9e916bc199 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -32,7 +32,7 @@ export default {
return {
'gl-border-t-transparent': !this.first && !this.selected,
'gl-border-t-gray-100': this.first && !this.selected,
- 'disabled-content': this.disabled,
+ 'gl-opacity-5': this.disabled,
'gl-border-b-gray-100': !this.selected,
'gl-bg-blue-50 gl-border-blue-200': this.selected,
};
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 dff3a6a8c3f..07272a5b8d6 100644
--- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -55,13 +55,12 @@ export default {
return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
},
oncallSchedules() {
- let schedules = {};
try {
- schedules = JSON.parse(this.modalData.oncallSchedules);
+ return JSON.parse(this.modalData.oncallSchedules);
} catch (e) {
Sentry.captureException(e);
}
- return schedules;
+ return {};
},
},
mounted() {
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 795b4f58ac5..1f70644eb2c 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -9,7 +9,9 @@ import {
GlIcon,
GlLoadingIcon,
GlSkeletonLoader,
+ GlResizeObserverDirective,
} from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { isEmpty } from 'lodash';
import { __, s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -33,6 +35,9 @@ export default {
GlSkeletonLoader,
ModalCopyButton,
},
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
props: {
modalId: {
type: String,
@@ -87,6 +92,7 @@ export default {
selectedArchitecture: null,
showAlert: false,
instructions: {},
+ platformsButtonGroupVertical: false,
};
},
computed: {
@@ -127,6 +133,13 @@ export default {
toggleAlert(state) {
this.showAlert = state;
},
+ onPlatformsButtonResize() {
+ if (bp.getBreakpointSize() === 'xs') {
+ this.platformsButtonGroupVertical = true;
+ } else {
+ this.platformsButtonGroupVertical = false;
+ }
+ },
},
i18n: {
installARunner: s__('Runners|Install a runner'),
@@ -159,17 +172,23 @@ export default {
<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)"
+ <div v-gl-resize-observer="onPlatformsButtonResize">
+ <gl-button-group
+ :vertical="platformsButtonGroupVertical"
+ :class="{ 'gl-w-full': platformsButtonGroupVertical }"
+ class="gl-mb-3"
+ data-testid="platform-buttons"
>
- {{ platform.humanReadableName }}
- </gl-button>
- </gl-button-group>
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ :selected="selectedPlatform && selectedPlatform.name === platform.name"
+ @click="selectPlatform(platform)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ </div>
</template>
<template v-if="hasArchitecureList">
<template v-if="selectedPlatform">
@@ -190,7 +209,7 @@ export default {
{{ architecture.name }}
</gl-dropdown-item>
</gl-dropdown>
- <div class="gl-display-flex gl-align-items-center gl-mb-3">
+ <div class="gl-sm-display-flex gl-align-items-center gl-mb-3">
<h5>{{ $options.i18n.downloadInstallBinary }}</h5>
<gl-button
class="gl-ml-auto"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 9b28ce0d881..94cf1f84ec3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -61,6 +61,7 @@ export default {
</script>
<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<button
ref="dropdownButton"
:class="{ 'js-extra-options': showExtraOptions }"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index e3704198ad0..d80b66fd9be 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState } from 'vuex';
+import { mapGetters, mapState } from 'vuex';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
@@ -18,6 +18,7 @@ export default {
},
computed: {
...mapState(['showDropdownContentsCreateView']),
+ ...mapGetters(['isDropdownVariantSidebar']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
@@ -25,11 +26,8 @@ export default {
return 'dropdown-contents-labels-view';
},
directionStyle() {
- if (this.renderOnTop) {
- return { bottom: '100%' };
- }
-
- return {};
+ const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
+ return this.renderOnTop ? { bottom } : {};
},
},
};
@@ -37,7 +35,7 @@ export default {
<template>
<div
- class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
+ class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 6065b6c160c..86788a84260 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -83,12 +83,13 @@ export default {
const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
if (highlightedLabel) {
- const rect = highlightedLabel.getBoundingClientRect();
- if (rect.bottom > this.$refs.labelsListContainer.clientHeight) {
- highlightedLabel.scrollIntoView(false);
- }
- if (rect.top < 0) {
- highlightedLabel.scrollIntoView();
+ const container = this.$refs.labelsListContainer.getBoundingClientRect();
+ const label = highlightedLabel.getBoundingClientRect();
+
+ if (label.bottom > container.bottom) {
+ this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom;
+ } else if (label.top < container.top) {
+ this.$refs.labelsListContainer.scrollTop -= container.top - label.top;
}
}
},
@@ -177,7 +178,7 @@ export default {
class="labels-fetch-loading gl-align-items-center w-100 h-100"
size="md"
/>
- <ul v-else class="list-unstyled mb-0">
+ <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word">
<label-item
v-for="(label, index) in visibleLabels"
:key="label.id"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
index e431fd000a6..e8fdf4bb0c2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -22,7 +22,7 @@ export default {
const { label, highlight, isLabelSet } = props;
const labelColorBox = h('span', {
- class: 'dropdown-label-box',
+ class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
style: {
backgroundColor: label.color,
},
@@ -33,7 +33,7 @@ export default {
const checkedIcon = h(GlIcon, {
class: {
- 'mr-2 align-self-center': true,
+ 'gl-mr-3 gl-flex-shrink-0': true,
hidden: !isLabelSet,
},
props: {
@@ -43,7 +43,7 @@ export default {
const noIcon = h('span', {
class: {
- 'mr-3 pr-2': true,
+ 'gl-mr-5 gl-pr-3': true,
hidden: isLabelSet,
},
attrs: {
@@ -56,7 +56,7 @@ export default {
const labelLink = h(
GlLink,
{
- class: 'd-flex align-items-baseline text-break-word label-item',
+ class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal',
on: {
click: () => {
listeners.clickLabel(label);
@@ -70,8 +70,8 @@ export default {
'li',
{
class: {
- 'd-block': true,
- 'text-left': true,
+ 'gl-display-block': true,
+ 'gl-text-left': true,
'is-focused': highlight,
},
},
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 f547433f322..a4462895f6a 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
@@ -268,7 +268,7 @@ export default {
this.$emit('toggleCollapse');
},
setContentIsOnViewport(showDropdownContents) {
- if (!this.isDropdownVariantEmbedded || !showDropdownContents) {
+ if (!showDropdownContents) {
this.contentIsOnViewport = true;
return;
@@ -276,8 +276,7 @@ export default {
this.$nextTick(() => {
if (this.$refs.dropdownContents) {
- const offset = { top: 100 };
- this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el, offset);
+ this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
}
});
},
@@ -313,6 +312,7 @@ export default {
<dropdown-contents
v-show="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
/>
</template>
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
new file mode 100644
index 00000000000..93b9833bb7d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
@@ -0,0 +1,18 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query issueAssignees($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
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 3885127fa8e..48787305459 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
@@ -13,12 +13,6 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability
}
}
- assignees {
- nodes {
- ...User
- ...UserAvailability
- }
- }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
new file mode 100644
index 00000000000..a2990d7171b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
@@ -0,0 +1,14 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+query timeTrackingReport($id: IssueID!) {
+ issuable: issue(id: $id) {
+ __typename
+ id
+ title
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
new file mode 100644
index 00000000000..53f7381760e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -0,0 +1,16 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query getMrAssignees($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ issuable: mergeRequest(iid: $iid) {
+ id
+ 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 63482873b69..6adbd4098f2 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
@@ -11,12 +11,6 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability
}
}
- assignees {
- nodes {
- ...User
- ...UserAvailability
- }
- }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
new file mode 100644
index 00000000000..753f1b345e3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
@@ -0,0 +1,14 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+query timeTrackingReport($id: MergeRequestID!) {
+ issuable: mergeRequest(id: $id) {
+ __typename
+ id
+ title
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+ }
+}
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 3f40c0368d7..24de5ea4fe3 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
@@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
...UserAvailability
}
}
- participants {
- nodes {
- ...User
- ...UserAvailability
- }
- }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 4447a87777a..66088b33c99 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -15,7 +15,7 @@ export default {
mixins: [timeagoMixin],
props: {
time: {
- type: String,
+ type: [String, Number],
required: true,
},
tooltipPlacement: {
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 11f484b2cdf..deac24d2270 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
@@ -20,7 +20,7 @@ export default {
},
props: {
target: {
- type: HTMLAnchorElement,
+ type: HTMLElement,
required: true,
},
user: {
@@ -79,7 +79,7 @@ export default {
<div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
<gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
- <span ref="bio" class="gl-ml-2" v-html="user.bioHtml"></span>
+ <span ref="bio" class="gl-ml-2 gl-overflow-hidden" v-html="user.bioHtml"></span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
<gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
new file mode 100644
index 00000000000..3116d2fbf32
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -0,0 +1,302 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
+import { __ } from '~/locale';
+import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
+import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
+
+export default {
+ i18n: {
+ unassigned: __('Unassigned'),
+ },
+ components: {
+ GlDropdownForm,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ SidebarParticipant,
+ GlLoadingIcon,
+ },
+ props: {
+ headerText: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: Array,
+ required: true,
+ },
+ allowMultipleAssignees: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ currentUser: {
+ type: Object,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ data() {
+ return {
+ search: '',
+ participants: [],
+ searchUsers: [],
+ isSearching: false,
+ };
+ },
+ apollo: {
+ participants: {
+ query() {
+ return participantsQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ iid: this.iid,
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.participants.nodes;
+ },
+ error() {
+ this.$emit('error');
+ },
+ },
+ searchUsers: {
+ // TODO Remove error policy
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ errorPolicy: 'all',
+ query: searchUsers,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.search,
+ first: 20,
+ };
+ },
+ update(data) {
+ // TODO Remove null filter (BE fix required)
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ return data.workspace?.users?.nodes.filter((x) => x).map(({ user }) => user) || [];
+ },
+ debounce: ASSIGNEES_DEBOUNCE_DELAY,
+ error({ graphQLErrors }) {
+ // TODO This error suppression is temporary (BE fix required)
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ const isNullError = ({ message }) => {
+ return message === 'Cannot return null for non-nullable field GroupMember.user';
+ };
+
+ if (graphQLErrors?.length > 0 && graphQLErrors.every(isNullError)) {
+ // only null-related errors exist, suppress them.
+ // eslint-disable-next-line no-console
+ console.error(
+ "Suppressing the error 'Cannot return null for non-nullable field GroupMember.user'. Please see https://gitlab.com/gitlab-org/gitlab/-/issues/329750",
+ );
+ this.isSearching = false;
+ return;
+ }
+
+ this.$emit('error');
+ this.isSearching = false;
+ },
+ result() {
+ this.isSearching = false;
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
+ },
+ users() {
+ if (!this.participants) {
+ return [];
+ }
+
+ const filteredParticipants = this.participants.filter(
+ (user) => user.name.includes(this.search) || user.username.includes(this.search),
+ );
+
+ // TODO this de-duplication is temporary (BE fix required)
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/327822
+ const mergedSearchResults = filteredParticipants
+ .concat(this.searchUsers)
+ .reduce(
+ (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
+ [],
+ );
+
+ return this.moveCurrentUserToStart(mergedSearchResults);
+ },
+ isSearchEmpty() {
+ return this.search === '';
+ },
+ shouldShowParticipants() {
+ return this.isSearchEmpty || this.isSearching;
+ },
+ isCurrentUserInList() {
+ const isCurrentUser = (user) => user.username === this.currentUser.username;
+ return this.users.some(isCurrentUser);
+ },
+ noUsersFound() {
+ return !this.isSearchEmpty && this.users.length === 0;
+ },
+ showCurrentUser() {
+ return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
+ },
+ selectedFiltered() {
+ if (this.shouldShowParticipants) {
+ return this.moveCurrentUserToStart(this.value);
+ }
+
+ const foundUsernames = this.users.map(({ username }) => username);
+ const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
+ return this.moveCurrentUserToStart(filtered);
+ },
+ selectedUserNames() {
+ return this.value.map(({ username }) => username);
+ },
+ unselectedFiltered() {
+ return this.users?.filter(({ username }) => !this.selectedUserNames.includes(username)) || [];
+ },
+ selectedIsEmpty() {
+ return this.selectedFiltered.length === 0;
+ },
+ },
+ watch: {
+ // We need to add this watcher to track the moment when user is alredy typing
+ // but query is still not started due to debounce
+ search(newVal) {
+ if (newVal) {
+ this.isSearching = true;
+ }
+ },
+ },
+ methods: {
+ selectAssignee(user) {
+ let selected = [...this.value];
+ if (!this.allowMultipleAssignees) {
+ selected = [user];
+ } else {
+ selected.push(user);
+ }
+ this.$emit('input', selected);
+ },
+ unselect(name) {
+ const selected = this.value.filter((user) => user.username !== name);
+ this.$emit('input', selected);
+ },
+ focusSearch() {
+ this.$refs.search.focusInput();
+ },
+ showDivider(list) {
+ return list.length > 0 && this.isSearchEmpty;
+ },
+ moveCurrentUserToStart(users) {
+ if (!users) {
+ return [];
+ }
+ const usersCopy = [...users];
+ const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
+
+ if (currentUser) {
+ const index = usersCopy.indexOf(currentUser);
+ usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ }
+
+ return usersCopy;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <template #header>
+ <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
+ <gl-dropdown-divider />
+ <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
+ </template>
+ <gl-dropdown-form class="gl-relative gl-min-h-7">
+ <gl-loading-icon
+ v-if="isLoading"
+ data-testid="loading-participants"
+ size="md"
+ class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
+ />
+ <template v-else>
+ <template v-if="shouldShowParticipants">
+ <gl-dropdown-item
+ v-if="isSearchEmpty"
+ :is-checked="selectedIsEmpty"
+ :is-check-centered="true"
+ data-testid="unassign"
+ @click="$emit('input', [])"
+ >
+ <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
+ v-for="item in selectedFiltered"
+ :key="item.id"
+ is-checked
+ is-check-centered
+ data-testid="selected-participant"
+ @click.stop="unselect(item.username)"
+ >
+ <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)"
+ >
+ <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown-form>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
new file mode 100644
index 00000000000..eff39e2fb89
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -0,0 +1,21 @@
+<script>
+export default {
+ provide() {
+ return {
+ // We can't use this.vuexModule due to bug in vue-apollo when
+ // provide is called in beforeCreate
+ // See https://github.com/vuejs/vue-apollo/pull/1153 for details
+ vuexModule: this.$options.propsData.vuexModule,
+ };
+ },
+ props: {
+ vuexModule: {
+ type: String,
+ required: true,
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index 176954891e9..692f2769b88 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -33,6 +33,10 @@ const focusFirstInvalidInput = (e) => {
}
};
+const getInputElement = (el) => {
+ return el.querySelector('input') || el;
+};
+
const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true);
const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
@@ -91,8 +95,9 @@ export default function initValidation(customFeedbackMap = {}) {
const elDataMap = new WeakMap();
return {
- inserted(el, binding, { context }) {
+ inserted(element, binding, { context }) {
const { arg: showGlobalValidation } = binding;
+ const el = getInputElement(element);
const { form: formEl } = el;
const validate = createValidator(context, feedbackMap);
@@ -121,7 +126,8 @@ export default function initValidation(customFeedbackMap = {}) {
validate({ el, reportInvalidInput: showGlobalValidation });
},
- update(el, binding) {
+ update(element, binding) {
+ const el = getInputElement(element);
const { arg: showGlobalValidation } = binding;
const { validate, isTouched, isBlurred } = elDataMap.get(el);
const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
@@ -130,3 +136,59 @@ export default function initValidation(customFeedbackMap = {}) {
},
};
}
+
+/**
+ * This is a helper that initialize the form fields structure to be used in initForm
+ * @param {*} fieldValues
+ * @returns formObject
+ */
+const initFormField = ({ value, required = true, skipValidation = false }) => ({
+ value,
+ required,
+ state: skipValidation ? true : null,
+ feedback: null,
+});
+
+/**
+ * This is a helper that initialize the form structure that is compliant to be used with the validation directive
+ *
+ * @example
+ * const form initForm = initForm({
+ * fields: {
+ * name: {
+ * value: 'lorem'
+ * },
+ * description: {
+ * value: 'ipsum',
+ * required: false,
+ * skipValidation: true
+ * }
+ * }
+ * })
+ *
+ * @example
+ * const form initForm = initForm({
+ * state: true, // to override
+ * foo: { // something custom
+ * bar: 'lorem'
+ * },
+ * fields: {...}
+ * })
+ *
+ * @param {*} formObject
+ * @returns form
+ */
+export const initForm = ({ fields = {}, ...rest } = {}) => {
+ const initFields = Object.fromEntries(
+ Object.entries(fields).map(([fieldName, fieldValues]) => {
+ return [fieldName, initFormField(fieldValues)];
+ }),
+ );
+
+ return {
+ state: false,
+ showValidation: false,
+ ...rest,
+ fields: initFields,
+ };
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index af14c6d9486..45452f2ea35 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -14,5 +14,25 @@ export default {
tooltipTitle(time) {
return formatDate(time);
},
+
+ durationTimeFormatted(duration) {
+ const date = new Date(duration * 1000);
+
+ let hh = date.getUTCHours();
+ let mm = date.getUTCMinutes();
+ let ss = date.getSeconds();
+
+ if (hh < 10) {
+ hh = `0${hh}`;
+ }
+ if (mm < 10) {
+ mm = `0${mm}`;
+ }
+ if (ss < 10) {
+ ss = `0${ss}`;
+ }
+
+ return `${hh}:${mm}:${ss}`;
+ },
},
};
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
index d2fc2c66924..d2fc2c66924 100644
--- a/app/assets/javascripts/projects/experiment_new_project_creation/components/legacy_container.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
new file mode 100644
index 00000000000..e9983af5401
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import Vue from 'vue';
+import Tracking from '~/tracking';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ panels: {
+ type: Array,
+ required: true,
+ },
+ experiment: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ created() {
+ const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: this.experiment });
+ const trackingInstance = new Vue({
+ ...trackingMixin,
+ render() {
+ return null;
+ },
+ });
+ this.track = trackingInstance.track;
+ },
+};
+</script>
+<template>
+ <div class="container">
+ <h2 class="gl-my-7 gl-font-size-h1 gl-text-center">
+ {{ title }}
+ </h2>
+ <div>
+ <div
+ v-for="panel in panels"
+ :key="panel.name"
+ class="new-namespace-panel-wrapper gl-display-inline-block gl-px-3 gl-mb-5"
+ >
+ <a
+ :href="`#${panel.name}`"
+ :data-qa-selector="`${panel.name}_link`"
+ class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!"
+ @click="track('click_tab', { label: panel.name })"
+ >
+ <div
+ v-safe-html="panel.illustration"
+ class="new-namespace-panel-illustration gl-text-white gl-display-flex gl-flex-shrink-0 gl-justify-content-center"
+ ></div>
+ <div class="gl-pl-4">
+ <h3 class="gl-font-size-h2 gl-reset-color">
+ {{ panel.title }}
+ </h3>
+ <p class="gl-text-gray-900">
+ {{ panel.description }}
+ </p>
+ </div>
+ </a>
+ </div>
+ </div>
+ <slot name="footer"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
new file mode 100644
index 00000000000..54313297b14
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+
+import LegacyContainer from './components/legacy_container.vue';
+import WelcomePage from './components/welcome.vue';
+
+export default {
+ components: {
+ GlBreadcrumb,
+ GlIcon,
+ WelcomePage,
+ LegacyContainer,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ initialBreadcrumb: {
+ type: String,
+ required: true,
+ },
+ panels: {
+ type: Array,
+ required: true,
+ },
+ jumpToLastPersistedPanel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ persistenceKey: {
+ type: String,
+ required: true,
+ },
+ experiment: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ data() {
+ return {
+ activePanelName: null,
+ };
+ },
+
+ computed: {
+ activePanel() {
+ return this.panels.find((p) => p.name === this.activePanelName);
+ },
+
+ details() {
+ return this.activePanel.details || this.activePanel.description;
+ },
+
+ hasTextDetails() {
+ return typeof this.details === 'string';
+ },
+
+ breadcrumbs() {
+ if (!this.activePanel) {
+ return null;
+ }
+
+ return [
+ { text: this.initialBreadcrumb, href: '#' },
+ { text: this.activePanel.title, href: `#${this.activePanel.name}` },
+ ];
+ },
+ },
+
+ created() {
+ this.handleLocationHashChange();
+
+ if (this.jumpToLastPersistedPanel) {
+ this.activePanelName = localStorage.getItem(this.persistenceKey) || this.panels[0].name;
+ }
+
+ window.addEventListener('hashchange', () => {
+ this.handleLocationHashChange();
+ this.$emit('panel-change');
+ });
+
+ this.$root.$on('clicked::link', (e) => {
+ window.location = e.target.href;
+ });
+ },
+
+ methods: {
+ handleLocationHashChange() {
+ this.activePanelName = window.location.hash.substring(1) || null;
+ if (this.activePanelName) {
+ localStorage.setItem(this.persistenceKey, this.activePanelName);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <welcome-page
+ v-if="activePanelName === null"
+ :panels="panels"
+ :title="title"
+ :experiment="experiment"
+ >
+ <template #footer>
+ <slot name="welcome-footer"> </slot>
+ </template>
+ </welcome-page>
+ <div v-else class="row">
+ <div class="col-lg-3">
+ <div v-safe-html="activePanel.illustration" class="gl-text-white"></div>
+ <h4>{{ activePanel.title }}</h4>
+
+ <p v-if="hasTextDetails">{{ details }}</p>
+ <component :is="details" v-else />
+
+ <slot name="extra-description"></slot>
+ </div>
+ <div class="col-lg-9">
+ <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs">
+ <template #separator>
+ <gl-icon name="chevron-right" :size="8" />
+ </template>
+ </gl-breadcrumb>
+ <legacy-container :key="activePanel.name" class="gl-mt-3" :selector="activePanel.selector" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
new file mode 100644
index 00000000000..12e5f634a08
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { sprintf, s__ } from '~/locale';
+import apolloProvider from '../provider';
+
+export default {
+ apolloProvider,
+ components: {
+ GlButton,
+ },
+ inject: ['projectPath'],
+ props: {
+ feature: {
+ type: Object,
+ required: true,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: 'success',
+ },
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ computed: {
+ featureSettings() {
+ return featureToMutationMap[this.feature.type];
+ },
+ },
+ methods: {
+ async mutate() {
+ this.isLoading = true;
+ try {
+ const mutation = this.featureSettings;
+ const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath));
+ const { errors, successPath } = data[mutation.mutationId];
+
+ if (errors.length > 0) {
+ throw new Error(errors[0]);
+ }
+
+ if (!successPath) {
+ throw new Error(
+ sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
+ );
+ }
+
+ redirectTo(successPath);
+ } catch (e) {
+ this.$emit('error', e.message);
+ this.isLoading = false;
+ }
+ },
+ },
+ i18n: {
+ buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
+ noSuccessPathError: s__(
+ 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-if="!feature.configured"
+ :loading="isLoading"
+ :variant="variant"
+ :category="category"
+ @click="mutate"
+ >{{ $options.i18n.buttonLabel }}</gl-button
+ >
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js
new file mode 100644
index 00000000000..ef96b443da8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js
@@ -0,0 +1,9 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export default new VueApollo({
+ defaultClient: createDefaultClient(),
+});
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_merge_request_download_paths.query.graphql
index 4ce13827da2..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_merge_request_download_paths.query.graphql
diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql
new file mode 100644
index 00000000000..c7e9fa16418
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql
@@ -0,0 +1,18 @@
+query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) {
+ project(fullPath: $projectPath) {
+ pipeline(iid: $iid) {
+ id
+ jobs(securityReportTypes: $reportTypes) {
+ nodes {
+ name
+ artifacts {
+ nodes {
+ downloadPath
+ fileType
+ }
+ }
+ }
+ }
+ }
+ }
+}
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 1151cffa76f..b7f283b8fd9 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
@@ -13,10 +13,10 @@ import {
REPORT_TYPE_SECRET_DETECTION,
reportTypeToSecurityReportTypeEnum,
} from './constants';
-import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql';
+import securityReportMergeRequestDownloadPathsQuery from './queries/security_report_merge_request_download_paths.query.graphql';
import store from './store';
import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
-import { extractSecurityReportArtifacts } from './utils';
+import { extractSecurityReportArtifactsFromMergeRequest } from './utils';
export default {
store,
@@ -86,7 +86,7 @@ export default {
},
apollo: {
reportArtifacts: {
- query: securityReportDownloadPathsQuery,
+ query: securityReportMergeRequestDownloadPathsQuery,
variables() {
return {
projectPath: this.targetProjectFullPath,
@@ -97,7 +97,7 @@ export default {
};
},
update(data) {
- return extractSecurityReportArtifacts(this.$options.reportTypes, data);
+ return extractSecurityReportArtifactsFromMergeRequest(this.$options.reportTypes, data);
},
error(error) {
this.showError(error);
diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js
index ad819bf7081..c3f24a7e52f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/utils.js
@@ -14,9 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa
}
};
-export const extractSecurityReportArtifacts = (reportTypes, data) => {
- const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
-
+const extractSecurityReportArtifacts = (reportTypes, jobs) => {
return jobs.reduce((acc, job) => {
const artifacts = job.artifacts?.nodes ?? [];
@@ -41,3 +39,13 @@ export const extractSecurityReportArtifacts = (reportTypes, data) => {
return acc;
}, []);
};
+
+export const extractSecurityReportArtifactsFromPipeline = (reportTypes, data) => {
+ const jobs = data.project?.pipeline?.jobs?.nodes ?? [];
+ return extractSecurityReportArtifacts(reportTypes, jobs);
+};
+
+export const extractSecurityReportArtifactsFromMergeRequest = (reportTypes, data) => {
+ const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
+ return extractSecurityReportArtifacts(reportTypes, jobs);
+};
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 4a387edbe3f..4ee586527b5 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -30,7 +30,7 @@ export default {
},
mounted() {
this.openDrawer(this.versionDigest);
- this.fetchItems();
+ this.fetchFreshItems();
const body = document.querySelector('body');
const namespaceId = body.getAttribute('data-namespace-id');
@@ -42,13 +42,18 @@ export default {
bottomReached() {
const page = this.pageInfo.nextPage;
if (page) {
- this.fetchItems({ page });
+ this.fetchFreshItems(page);
}
},
handleResize() {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height);
},
+ fetchFreshItems(page) {
+ const { versionDigest } = this;
+
+ this.fetchItems({ page, versionDigest });
+ },
},
};
</script>
@@ -58,7 +63,7 @@ export default {
<gl-drawer
ref="drawer"
v-gl-resize-observer="handleResize"
- class="whats-new-drawer"
+ class="whats-new-drawer gl-reset-line-height"
:z-index="700"
:open="open"
@close="closeDrawer"
@@ -83,6 +88,6 @@ export default {
<skeleton-loader />
</div>
</gl-drawer>
- <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div>
+ <div v-if="open" class="whats-new-modal-backdrop modal-backdrop" @click="closeDrawer"></div>
</div>
</template>
diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue
index f6f7618b0d8..5444e77a4d2 100644
--- a/app/assets/javascripts/whats_new/components/feature.vue
+++ b/app/assets/javascripts/whats_new/components/feature.vue
@@ -1,11 +1,13 @@
<script>
-import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective, GlButton } from '@gitlab/ui';
+import { dateInWords, isValidDate } from '~/lib/utils/datetime_utility';
export default {
components: {
GlBadge,
GlIcon,
GlLink,
+ GlButton,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -16,52 +18,68 @@ export default {
required: true,
},
},
+ computed: {
+ releaseDate() {
+ const { published_at } = this.feature;
+ const date = new Date(published_at);
+
+ if (!isValidDate(date) || date.getTime() === 0) {
+ return '';
+ }
+
+ return dateInWords(date);
+ },
+ },
};
</script>
<template>
- <div class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
+ <div class="gl-py-6 gl-px-6 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ class="gl-display-block"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
+ <div
+ class="whats-new-item-image gl-bg-size-cover"
+ :style="`background-image: url(${feature.image_url});`"
+ >
+ <span class="gl-sr-only">{{ feature.title }}</span>
+ </div>
+ </gl-link>
<gl-link
:href="feature.url"
target="_blank"
- class="whats-new-item-title-link"
+ class="whats-new-item-title-link gl-display-block gl-mt-4 gl-mb-1"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
- <h5 class="gl-font-lg" data-test-id="feature-title">{{ feature.title }}</h5>
+ <h5 class="gl-font-lg gl-my-0" data-test-id="feature-title">{{ feature.title }}</h5>
</gl-link>
+ <div v-if="releaseDate" class="gl-mb-3" data-testid="release-date">{{ releaseDate }}</div>
<div v-if="feature.packages" class="gl-mb-3">
<gl-badge
v-for="packageName in feature.packages"
:key="packageName"
- size="sm"
+ size="md"
class="whats-new-item-badge gl-mr-2"
>
<gl-icon name="license" />{{ packageName }}
</gl-badge>
</div>
- <gl-link
- :href="feature.url"
- target="_blank"
- data-track-event="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
- >
- <img
- :alt="feature.title"
- :src="feature.image_url"
- class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
- />
- </gl-link>
- <div v-safe-html="feature.body" class="gl-pt-3"></div>
- <gl-link
+ <div v-safe-html="feature.body" class="gl-pt-3 gl-line-height-20"></div>
+ <gl-button
:href="feature.url"
target="_blank"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
- >{{ __('Learn more') }}</gl-link
>
+ {{ __('Learn more') }} <gl-icon name="arrow-right" />
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 1dc92ea2606..f209f145884 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -14,17 +14,19 @@ export default {
localStorage.setItem(STORAGE_KEY, versionDigest);
}
},
- fetchItems({ commit, state }, { page } = { page: null }) {
+ fetchItems({ commit, state }, { page, versionDigest } = { page: null, versionDigest: null }) {
if (state.fetching) {
return false;
}
commit(types.SET_FETCHING, true);
+ const v = versionDigest;
return axios
.get('/-/whats_new', {
params: {
page,
+ v,
},
})
.then(({ data, headers }) => {
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index a5cfc8d12b0..c4f292dd05d 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -105,10 +105,6 @@ hr {
}
}
-kbd {
- display: inline-block;
-}
-
code {
padding: 2px 4px;
color: $code-color;
diff --git a/app/assets/stylesheets/components/feature_highlight.scss b/app/assets/stylesheets/components/feature_highlight.scss
index 08706951967..54123e74675 100644
--- a/app/assets/stylesheets/components/feature_highlight.scss
+++ b/app/assets/stylesheets/components/feature_highlight.scss
@@ -7,3 +7,25 @@
padding: 0.25rem;
}
}
+
+.gl-order-1 {
+ order: 1;
+}
+
+.gl-sm-order-init {
+ @media (min-width: $breakpoint-sm) {
+ order: initial;
+ }
+}
+
+.gl-xs-ml-3 {
+ @media (max-width: $breakpoint-sm) {
+ @include gl-ml-3;
+ }
+}
+
+.gl-sm-mr-3 {
+ @media (min-width: $breakpoint-sm) {
+ @include gl-mr-3;
+ }
+}
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index 3e9060e869b..7af97505749 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -2,6 +2,7 @@
margin-top: $header-height;
@include gl-shadow-none;
overflow-y: hidden;
+ width: 500px;
.gl-infinite-scroll-legend {
@include gl-display-none;
@@ -54,18 +55,9 @@
.whats-new-item-image {
border-color: $gray-50;
+ height: 250px;
}
.whats-new-modal-backdrop {
z-index: 699;
}
-
-.whats-new-notification-count {
- @include gl-bg-gray-900;
- @include gl-font-sm;
- @include gl-line-height-normal;
- @include gl-text-white;
- @include gl-vertical-align-top;
- border-radius: 20px;
- padding: 3px 10px;
-}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 1fe94a796f5..cde5ad24fa5 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -22,6 +22,7 @@
@import 'framework/flash';
@import 'framework/forms';
@import 'framework/gfm';
+@import 'framework/kbd';
@import 'framework/header';
@import 'framework/highlight';
@import 'framework/issue_box';
@@ -45,7 +46,6 @@
@import 'framework/toggle';
@import 'framework/typography';
@import 'framework/zen';
-@import 'framework/blank';
@import 'framework/wells';
@import 'framework/page_header';
@import 'framework/page_title';
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
deleted file mode 100644
index 0ada5fabde9..00000000000
--- a/app/assets/stylesheets/framework/blank.scss
+++ /dev/null
@@ -1,136 +0,0 @@
-.blank-state-parent-container {
- .section-container {
- padding: 10px;
- }
-
- .section-body {
- width: 100%;
- height: 100%;
- padding-bottom: 25px;
- border-radius: $border-radius-default;
- }
-}
-
-.blank-state-row {
- display: flex;
- flex-wrap: wrap;
- justify-content: space-between;
-}
-
-.blank-state-welcome {
- text-align: center;
- padding: $gl-padding 0 ($gl-padding * 2);
-
- .blank-state-welcome-title {
- font-size: 24px;
- }
-
- .blank-state-text {
- margin-bottom: 0;
- }
-}
-
-.blank-state-link {
- color: $gl-text-color;
- margin-bottom: 15px;
-
- &:hover {
- background-color: $gray-light;
- text-decoration: none;
- color: $gl-text-color;
- }
-}
-
-.blank-state-center {
- padding-top: 20px;
- padding-bottom: 20px;
- text-align: center;
-}
-
-.blank-state {
- display: flex;
- align-items: center;
- padding: 20px 50px;
- border: 1px solid $border-color;
- border-radius: $border-radius-default;
- min-height: 240px;
- margin-bottom: $gl-padding;
- width: calc(50% - #{$gl-padding-8});
-
- @include media-breakpoint-down(sm) {
- width: 100%;
- flex-direction: column;
- justify-content: center;
- padding: 50px 20px;
-
- .column-small & {
- width: 100%;
- }
-
- }
-}
-
-.blank-state,
-.blank-state-center {
- .blank-state-icon {
- svg {
- display: block;
- margin: auto;
- }
- }
-
- .blank-state-title {
- margin-top: 0;
- font-size: 18px;
- }
-
- .blank-state-body {
- @include media-breakpoint-down(sm) {
- text-align: center;
- margin-top: 20px;
- }
-
- @include media-breakpoint-up(sm) {
- padding-left: 20px;
- }
- }
-}
-
-@include media-breakpoint-up(lg) {
- .column-large {
- flex: 2;
- }
-
- .column-small {
- flex: 1;
- margin-bottom: 15px;
-
- .blank-state {
- max-width: 400px;
- flex-wrap: wrap;
- margin-left: 15px;
- }
-
- .blank-state-icon {
- margin-bottom: 30px;
- }
- }
-}
-
-.experiment-new-project-page-blank-state {
- @include media-breakpoint-down(md) {
- flex-direction: column;
- justify-content: center;
- text-align: center;
- }
-
- .blank-state-icon {
- min-width: 215px;
- }
-}
-
-$experiment-new-project-indigo-700: #41419f;
-
-.experiment-new-project-page-blank-state-title {
- color: $experiment-new-project-indigo-700;
-}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index d1fa1187703..603d28a8395 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -337,7 +337,7 @@
.btn-loading {
&:not(.disabled) {
- .spinner {
+ .gl-spinner {
display: none;
}
}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 652ffd79ab3..a7ce19ffc69 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -93,6 +93,9 @@
.tab-content {
overflow: visible;
+ @include media-breakpoint-down(sm) {
+ isolation: isolate;
+ }
}
pre {
@@ -266,12 +269,6 @@ img.emoji {
height: 220px;
}
-.description-block {
- @extend .light-well;
- @extend .light;
- margin-bottom: 10px;
-}
-
.footer-links {
margin-bottom: 20px;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index c5467c304ec..14d1a0663d0 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -429,30 +429,6 @@
display: none;
}
-.toggle-mobile-nav {
- display: none;
- background-color: transparent;
- border: 0;
- padding: 6px 16px;
- margin: 0 0 0 -15px;
- height: 46px;
- color: $gl-text-color;
-
- @include media-breakpoint-down(sm) {
- display: flex;
- align-items: center;
-
- i {
- font-size: 18px;
- }
-
- + .breadcrumbs-links {
- padding-left: $gl-padding;
- border-left: 1px solid $gl-text-color-quaternary;
- }
- }
-}
-
@include media-breakpoint-down(sm) {
.close-nav-button {
display: flex;
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index bc7a31c112f..a07e0b48cff 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -670,10 +670,6 @@ table.code {
float: right;
}
-.files-changed {
- border-bottom: 0;
-}
-
.merge-request-details .file-content.image_file img {
max-height: 50vh;
}
@@ -733,7 +729,7 @@ table.code {
}
.files {
- .diff-file:last-child {
+ .diff-file:not(.is-virtual-scrolling):last-child {
margin-bottom: 0;
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index ff42cd836da..894eddbe1a7 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -147,7 +147,7 @@
position: absolute;
}
- .spinner {
+ .gl-spinner {
position: absolute;
top: 9px;
right: 8px;
@@ -266,15 +266,6 @@
}
}
- .shortcut-mappings {
- display: none;
- }
-
- &.shortcuts .shortcut-mappings {
- display: inline-block;
- margin-right: 5px;
- }
-
ul {
margin: 0;
padding: 0;
@@ -848,12 +839,56 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.frequent-items-dropdown-container {
display: flex;
flex-direction: row;
- width: 500px;
- height: 354px;
+ height: $grid-size * 40;
+
+ &.with-deprecated-styles {
+ width: 500px;
+ height: 354px;
+
+ .section-header,
+ .frequent-items-list-container li.section-empty {
+ padding: 0 $gl-padding;
+ }
+
+ .search-input-container {
+ position: relative;
+ padding: 4px $gl-padding;
+
+ .search-icon {
+ position: absolute;
+ top: 13px;
+ right: 25px;
+ color: $gray-300;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ flex-direction: column;
+ width: 100%;
+ height: auto;
+ flex: 1;
+
+ .frequent-items-dropdown-sidebar,
+ .frequent-items-dropdown-content {
+ width: 100%;
+ }
+
+ .frequent-items-dropdown-sidebar {
+ border-bottom: 1px solid $border-color;
+ border-right: 0;
+ }
+ }
+
+ .frequent-items-list-container {
+ width: auto;
+ height: auto;
+ padding-bottom: 0;
+ }
+ }
.frequent-items-dropdown-sidebar,
.frequent-items-dropdown-content {
- padding: 8px 0;
+ @include gl-pt-3;
}
.loading-animation {
@@ -870,32 +905,13 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
width: 70%;
}
- @include media-breakpoint-down(xs) {
- flex-direction: column;
- width: 100%;
- height: auto;
- flex: 1;
-
- .frequent-items-dropdown-sidebar,
- .frequent-items-dropdown-content {
- width: 100%;
- }
-
- .frequent-items-dropdown-sidebar {
- border-bottom: 1px solid $border-color;
- border-right: 0;
- }
- }
-
.section-header,
.frequent-items-list-container li.section-empty {
- padding: 0 $gl-padding;
color: $gl-text-color-secondary;
font-size: $gl-font-size;
}
.frequent-items-list-container {
- height: 304px;
padding: 8px 0;
overflow-y: auto;
@@ -908,36 +924,16 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
}
}
- .search-input-container {
- position: relative;
- padding: 4px $gl-padding;
-
- .search-icon {
- position: absolute;
- top: 13px;
- right: 25px;
- color: $gray-300;
- }
- }
-
.section-header {
font-weight: 700;
margin-top: 8px;
}
-
- @include media-breakpoint-down(xs) {
- .frequent-items-list-container {
- width: auto;
- height: auto;
- padding-bottom: 0;
- }
- }
}
.frequent-items-list-item-container {
.frequent-items-item-avatar-container,
.frequent-items-item-metadata-container {
- float: left;
+ flex-shrink: 0;
}
.frequent-items-item-metadata-container {
diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss
index 78995c6e4f5..05b53e0c3d8 100644
--- a/app/assets/stylesheets/framework/editor-lite.scss
+++ b/app/assets/stylesheets/framework/editor-lite.scss
@@ -11,7 +11,7 @@
&::before {
content: '';
- @include spinner(32px, 3px);
+ @include spinner-deprecated(32px, 3px);
@include gl-absolute;
@include gl-z-index-1;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 432be7d0b3f..7566a533911 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -1,3 +1,5 @@
+$top-nav-hover-bg: var(--indigo-900-alpha-008, $indigo-900-alpha-008) !important;
+
.navbar-gitlab {
padding: 0 16px;
z-index: $header-zindex;
@@ -254,6 +256,7 @@
}
}
+ .top-nav-toggle,
> button {
background: transparent;
border: 0;
@@ -605,3 +608,60 @@
@include media-breakpoint-down(xs) { margin-right: 3px; }
}
+
+.toggle-mobile-nav {
+ display: none;
+ background-color: transparent;
+ border: 0;
+ padding: 6px 16px;
+ margin: 0 0 0 -15px;
+ height: 46px;
+ color: $gl-text-color;
+
+ @include media-breakpoint-down(sm) {
+ display: flex;
+ align-items: center;
+
+ i {
+ font-size: 18px;
+ }
+
+ + .breadcrumbs-links {
+ padding-left: $gl-padding;
+ border-left: 1px solid $gl-text-color-quaternary;
+ }
+ }
+}
+
+.top-nav-container-view {
+ .gl-new-dropdown & .gl-search-box-by-type {
+ @include gl-m-0;
+ }
+
+ .frequent-items-list-item-container > a:hover {
+ background-color: $top-nav-hover-bg;
+ }
+}
+
+.top-nav-toggle {
+ .dropdown-icon {
+ @include gl-mr-3;
+ }
+
+ .dropdown-chevron {
+ top: 0;
+ }
+}
+
+.top-nav-menu-item {
+ color: var(--indigo-900, $theme-indigo-900) !important;
+
+ &.active,
+ &:hover {
+ background-color: $top-nav-hover-bg;
+ }
+
+ .gl-icon {
+ color: inherit !important;
+ }
+}
diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss
index 4d5032ac674..8baf70da0c6 100644
--- a/app/assets/stylesheets/framework/issue_box.scss
+++ b/app/assets/stylesheets/framework/issue_box.scss
@@ -5,16 +5,7 @@
*/
.status-box {
-
- /* Extra small devices (phones, less than 768px) */
- /* No media query since this is the default in Bootstrap */
- padding: 5px 11px;
- margin-top: 4px;
- /* Small devices (tablets, 768px and up) */
- @include media-breakpoint-up(sm) {
- padding: 0 $gl-btn-padding;
- margin-top: 5px;
- }
+ padding: 0 $gl-btn-padding;
border-radius: $border-radius-default;
display: block;
diff --git a/app/assets/stylesheets/framework/kbd.scss b/app/assets/stylesheets/framework/kbd.scss
new file mode 100644
index 00000000000..05991bc16fd
--- /dev/null
+++ b/app/assets/stylesheets/framework/kbd.scss
@@ -0,0 +1,15 @@
+kbd {
+ display: inline-block;
+ padding: 3px 5px;
+ font-size: $gl-font-size-monospace-sm;
+ line-height: 10px;
+ color: var(--gray-700, $gray-700);
+ vertical-align: middle;
+ background-color: var(--gray-10, $gray-10);
+ border-width: 1px;
+ border-style: solid;
+ border-color: var(--gray-100, $gray-100) var(--gray-100, $gray-100) var(--gray-200, $gray-200);
+ border-image: none;
+ border-radius: 3px;
+ box-shadow: 0 -1px 0 var(--gray-200, $gray-200) inset;
+}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index df2ba718c72..a3e8b2c245c 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -146,7 +146,7 @@ ul.content-list {
> .btn,
> .btn-group,
> .dropdown.inline {
- margin-right: $gl-padding-top;
+ margin-right: $grid-size;
display: inline-block;
margin-top: 3px;
margin-bottom: 4px;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 27b7cac2df5..f904ef11f5b 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -182,6 +182,11 @@
width: 100%;
}
+ /* This resets the width of the control so that the search button doesn't wrap */
+ .gl-search-box-by-click .form-control {
+ width: 1%;
+ }
+
.dropdown-menu-toggle {
margin-bottom: 0;
}
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
index c8eadce5c51..afd2e7ff757 100644
--- a/app/assets/stylesheets/framework/spinner.scss
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -1,16 +1,20 @@
-@mixin spinner-color($color) {
+/**
+* Do not use these spinner mixins. Rely on GitLab UI
+* GlLoadingIcon component instead.
+*/
+@mixin spinner-color-deprecated($color) {
border-color: rgba($color, 0.25);
border-top-color: $color;
}
-@mixin spinner-size($size, $border-width) {
+@mixin spinner-size-deprecated($size, $border-width) {
width: $size;
height: $size;
border-width: $border-width;
@include webkit-prefix(transform-origin, 50% 50% calc((#{$size} / 2) + #{$border-width}));
}
-@keyframes spinner-rotate {
+@keyframes spinner-rotate-deprecated {
0% {
transform: rotate(0);
}
@@ -20,47 +24,16 @@
}
}
-@mixin spinner($size: 16px, $border-width: 2px, $color: $gray-700) {
+@mixin spinner-deprecated($size: 16px, $border-width: 2px, $color: $gray-700) {
border-radius: 50%;
position: relative;
margin: 0 auto;
- animation-name: spinner-rotate;
+ animation-name: spinner-rotate-deprecated;
animation-duration: 0.6s;
animation-timing-function: linear;
animation-iteration-count: infinite;
border-style: solid;
display: inline-flex;
- @include spinner-size($size, $border-width);
- @include spinner-color($color);
-}
-
-.spinner {
- @include spinner;
-
- &.spinner-md {
- @include spinner-size(32px, 3px);
- }
-
- &.spinner-lg {
- @include spinner-size(64px, 4px);
- }
-
- &.spinner-dark {
- @include spinner-color($gray-700);
- }
-
- &.spinner-light {
- @include spinner-color($white);
- }
-}
-
-.btn {
- .spinner,
- .gl-spinner {
- vertical-align: text-bottom;
- }
-}
-
-.spin {
- animation: spinner-rotate 2s infinite linear;
+ @include spinner-size-deprecated($size, $border-width);
+ @include spinner-color-deprecated($color);
}
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 1504f3ee50f..9b38e842635 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -50,6 +50,12 @@
img.avatar {
margin-right: $gl-padding;
+
+ @include media-breakpoint-down(sm) {
+ width: $gl-spacing-scale-6;
+ height: $gl-spacing-scale-6;
+ margin-right: $gl-padding-8;
+ }
}
.controls {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 648ae29e212..603b05efe10 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -81,22 +81,6 @@
word-break: keep-all;
}
- kbd {
- display: inline-block;
- padding: 3px 5px;
- font-size: 11px;
- line-height: 10px;
- color: $gray-700;
- vertical-align: middle;
- background-color: $gray-10;
- border-width: 1px;
- border-style: solid;
- border-color: $gray-100 $gray-100 $gray-200;
- border-image: none;
- border-radius: 3px;
- box-shadow: 0 -1px 0 $gray-200 inset;
- }
-
h1 {
font-size: 1.75em;
font-weight: $gl-font-weight-bold;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 18aa0d3013d..bfb21d7112b 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -283,6 +283,8 @@ $indigo-700: #4b4ba3;
$indigo-800: #393982;
$indigo-900: #292961;
$indigo-950: #1a1a40;
+// To do this variant right for darkmode, we need to create a variable for it.
+$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$theme-blue-50: #f4f8fc;
$theme-blue-100: #e6edf5;
diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
index 0d6f360112b..2f8602a212d 100644
--- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
@@ -147,11 +147,11 @@
display: block;
&:hover {
- box-shadow: inset 0 0 0 0.0625rem $dropdown-toggle-active-border-color;
+ box-shadow: inset 0 0 0 2px var(--gray-400, $gray-400);
background-color: var(--gray-50, $gray-50);
}
- .spinner,
+ .gl-spinner,
svg {
width: $ci-action-dropdown-svg-size;
height: $ci-action-dropdown-svg-size;
@@ -176,12 +176,6 @@
li {
position: relative;
- // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
- &:hover > .mini-pipeline-graph-dropdown-item,
- &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
- @extend .mini-pipeline-graph-dropdown-item:hover;
- }
-
// link to the build
.mini-pipeline-graph-dropdown-item {
align-items: center;
@@ -216,13 +210,16 @@
display: block;
}
}
+ }
- &:hover,
- &:focus {
- outline: none;
- text-decoration: none;
- background-color: var(--gray-100, $gray-50);
- }
+ // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
+ &:hover > .mini-pipeline-graph-dropdown-item,
+ &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item,
+ .mini-pipeline-graph-dropdown-item:hover,
+ .mini-pipeline-graph-dropdown-item:focus {
+ outline: none;
+ text-decoration: none;
+ background-color: var(--gray-100, $gray-50);
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index b91850f1775..ec41909beec 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -11,10 +11,6 @@
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;
- z-index: 1;
.with-performance-bar & {
top: $header-height + $performance-bar-height;
@@ -202,10 +198,6 @@
}
.build-job {
- &.active {
- font-weight: $gl-font-weight-bold;
- }
-
&.retried {
background-color: var(--gray-10, $gray-10);
}
diff --git a/app/assets/stylesheets/page_bundles/dev_ops_report.scss b/app/assets/stylesheets/page_bundles/dev_ops_report.scss
deleted file mode 100644
index 5c6019efce6..00000000000
--- a/app/assets/stylesheets/page_bundles/dev_ops_report.scss
+++ /dev/null
@@ -1,261 +0,0 @@
-@import 'mixins_and_variables_and_functions';
-
-$space-between-cards: 8px;
-
-.devops-empty svg {
- margin: 64px auto 32px;
- max-width: 420px;
-}
-
-.devops-header {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
- padding: 0 4px;
- display: flex;
- align-items: center;
-
- .devops-header-title {
- font-size: 48px;
- line-height: 1;
- margin: 0;
- }
-
- .devops-header-subtitle {
- font-size: 22px;
- line-height: 1;
- color: var(--gl-text-color-secondary, $gl-text-color-secondary);
- margin-left: 8px;
- font-weight: $gl-font-weight-normal;
-
- .devops-header-icon {
- vertical-align: px-to-rem(-$gl-spacing-scale-1);
- }
-
- a {
- font-size: 18px;
- color: var(--gl-text-color-secondary, $gl-text-color-secondary);
-
- &:hover {
- color: var(--blue-500, $blue-500);
- }
- }
- }
-}
-
-.devops-cards {
- display: flex;
- justify-content: center;
- flex-wrap: wrap;
-}
-
-.devops-card-wrapper {
- display: flex;
- flex-direction: column;
- align-items: stretch;
- text-align: center;
- width: 50%;
- border-color: var(--border-color, $border-color);
- margin: 0 0 32px;
- padding: $space-between-cards / 2;
- position: relative;
-
- @include media-breakpoint-up(xs) {
- width: percentage(1 / 4);
- }
-
- @include media-breakpoint-up(sm) {
- width: percentage(1 / 5);
- }
-
- @include media-breakpoint-up(md) {
- width: percentage(1 / 6);
- }
-
- @include media-breakpoint-up(lg) {
- width: percentage(1 / 10);
- }
-}
-
-.devops-card {
- border: solid 1px var(--border-color, $border-color);
- border-radius: 3px;
- border-top-width: 3px;
- display: flex;
- flex-direction: column;
- flex-grow: 1;
-}
-
-.devops-card-low {
- border-top-color: var(--red-400, $red-400);
-
- .board-card-score-big {
- background-color: var(--red-50, $red-50);
- }
-}
-
-.devops-card-average {
- border-top-color: var(--orange-200, $orange-200);
-
- .board-card-score-big {
- background-color: var(--orange-50, $orange-50);
- }
-}
-
-.devops-card-high {
- border-top-color: var(--green-400, $green-400);
-
- .board-card-score-big {
- background-color: var(--green-50, $green-50);
- }
-}
-
-.devops-card-title {
- margin: $gl-padding auto auto;
- max-width: 100px;
-
- h3 {
- font-size: 14px;
- margin: 0 0 2px;
- }
-
- .light-text {
- font-size: 13px;
- line-height: 1.25;
- color: var(--gl-text-color-secondary, $gl-text-color-secondary);
- }
-}
-
-.board-card-scores {
- display: flex;
- justify-content: space-around;
- align-items: center;
- margin: $gl-padding $gl-btn-padding;
- line-height: 1;
-}
-
-.board-card-score {
- color: var(--gl-text-color-secondary, $gl-text-color-secondary);
-
- .board-card-score-name {
- font-size: 13px;
- margin-top: 4px;
- }
-}
-
-.board-card-score-value {
- font-size: 16px;
- color: var(--gl-text-color, $gl-text-color);
- font-weight: $gl-font-weight-normal;
-}
-
-.board-card-score-big {
- border-top: 2px solid var(--border-color, $border-color);
- border-bottom: 1px solid var(--border-color, $border-color);
- font-size: 22px;
- padding: 10px 0;
- font-weight: $gl-font-weight-normal;
-}
-
-.board-card-buttons {
- display: flex;
-
- > * {
- font-size: 16px;
- color: var(--gl-text-color-secondary, $gl-text-color-secondary);
- padding: 10px;
- flex-grow: 1;
-
- &:hover {
- background-color: var(--border-color, $border-color);
- color: var(--border-color, $border-color);
- }
-
- + * {
- border-left: solid 1px var(--border-color, $border-color);
- }
- }
-}
-
-.devops-steps {
- margin-top: $gl-padding;
- height: 1px;
- min-width: 100%;
- justify-content: space-around;
- position: relative;
- background: var(--border-color, $border-color);
-}
-
-.devops-step {
- $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%;
- @each $pos in $step-positions {
- $i: index($step-positions, $pos);
-
- &:nth-child(#{$i}) {
- left: $pos;
- }
- }
-
- position: absolute;
- transform-origin: 75% 50%;
- padding: 8px;
- height: 50px;
- width: 50px;
- border-radius: 3px;
- display: flex;
- flex-direction: column;
- align-items: center;
- border: solid 1px var(--border-color, $border-color);
- background: var(--white, $white);
- transform: translate(-50%, -50%);
- color: var(--gl-text-color-secondary, $gl-text-color-secondary);
- fill: var(--gl-text-color-secondary, $gl-text-color-secondary);
- box-shadow: 0 2px 4px var(--dropdown-shadow-color, $dropdown-shadow-color);
-
- &:hover {
- padding: 8px 10px;
- fill: currentColor;
- z-index: 100;
- height: auto;
- width: auto;
-
- .devops-step-title {
- max-height: 2em;
- opacity: 1;
- transition: opacity 0.2s;
- }
-
- svg {
- transform: scale(1.5);
- margin: $gl-btn-padding;
- }
- }
-
- svg {
- transition: transform 0.1s;
- width: 30px;
- height: 30px;
- min-height: 30px;
- min-width: 30px;
- }
-}
-
-.devops-step-title {
- max-height: 0;
- opacity: 0;
- text-transform: uppercase;
- margin: $gl-vert-padding 0 0;
- text-align: center;
- font-size: 12px;
-}
-
-.devops-high-score {
- color: var(--green-400, $green-400);
-}
-
-.devops-average-score {
- color: var(--orange-500, $orange-500);
-}
-
-.devops-low-score {
- color: var(--red-400, $red-400);
-}
diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss
index db4be3f18e8..4beb5edbe7b 100644
--- a/app/assets/stylesheets/page_bundles/jira_connect.scss
+++ b/app/assets/stylesheets/page_bundles/jira_connect.scss
@@ -45,3 +45,9 @@ $header-height: 40px;
margin-left: auto;
margin-right: auto;
}
+
+// needed for external_link
+svg.s16 {
+ width: 16px;
+ height: 16px;
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_conflicts.scss b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
index d7473d2c942..9fe56fd337f 100644
--- a/app/assets/stylesheets/page_bundles/merge_conflicts.scss
+++ b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
@@ -173,22 +173,5 @@
text-align: right;
padding: $gl-padding-top $gl-padding;
color: var(--gl-text-color, $gl-text-color);
-
- .discard-actions {
- display: inline-block;
- margin-left: 10px;
- }
- }
-
- .resolve-conflicts-form {
- h4 {
- margin-top: 0;
- }
-
- .resolve-info {
- @media(max-width: map-get($grid-breakpoints, lg)-1) {
- margin-bottom: $gl-padding;
- }
- }
}
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 9fdc30359f8..5e9dd883635 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -7,6 +7,10 @@
.diff-files-holder {
flex: 1;
min-width: 0;
+
+ .vue-recycle-scroller__item-wrapper {
+ overflow: visible;
+ }
}
.with-system-header {
diff --git a/app/assets/stylesheets/page_bundles/milestone.scss b/app/assets/stylesheets/page_bundles/milestone.scss
index 858e13fc558..03dd12ec230 100644
--- a/app/assets/stylesheets/page_bundles/milestone.scss
+++ b/app/assets/stylesheets/page_bundles/milestone.scss
@@ -38,18 +38,6 @@ $status-box-line-height: 26px;
color: var(--blue-600, $blue-600);
}
}
-
- .status-box {
- font-size: $tooltip-font-size;
- margin-top: 0;
- margin-right: $gl-padding-4;
- line-height: $status-box-line-height;
-
- @include media-breakpoint-down(xs) {
- line-height: unset;
- padding: $gl-padding-4 $gl-input-padding;
- }
- }
}
}
@@ -199,11 +187,6 @@ $status-box-line-height: 26px;
align-items: center;
flex-wrap: wrap;
- .status-box {
- margin-top: 0;
- order: 1;
- }
-
.milestone-buttons {
margin-left: auto;
order: 2;
diff --git a/app/assets/stylesheets/page_bundles/new_namespace.scss b/app/assets/stylesheets/page_bundles/new_namespace.scss
new file mode 100644
index 00000000000..60aa3c8f29f
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/new_namespace.scss
@@ -0,0 +1,28 @@
+@import 'mixins_and_variables_and_functions';
+
+$new-namespace-panel-illustration-width: 215px;
+$new-namespace-panel-height: 240px;
+
+.new-namespace-panel-illustration {
+ width: $new-namespace-panel-illustration-width;
+}
+
+.new-namespace-panel-wrapper {
+ @include media-breakpoint-down(md) {
+ width: 100%;
+ }
+ width: 50%;
+}
+
+.new-namespace-panel {
+ &:hover {
+ background-color: $gray-10;
+ }
+
+ color: $purple-700;
+ min-height: $new-namespace-panel-height;
+ text-align: center;
+ @include media-breakpoint-up(lg) {
+ text-align: left;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
index 5eaf91c3017..ddc638197ca 100644
--- a/app/assets/stylesheets/page_bundles/oncall_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -95,7 +95,7 @@ $column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradi
@include gl-font-weight-normal;
&.label-dark {
- @include gl-text-gray-900;
+ color: var(--gray-900, $gray-900);
}
&.label-bold {
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index 2f3cf889549..c9171eb4fc7 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -306,11 +306,6 @@
}
}
- // ensure .build-content has hover style when action-icon is hovered
- .ci-job-dropdown-container:hover .build-content {
- @extend .build-content:hover;
- }
-
.ci-status-icon svg {
height: 24px;
width: 24px;
@@ -330,6 +325,7 @@
@include build-content();
}
+ .ci-job-dropdown-container:hover .build-content,
a.build-content:hover,
button.build-content:hover {
background-color: var(--gray-100, $gray-100);
@@ -409,7 +405,7 @@
fill: var(--gray-500, $gray-500);
}
- .spinner {
+ .gl-spinner {
top: 2px;
}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
index 6ef7f912ea9..ace91d197b6 100644
--- a/app/assets/stylesheets/page_bundles/pipelines.scss
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -182,11 +182,6 @@ button.gl-button.btn-link.mini-pipeline-graph-dropdown-toggle {
border-bottom-color: $border-color;
}
- &::after {
- margin-top: 1px;
- border-bottom-color: $white;
- }
-
/**
* Center dropdown menu in mini graph
*/
diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss
index 3e20ca9c62f..e7813e3b56e 100644
--- a/app/assets/stylesheets/page_bundles/todos.scss
+++ b/app/assets/stylesheets/page_bundles/todos.scss
@@ -8,8 +8,6 @@
.todos-list > .todo {
// workaround because we cannot use border-collapse
border-top: 1px solid transparent;
- display: flex;
- flex-direction: row;
&:hover {
background-color: var(--blue-50, $blue-50);
@@ -26,25 +24,6 @@
}
}
- .todo-avatar,
- .todo-actions {
- @include transition(opacity);
- flex: 0 0 auto;
- }
-
- .todo-actions {
- display: flex;
- justify-content: center;
- flex-direction: column;
- margin-left: 10px;
- min-width: 55px;
- }
-
- .todo-item {
- flex: 0 1 100%;
- min-width: 0;
- }
-
&.todo-pending.done-reversible {
&:hover {
border-color: var(--border-color, $border-color);
@@ -71,58 +50,22 @@
.todo-item {
@include transition(opacity);
- .todo-title {
- > .title-item {
- &:first-child {
- margin-left: 0;
- }
-
- &:last-child {
- margin-right: 0;
- }
- }
-
- .todo-label {
- flex: 0 1 auto;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-
.status-box {
- margin: 0;
- float: none;
- display: inline-block;
- font-weight: $gl-font-weight-normal;
- padding: 0 5px;
line-height: inherit;
- font-size: 14px;
}
.todo-label,
.todo-project {
a {
- font-weight: $gl-font-weight-normal;
color: var(--blue-600, $blue-600);
}
}
.todo-body {
- .badge.badge-pill,
p {
color: var(--gl-text-color, $gl-text-color);
}
- .md {
- color: $gl-grayish-blue;
- font-size: $gl-font-size;
- }
-
- code {
- white-space: pre-wrap;
- }
-
pre {
border: 0;
background: var(--gray-50, $gray-50);
@@ -139,120 +82,13 @@
float: none;
}
- p:last-child {
- margin-bottom: 0;
- }
- }
-
- .gl-label-scoped {
- --label-inset-border: inset 0 0 0 1px currentColor;
- }
-}
-
-@include media-breakpoint-down(lg) {
- .todos-filters {
- .filter-categories {
- width: 75%;
-
- .filter-item {
- margin-bottom: 10px;
- }
+ .gl-label-scoped {
+ --label-inset-border: inset 0 0 0 1px currentColor;
}
- }
-}
-@include media-breakpoint-down(sm) {
- .container-fluid .todos-list-container {
- margin: 0 (-$gl-padding);
- }
-
- .todo {
- .avatar {
- display: none;
- }
- }
-
- .todo-item {
- .todo-title {
- margin-bottom: 10px;
-
- .todo-label {
- white-space: normal;
- }
- }
-
- .todo-body {
- margin: 0;
+ @include media-breakpoint-down(sm) {
border-left: 2px solid var(--border-color, $border-color);
padding-left: 10px;
}
}
-
- .todos-filters {
- .filter-categories {
- width: auto;
- }
-
- .dropdown-menu-toggle {
- width: 100%;
- }
-
- .dropdown-menu-toggle-sort {
- width: auto;
- }
- }
-}
-
-.todos-empty {
- display: flex;
- flex-direction: column;
- max-width: 900px;
- margin-left: auto;
- margin-right: auto;
-
- @include media-breakpoint-up(sm) {
- flex-direction: row;
- padding-top: 80px;
- }
-}
-
-.todos-empty-content {
- align-self: center;
- max-width: 480px;
-}
-
-.todos-empty-hero {
- width: 200px;
- margin-left: auto;
- margin-right: auto;
-
- @include media-breakpoint-up(sm) {
- width: 300px;
- margin-right: 0;
- order: 2;
- }
-}
-
-.todos-all-done {
- padding-top: 20px;
-
- @include media-breakpoint-up(sm) {
- padding-top: 50px;
- }
-
- > svg {
- display: block;
- max-width: 300px;
- margin: 0 auto 20px;
- }
-
- p {
- max-width: 470px;
- margin-left: auto;
- margin-right: auto;
- }
-
- a {
- font-weight: $gl-font-weight-bold;
- }
}
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index 14cff5b038a..c177d0b74a2 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -37,10 +37,6 @@
.file-title {
@include gl-font-monospace;
- line-height: 35px;
- padding-top: 7px;
- padding-bottom: 7px;
- display: flex;
}
.editor-ref {
@@ -69,19 +65,15 @@
}
.file-buttons {
- display: flex;
flex: 1;
- justify-content: flex-end;
}
.soft-wrap-toggle {
- display: inline-block;
- vertical-align: top;
font-family: $regular-font;
- margin: 0 $btn-side-margin;
+ margin-left: $gl-padding-8;
.soft-wrap {
- display: block;
+ display: inline-flex;
}
.no-wrap {
@@ -94,7 +86,7 @@
}
.no-wrap {
- display: block;
+ display: inline-flex;
}
}
}
@@ -111,17 +103,21 @@
.new-file-path {
max-width: none;
width: 100%;
- margin-bottom: 3px;
+ margin-top: $gl-padding-8;
}
.file-buttons {
- display: block;
+ display: flex;
+ flex-direction: column;
width: 100%;
- margin-bottom: 10px;
+
+ .md-header-toolbar {
+ margin: $gl-padding 0;
+ }
.soft-wrap-toggle {
width: 100%;
- margin: 3px 0;
+ margin-left: 0;
}
@media(max-width: map-get($grid-breakpoints, md)-1) {
@@ -168,7 +164,6 @@
.license-selector,
.gitignore-selector,
.gitlab-ci-yml-selector,
- .gitlab-ci-syntax-yml-selector,
.dockerfile-selector,
.template-type-selector,
.metrics-dashboard-selector {
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index c05216ac6e6..9182292ffd3 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -1,30 +1,30 @@
-.shortcut-mappings {
- font-size: 12px;
- color: $gray-700;
-
- tbody:first-child tr:first-child {
- padding-top: 0;
+.shortcut-help {
+ &-body {
+ height: 80vh;
+ overflow-y: scroll;
}
- th {
- padding-top: 15px;
- line-height: 1.5;
- color: $help-shortcut-header-color;
- text-align: left;
+ &-container {
+ column-count: 1;
+ @include media-breakpoint-up(md) {
+ column-count: 2;
+ }
+ column-gap: 1rem;
}
- td {
- padding-top: 3px;
- padding-bottom: 3px;
- vertical-align: top;
- line-height: 20px;
- }
+ &-mapping {
+ overflow: hidden;
+ break-inside: avoid;
+
+ &-title {
+ margin-left: 40%;
+ }
- .shortcut {
- padding-right: 10px;
- color: $gray-300;
- text-align: right;
- white-space: nowrap;
+ kbd {
+ margin: 0.1rem 0;
+ line-height: unset;
+ font-size: unset;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index b9f5a427a24..0437fa19752 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -217,7 +217,6 @@
.title {
color: $gl-text-color;
- margin-bottom: $gl-padding-4;
line-height: $gl-line-height-20;
.avatar {
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 59768f4cda8..c025d8569a7 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -369,10 +369,6 @@ table {
.btn {
float: none;
width: 100%;
-
- &:not(:last-child) {
- margin-bottom: 10px;
- }
}
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 801dd44be8e..01739c7eb3e 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -372,7 +372,7 @@ $system-note-svg-size: 16px;
top: $mr-tabs-height + $header-height;
.with-performance-bar & {
- top: 126px;
+ top: 123px;
}
}
@@ -672,6 +672,7 @@ $system-note-svg-size: 16px;
align-items: center;
margin-left: 10px;
color: $gray-400;
+ margin-top: -4px;
@include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
float: none;
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 16f96ebadc9..dfd64d0773c 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -237,11 +237,6 @@
line-height: 34px;
margin: 0;
- > li + li::before {
- padding: 0 3px;
- color: $gray-300;
- }
-
a {
color: $gl-text-color;
}
@@ -1032,11 +1027,6 @@ pre.light-well {
}
}
-.issuable-footer {
- padding-top: $gl-padding;
- padding-bottom: 37px;
-}
-
.project-ci-linter {
.ci-editor {
height: 400px;
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
index 346b3f61caa..7d74070b4f2 100644
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ b/app/assets/stylesheets/pages/settings_ci_cd.scss
@@ -11,10 +11,6 @@
.trigger-actions {
white-space: nowrap;
-
- .btn {
- margin-left: 10px;
- }
}
.auto-devops-card {
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 11b4bde74a6..9d98fe5c739 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -70,6 +70,7 @@ $indigo-700: #a6a6de;
$indigo-800: #d1d1f0;
$indigo-900: #ebebfa;
$indigo-950: #f7f7ff;
+$indigo-900-alpha-008: rgba($indigo-900, 0.08);
$gray-lightest: #222;
$gray-light: $gray-50;
@@ -160,6 +161,7 @@ body.gl-dark {
--indigo-800: #{$indigo-800};
--indigo-900: #{$indigo-900};
--indigo-950: #{$indigo-950};
+ --indigo-900-alpha-008: #{$indigo-900-alpha-008};
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
@@ -232,9 +234,7 @@ $well-inner-border: $gray-200;
}
// white-ish text for light labels
-// and for scoped label value (the right section)
-.gl-label-text-light.gl-label-text-light,
-.gl-label-text-dark + .gl-label-text-dark {
+.gl-label-text-light.gl-label-text-light {
color: $gray-900;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 024162eba3e..c22a1ae1187 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -172,3 +172,38 @@
width: 50%;
}
}
+
+.gl-sm-mr-3 {
+ @include media-breakpoint-up(sm) {
+ margin-right: $gl-spacing-scale-3;
+ }
+}
+
+.gl-mb-n3 {
+ margin-bottom: -$gl-spacing-scale-3;
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1408
+$gl-line-height-42: px-to-rem(42px);
+
+.gl-line-height-42 {
+ line-height: $gl-line-height-42;
+}
+
+.gl-w-grid-size-30 {
+ width: $grid-size * 30;
+}
+
+.gl-w-grid-size-40 {
+ width: $grid-size * 40;
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
+.gl-max-w-none\! {
+ max-width: none !important;
+}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2209
+.gl-max-h-none\! {
+ max-height: none !important;
+}