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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-06-20 14:10:13 +0300
commit0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch)
tree7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /app/assets/javascripts
parent72123183a20411a36d607d70b12d57c484394c8e (diff)
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue168
-rw-r--r--app/assets/javascripts/access_tokens/components/constants.js61
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue10
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue130
-rw-r--r--app/assets/javascripts/access_tokens/index.js71
-rw-r--r--app/assets/javascripts/activities.js4
-rw-r--r--app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue249
-rw-r--r--app/assets/javascripts/admin/application_settings/inactive_project_deletion/index.js36
-rw-r--r--app/assets/javascripts/admin/users/components/user_actions.vue2
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue14
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue18
-rw-r--r--app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue8
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue6
-rw-r--r--app/assets/javascripts/api.js18
-rw-r--r--app/assets/javascripts/api/projects_api.js11
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue4
-rw-r--r--app/assets/javascripts/awards_handler.js40
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue2
-rw-r--r--app/assets/javascripts/batch_comments/components/drafts_count.vue9
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_dropdown.vue5
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue7
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue115
-rw-r--r--app/assets/javascripts/batch_comments/services/drafts_service.js4
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js20
-rw-r--r--app/assets/javascripts/behaviors/components/sandboxed_mermaid.vue77
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js20
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_kroki.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js9
-rw-r--r--app/assets/javascripts/blob/blob_line_permalink_updater.js5
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue6
-rw-r--r--app/assets/javascripts/blob/components/blob_header_default_actions.vue17
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue4
-rw-r--r--app/assets/javascripts/blob/viewer/index.js17
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue11
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue29
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue19
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue67
-rw-r--r--app/assets/javascripts/boards/graphql/board_create.mutation.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/board_update.mutation.graphql5
-rw-r--r--app/assets/javascripts/boards/index.js1
-rw-r--r--app/assets/javascripts/boards/stores/actions.js31
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js15
-rw-r--r--app/assets/javascripts/boards/stores/state.js2
-rw-r--r--app/assets/javascripts/breadcrumb.js2
-rw-r--r--app/assets/javascripts/ci_secure_files/components/secure_files_list.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue3
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue27
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue81
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue426
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue32
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue199
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js63
-rw-r--r--app/assets/javascripts/clusters/agents/components/create_token_button.vue7
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue51
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js10
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_token.vue11
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue10
-rw-r--r--app/assets/javascripts/clusters_list/components/install_agent_modal.vue7
-rw-r--r--app/assets/javascripts/code_navigation/utils/index.js4
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue21
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue157
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue34
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue79
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue28
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue7
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js22
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_definition.js27
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnote_reference.js15
-rw-r--r--app/assets/javascripts/content_editor/extensions/footnotes_section.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js18
-rw-r--r--app/assets/javascripts/content_editor/services/asset_resolver.js15
-rw-r--r--app/assets/javascripts/content_editor/services/code_block_language_loader.js8
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js329
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js39
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js180
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js222
-rw-r--r--app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue4
-rw-r--r--app/assets/javascripts/custom_metrics/index.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/path_navigation.vue11
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue30
-rw-r--r--app/assets/javascripts/cycle_analytics/constants.js1
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/render.js8
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_presentation.vue13
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue180
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue2
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/design_navigation.vue4
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue12
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue165
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue12
-rw-r--r--app/assets/javascripts/diff.js3
-rw-r--r--app/assets/javascripts/diffs/components/app.vue16
-rw-r--r--app/assets/javascripts/diffs/components/collapsed_files_warning.vue2
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue9
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue47
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue7
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue13
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue43
-rw-r--r--app/assets/javascripts/diffs/components/merge_conflict_warning.vue4
-rw-r--r--app/assets/javascripts/diffs/store/utils.js8
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js12
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js27
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_webide_ext.js28
-rw-r--r--app/assets/javascripts/editor/schema/ci.json198
-rw-r--r--app/assets/javascripts/editor/source_editor.js5
-rw-r--r--app/assets/javascripts/editor/source_editor_extension.js2
-rw-r--r--app/assets/javascripts/emoji/constants.js2
-rw-r--r--app/assets/javascripts/emoji/index.js18
-rw-r--r--app/assets/javascripts/emoji/utils.js8
-rw-r--r--app/assets/javascripts/environments/components/container.vue2
-rw-r--r--app/assets/javascripts/environments/components/deploy_board_wrapper.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_folder.vue4
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue33
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue4
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue2
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue7
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_actions.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/empty_state.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue13
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue20
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue12
-rw-r--r--app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue2
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js2
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_down.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js20
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue6
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js2
-rw-r--r--app/assets/javascripts/gl_form.js5
-rw-r--r--app/assets/javascripts/google_cloud/components/gcp_regions_list.vue2
-rw-r--r--app/assets/javascripts/google_cloud/components/revoke_oauth.vue2
-rw-r--r--app/assets/javascripts/google_cloud/components/service_accounts_list.vue2
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js8
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql3
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json4
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue7
-rw-r--r--app/assets/javascripts/group_settings/constants.js3
-rw-r--r--app/assets/javascripts/group_settings/stale_runner_cleanup.js3
-rw-r--r--app/assets/javascripts/groups/components/app.vue42
-rw-r--r--app/assets/javascripts/groups/components/empty_state.vue91
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue4
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue279
-rw-r--r--app/assets/javascripts/groups/components/groups.vue7
-rw-r--r--app/assets/javascripts/groups/components/item_caret.vue4
-rw-r--r--app/assets/javascripts/groups/components/item_type_icon.vue4
-rw-r--r--app/assets/javascripts/groups/create_edit_form.js29
-rw-r--r--app/assets/javascripts/groups/index.js25
-rw-r--r--app/assets/javascripts/groups/settings/api/access_dropdown_api.js16
-rw-r--r--app/assets/javascripts/groups/settings/components/access_dropdown.vue194
-rw-r--r--app/assets/javascripts/groups/settings/constants.js3
-rw-r--r--app/assets/javascripts/groups/settings/init_access_dropdown.js36
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue27
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue28
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue48
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue55
-rw-r--r--app/assets/javascripts/ide/components/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue6
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue7
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue6
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail/description.vue2
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue74
-rw-r--r--app/assets/javascripts/ide/components/terminal/empty_state.vue2
-rw-r--r--app/assets/javascripts/ide/components/terminal/session.vue2
-rw-r--r--app/assets/javascripts/ide/lib/editor_options.js1
-rw-r--r--app/assets/javascripts/ide/utils.js3
-rw-r--r--app/assets/javascripts/image_diff/helpers/dom_helper.js2
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue54
-rw-r--r--app/assets/javascripts/import_entities/import_groups/constants.js3
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue2
-rw-r--r--app/assets/javascripts/incidents/components/incidents_list.vue4
-rw-r--r--app/assets/javascripts/incidents/list.js1
-rw-r--r--app/assets/javascripts/incidents_settings/components/pagerduty_form.vue24
-rw-r--r--app/assets/javascripts/integrations/constants.js36
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue51
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/configuration.vue38
-rw-r--r--app/assets/javascripts/integrations/edit/components/sections/trigger.vue26
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_field.vue46
-rw-r--r--app/assets/javascripts/integrations/edit/index.js4
-rw-r--r--app/assets/javascripts/integrations/overrides/components/integration_overrides.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue13
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue13
-rw-r--r--app/assets/javascripts/invite_members/components/invite_modal_base.vue16
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue33
-rw-r--r--app/assets/javascripts/invite_members/constants.js13
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue34
-rw-r--r--app/assets/javascripts/issuable/components/csv_import_modal.vue4
-rw-r--r--app/assets/javascripts/issuable/components/issuable_header_warnings.vue2
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue5
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue5
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js3
-rw-r--r--app/assets/javascripts/issuable/popover/components/issue_popover.vue83
-rw-r--r--app/assets/javascripts/issuable/popover/components/mr_popover.vue (renamed from app/assets/javascripts/mr_popover/components/mr_popover.vue)35
-rw-r--r--app/assets/javascripts/issuable/popover/constants.js (renamed from app/assets/javascripts/mr_popover/constants.js)0
-rw-r--r--app/assets/javascripts/issuable/popover/index.js85
-rw-r--r--app/assets/javascripts/issuable/popover/queries/issue.query.graphql11
-rw-r--r--app/assets/javascripts/issuable/popover/queries/merge_request.query.graphql (renamed from app/assets/javascripts/mr_popover/queries/merge_request.query.graphql)4
-rw-r--r--app/assets/javascripts/issues/create_merge_request_dropdown.js49
-rw-r--r--app/assets/javascripts/issues/index.js4
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue99
-rw-r--r--app/assets/javascripts/issues/list/constants.js6
-rw-r--r--app/assets/javascripts/issues/list/index.js10
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql136
-rw-r--r--app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql94
-rw-r--r--app/assets/javascripts/issues/list/utils.js11
-rw-r--r--app/assets/javascripts/issues/new/components/title_suggestions.vue28
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue80
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql21
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue21
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue73
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue71
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue70
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js18
-rw-r--r--app/assets/javascripts/issues/show/components/title.vue2
-rw-r--r--app/assets/javascripts/issues/show/index.js5
-rw-r--r--app/assets/javascripts/jira_connect/branches/pages/index.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue86
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue2
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue25
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue2
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue2
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue4
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue3
-rw-r--r--app/assets/javascripts/jobs/components/log/line_header.vue2
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue2
-rw-r--r--app/assets/javascripts/jobs/components/stuck_block.vue23
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue2
-rw-r--r--app/assets/javascripts/labels/components/promote_label_modal.vue2
-rw-r--r--app/assets/javascripts/lazy_loader.js9
-rw-r--r--app/assets/javascripts/lib/gfm/index.js27
-rw-r--r--app/assets/javascripts/lib/graphql.js13
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js2
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js11
-rw-r--r--app/assets/javascripts/lib/utils/forms.js4
-rw-r--r--app/assets/javascripts/lib/utils/rails_ujs.js4
-rw-r--r--app/assets/javascripts/lib/utils/table_utility.js10
-rw-r--r--app/assets/javascripts/lib/utils/users_cache.js7
-rw-r--r--app/assets/javascripts/logo.js4
-rw-r--r--app/assets/javascripts/logs/utils.js21
-rw-r--r--app/assets/javascripts/main.js7
-rw-r--r--app/assets/javascripts/members/components/members_tabs.vue4
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue33
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue2
-rw-r--r--app/assets/javascripts/members/constants.js12
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue4
-rw-r--r--app/assets/javascripts/merge_request.js10
-rw-r--r--app/assets/javascripts/merge_request_tabs.js3
-rw-r--r--app/assets/javascripts/milestones/components/promote_milestone_modal.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/create_dashboard_modal.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue14
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue27
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue2
-rw-r--r--app/assets/javascripts/monitoring/services/alerts_service.js43
-rw-r--r--app/assets/javascripts/mr_popover/index.js67
-rw-r--r--app/assets/javascripts/nav/components/responsive_header.vue2
-rw-r--r--app/assets/javascripts/notes/components/comment_field_layout.vue4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue34
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue9
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue11
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue12
-rw-r--r--app/assets/javascripts/notes/components/email_participants_warning.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue14
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue114
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue9
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue5
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue6
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue2
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/i18n.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js14
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js17
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue3
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue25
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue75
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata_loader.vue30
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue14
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history_loader.vue24
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql32
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql39
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql24
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue27
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue124
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue (renamed from app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue)0
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue119
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_downloader.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js6
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/repository/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js10
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js6
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js22
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js25
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/settings/access_tokens/index.js8
-rw-r--r--app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue2
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_error_details.vue2
-rw-r--r--app/assets/javascripts/pages/import/history/components/import_history_app.vue2
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js10
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue11
-rw-r--r--app/assets/javascripts/pages/projects/incidents/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js32
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js9
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js7
-rw-r--r--app/assets/javascripts/pages/projects/settings/access_tokens/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/settings/branch_rules/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/integrations/edit/index.js (renamed from app/assets/javascripts/pages/projects/services/edit/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/settings/integrations/index/index.js (renamed from app/assets/javascripts/pages/projects/settings/integrations/show/index.js)0
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue131
-rw-r--r--app/assets/javascripts/pages/projects/shared/save_project_loader.js16
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/static_site_editor/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/tags/index/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/tags/remove_tag.js16
-rw-r--r--app/assets/javascripts/pages/projects/tags/show/index.js9
-rw-r--r--app/assets/javascripts/pages/shared/nav/sidebar_tracking.js10
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/add_request.vue34
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue4
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue2
-rw-r--r--app/assets/javascripts/performance_bar/index.js4
-rw-r--r--app/assets/javascripts/performance_bar/performance_bar_log.js2
-rw-r--r--app/assets/javascripts/performance_bar/services/performance_bar_service.js15
-rw-r--r--app/assets/javascripts/performance_bar/stores/performance_bar_store.js10
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue6
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue11
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue43
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue13
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue8
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue23
-rw-r--r--app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue7
-rw-r--r--app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue65
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js21
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue7
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue (renamed from app/assets/javascripts/pipeline_wizard/components/input.vue)0
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/step.vue2
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/.gitkeep0
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/jobs/jobs_app.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue102
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue31
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue43
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue23
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue58
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue2
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql12
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js28
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js31
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js40
-rw-r--r--app/assets/javascripts/pipelines/pipeline_test_details.js11
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue5
-rw-r--r--app/assets/javascripts/project_select.js204
-rw-r--r--app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue23
-rw-r--r--app/assets/javascripts/projects/clusters_deprecation_alert/index.js21
-rw-r--r--app/assets/javascripts/projects/commit/components/form_modal.vue2
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue2
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue4
-rw-r--r--app/assets/javascripts/projects/compare/components/revision_card.vue10
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js8
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue12
-rw-r--r--app/assets/javascripts/projects/project_import_gitlab_project.js15
-rw-r--r--app/assets/javascripts/projects/project_new.js154
-rw-r--r--app/assets/javascripts/projects/project_visibility.js6
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue110
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue38
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js26
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql8
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue16
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js13
-rw-r--r--app/assets/javascripts/related_issues/constants.js11
-rw-r--r--app/assets/javascripts/related_issues/index.js3
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue2
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql1
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql1
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue2
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue23
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js1
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/sketch_viewer.vue31
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue2
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue14
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue8
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/right_sidebar.js2
-rw-r--r--app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue30
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue18
-rw-r--r--app/assets/javascripts/runner/admin_runners/index.js4
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_status_cell.vue7
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_dropdown.vue26
-rw-r--r--app/assets/javascripts/runner/components/registration/registration_token.vue18
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue27
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue8
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_list_empty_state.vue75
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue8
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue12
-rw-r--r--app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners.query.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql21
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql20
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner.query.graphql40
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql5
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql39
-rw-r--r--app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue114
-rw-r--r--app/assets/javascripts/runner/group_runner_show/index.js36
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue17
-rw-r--r--app/assets/javascripts/runner/group_runners/index.js4
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js14
-rw-r--r--app/assets/javascripts/search/store/actions.js18
-rw-r--r--app/assets/javascripts/search_autocomplete.js2
-rw-r--r--app/assets/javascripts/search_settings/components/search_settings.vue2
-rw-r--r--app/assets/javascripts/security_configuration/components/app.vue28
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js4
-rw-r--r--app/assets/javascripts/security_configuration/graphql/current_license.query.graphql6
-rw-r--r--app/assets/javascripts/security_configuration/index.js1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue13
-rw-r--r--app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js20
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql5
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue92
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue1
-rw-r--r--app/assets/javascripts/sidebar/graphql.js8
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js7
-rw-r--r--app/assets/javascripts/sidebar/queries/escalation_status.fragment.graphql4
-rw-r--r--app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql4
-rw-r--r--app/assets/javascripts/sidebar/utils.js1
-rw-r--r--app/assets/javascripts/single_file_diff.js2
-rw-r--r--app/assets/javascripts/static_site_editor/components/app.vue13
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue190
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_drawer.vue27
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_header.vue23
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue130
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue126
-rw-r--r--app/assets/javascripts/static_site_editor/components/front_matter_controls.vue57
-rw-r--r--app/assets/javascripts/static_site_editor/components/invalid_content_message.vue29
-rw-r--r--app/assets/javascripts/static_site_editor/components/publish_toolbar.vue57
-rw-r--r--app/assets/javascripts/static_site_editor/components/skeleton_loader.vue19
-rw-r--r--app/assets/javascripts/static_site_editor/components/submit_changes_error.vue24
-rw-r--r--app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue27
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js35
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js47
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql5
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql7
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql17
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql10
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/file.js11
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js25
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js47
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql58
-rw-r--r--app/assets/javascripts/static_site_editor/image_repository.js25
-rw-r--r--app/assets/javascripts/static_site_editor/index.js56
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue169
-rw-r--r--app/assets/javascripts/static_site_editor/pages/success.vue106
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/constants.js57
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue134
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue56
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue93
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue150
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js42
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js109
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js116
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js63
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js7
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js9
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js11
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js6
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js23
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js40
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js40
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js6
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js7
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js38
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js22
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue31
-rw-r--r--app/assets/javascripts/static_site_editor/router/constants.js2
-rw-r--r--app/assets/javascripts/static_site_editor/router/index.js15
-rw-r--r--app/assets/javascripts/static_site_editor/router/routes.js21
-rw-r--r--app/assets/javascripts/static_site_editor/services/formatter.js56
-rw-r--r--app/assets/javascripts/static_site_editor/services/front_matterify.js75
-rw-r--r--app/assets/javascripts/static_site_editor/services/generate_branch_name.js8
-rw-r--r--app/assets/javascripts/static_site_editor/services/image_service.js8
-rw-r--r--app/assets/javascripts/static_site_editor/services/load_source_content.js15
-rw-r--r--app/assets/javascripts/static_site_editor/services/parse_source_file.js46
-rw-r--r--app/assets/javascripts/static_site_editor/services/renderers/render_image.js89
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js145
-rw-r--r--app/assets/javascripts/static_site_editor/services/templater.js89
-rw-r--r--app/assets/javascripts/tags/components/delete_tag_modal.vue192
-rw-r--r--app/assets/javascripts/tags/event_hub.js3
-rw-r--r--app/assets/javascripts/tags/init_delete_tag_modal.js14
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue58
-rw-r--r--app/assets/javascripts/terraform/components/states_table_actions.vue10
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue7
-rw-r--r--app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql1
-rw-r--r--app/assets/javascripts/terraform/index.js4
-rw-r--r--app/assets/javascripts/token_access/components/token_access.vue2
-rw-r--r--app/assets/javascripts/token_access/components/token_projects_table.vue6
-rw-r--r--app/assets/javascripts/user_popovers.js139
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue97
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/humanized_text.js23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue84
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js17
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js207
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue100
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/issues.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/index.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js22
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue36
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js14
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue10
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue4
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js9
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue214
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js30
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue109
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue53
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_header.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_color.query.graphql9
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_update_color.mutation.graphql9
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/utils.js15
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/registry_search.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue123
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue125
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue2
-rw-r--r--app/assets/javascripts/vue_shared/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue59
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue17
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue5
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue12
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue28
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue6
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue2
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js3
-rw-r--r--app/assets/javascripts/whats_new/utils/notification.js2
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue18
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue9
-rw-r--r--app/assets/javascripts/work_items/components/update_work_item.js23
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue111
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue234
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue53
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue17
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js37
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue165
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue28
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state.vue46
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue50
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue26
-rw-r--r--app/assets/javascripts/work_items/constants.js18
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql9
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js84
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql36
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql8
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql7
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql16
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql28
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue4
701 files changed, 10993 insertions, 6847 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
new file mode 100644
index 00000000000..944a2ef7f64
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -0,0 +1,168 @@
+<script>
+import { GlButton, GlIcon, GlLink, GlPagination, GlTable, GlTooltipDirective } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { __, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
+import { EVENT_SUCCESS, FIELDS, FORM_SELECTOR, INITIAL_PAGE, PAGE_SIZE } from './constants';
+
+export default {
+ EVENT_SUCCESS,
+ FORM_SELECTOR,
+ PAGE_SIZE,
+ name: 'AccessTokenTableApp',
+ components: {
+ DomElementListener,
+ GlButton,
+ GlIcon,
+ GlLink,
+ GlPagination,
+ GlTable,
+ TimeAgoTooltip,
+ UserDate,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ lastUsedHelpLink: helpPagePath('/user/profile/personal_access_tokens.md', {
+ anchor: 'view-the-last-time-a-token-was-used',
+ }),
+ i18n: {
+ emptyField: __('Never'),
+ expired: __('Expired'),
+ header: __('Active %{accessTokenTypePlural} (%{totalAccessTokens})'),
+ modalMessage: __(
+ 'Are you sure you want to revoke this %{accessTokenType}? This action cannot be undone.',
+ ),
+ revokeButton: __('Revoke'),
+ tokenValidity: __('Token valid until revoked'),
+ },
+ inject: [
+ 'accessTokenType',
+ 'accessTokenTypePlural',
+ 'initialActiveAccessTokens',
+ 'noActiveTokensMessage',
+ 'showRole',
+ ],
+ data() {
+ return {
+ activeAccessTokens: this.initialActiveAccessTokens,
+ currentPage: INITIAL_PAGE,
+ };
+ },
+ computed: {
+ filteredFields() {
+ return this.showRole ? FIELDS : FIELDS.filter((field) => field.key !== 'role');
+ },
+ header() {
+ return sprintf(this.$options.i18n.header, {
+ accessTokenTypePlural: this.accessTokenTypePlural,
+ totalAccessTokens: this.activeAccessTokens.length,
+ });
+ },
+ modalMessage() {
+ return sprintf(this.$options.i18n.modalMessage, {
+ accessTokenType: this.accessTokenType,
+ });
+ },
+ showPagination() {
+ return this.activeAccessTokens.length > PAGE_SIZE;
+ },
+ },
+ methods: {
+ onSuccess(event) {
+ const [{ active_access_tokens: activeAccessTokens }] = event.detail;
+ this.activeAccessTokens = convertObjectPropsToCamelCase(activeAccessTokens, { deep: true });
+ this.currentPage = INITIAL_PAGE;
+ },
+ sortingChanged(aRow, bRow, key) {
+ if (['createdAt', 'lastUsedAt', 'expiresAt'].includes(key)) {
+ // Transform `null` value to the latest possible date
+ // https://stackoverflow.com/a/11526569/18428169
+ const maxEpoch = 8640000000000000;
+ const a = new Date(aRow[key] ?? maxEpoch).getTime();
+ const b = new Date(bRow[key] ?? maxEpoch).getTime();
+ return a - b;
+ }
+
+ // For other columns the default sorting works OK
+ return false;
+ },
+ },
+};
+</script>
+
+<template>
+ <dom-element-listener :selector="$options.FORM_SELECTOR" @[$options.EVENT_SUCCESS]="onSuccess">
+ <div>
+ <hr />
+ <h5>{{ header }}</h5>
+
+ <gl-table
+ data-testid="active-tokens"
+ :empty-text="noActiveTokensMessage"
+ :fields="filteredFields"
+ :items="activeAccessTokens"
+ :per-page="$options.PAGE_SIZE"
+ :current-page="currentPage"
+ :sort-compare="sortingChanged"
+ show-empty
+ >
+ <template #cell(createdAt)="{ item: { createdAt } }">
+ <user-date :date="createdAt" />
+ </template>
+
+ <template #head(lastUsedAt)="{ label }">
+ <span>{{ label }}</span>
+ <gl-link :href="$options.lastUsedHelpLink"
+ ><gl-icon name="question-o" /><span class="gl-sr-only">{{
+ s__('AccessTokens|The last time a token was used')
+ }}</span></gl-link
+ >
+ </template>
+
+ <template #cell(lastUsedAt)="{ item: { lastUsedAt } }">
+ <time-ago-tooltip v-if="lastUsedAt" :time="lastUsedAt" />
+ <template v-else> {{ $options.i18n.emptyField }}</template>
+ </template>
+
+ <template #cell(expiresAt)="{ item: { expiresAt, expired, expiresSoon } }">
+ <template v-if="expiresAt">
+ <span v-if="expired" class="text-danger">{{ $options.i18n.expired }}</span>
+ <time-ago-tooltip v-else :class="{ 'text-warning': expiresSoon }" :time="expiresAt" />
+ </template>
+ <span v-else v-gl-tooltip :title="$options.i18n.tokenValidity">{{
+ $options.i18n.emptyField
+ }}</span>
+ </template>
+
+ <template #cell(action)="{ item: { revokePath, expiresAt } }">
+ <gl-button
+ variant="danger"
+ :category="expiresAt ? 'primary' : 'secondary'"
+ :aria-label="$options.i18n.revokeButton"
+ :data-confirm="modalMessage"
+ data-confirm-btn-variant="danger"
+ data-qa-selector="revoke_button"
+ data-method="put"
+ :href="revokePath"
+ icon="remove"
+ />
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-if="showPagination"
+ v-model="currentPage"
+ :per-page="$options.PAGE_SIZE"
+ :total-items="activeAccessTokens.length"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ :label-next-page="__('Go to next page')"
+ :label-prev-page="__('Go to previous page')"
+ align="center"
+ />
+ </div>
+ </dom-element-listener>
+</template>
diff --git a/app/assets/javascripts/access_tokens/components/constants.js b/app/assets/javascripts/access_tokens/components/constants.js
new file mode 100644
index 00000000000..84e50bc099f
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/constants.js
@@ -0,0 +1,61 @@
+import { __, s__ } from '~/locale';
+
+export const EVENT_ERROR = 'ajax:error';
+export const EVENT_SUCCESS = 'ajax:success';
+export const FORM_SELECTOR = '#js-new-access-token-form';
+
+export const INITIAL_PAGE = 1;
+export const PAGE_SIZE = 100;
+
+export const FIELDS = [
+ {
+ key: 'name',
+ label: __('Token name'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ formatter(scopes) {
+ return scopes?.length ? scopes.join(', ') : __('no scopes selected');
+ },
+ key: 'scopes',
+ label: __('Scopes'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'createdAt',
+ label: s__('AccessTokens|Created'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'lastUsedAt',
+ label: __('Last Used'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'expiresAt',
+ label: __('Expires'),
+ sortable: true,
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ },
+ {
+ key: 'role',
+ label: __('Role'),
+ tdClass: `gl-text-black-normal`,
+ thClass: `gl-text-black-normal`,
+ sortable: true,
+ },
+ {
+ key: 'action',
+ label: __('Action'),
+ thClass: `gl-text-black-normal`,
+ },
+];
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
index 561b2617c5f..147de529eea 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -21,17 +21,17 @@ export default {
required: false,
default: () => ({}),
},
+ minDate: {
+ type: Date,
+ required: false,
+ default: () => new Date(),
+ },
maxDate: {
type: Date,
required: false,
default: () => null,
},
},
- data() {
- return {
- minDate: new Date(),
- };
- },
};
</script>
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
new file mode 100644
index 00000000000..904052688f3
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -0,0 +1,130 @@
+<script>
+import { GlAlert } from '@gitlab/ui';
+import { createAlert, VARIANT_INFO } from '~/flash';
+import { __, n__, sprintf } from '~/locale';
+import DomElementListener from '~/vue_shared/components/dom_element_listener.vue';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
+import { EVENT_ERROR, EVENT_SUCCESS, FORM_SELECTOR } from './constants';
+
+export default {
+ EVENT_ERROR,
+ EVENT_SUCCESS,
+ FORM_SELECTOR,
+ name: 'NewAccessTokenApp',
+ components: { DomElementListener, GlAlert, InputCopyToggleVisibility },
+ i18n: {
+ alertInfoMessage: __('Your new %{accessTokenType} has been created.'),
+ copyButtonTitle: __('Copy %{accessTokenType}'),
+ description: __("Make sure you save it - you won't be able to access it again."),
+ label: __('Your new %{accessTokenType}'),
+ },
+ tokenInputId: 'new-access-token',
+ inject: ['accessTokenType'],
+ data() {
+ return { errors: null, infoAlert: null, newToken: null };
+ },
+ computed: {
+ alertInfoMessage() {
+ return sprintf(this.$options.i18n.alertInfoMessage, {
+ accessTokenType: this.accessTokenType,
+ });
+ },
+ alertDangerTitle() {
+ return n__(
+ 'The form contains the following error:',
+ 'The form contains the following errors:',
+ this.errors?.length ?? 0,
+ );
+ },
+ copyButtonTitle() {
+ return sprintf(this.$options.i18n.copyButtonTitle, { accessTokenType: this.accessTokenType });
+ },
+ formInputGroupProps() {
+ return {
+ id: this.$options.tokenInputId,
+ class: 'qa-created-access-token',
+ 'data-qa-selector': 'created_access_token_field',
+ name: this.$options.tokenInputId,
+ };
+ },
+ label() {
+ return sprintf(this.$options.i18n.label, { accessTokenType: this.accessTokenType });
+ },
+ },
+ mounted() {
+ /** @type {HTMLFormElement} */
+ this.form = document.querySelector(FORM_SELECTOR);
+
+ /** @type {HTMLInputElement} */
+ this.submitButton = this.form.querySelector('input[type=submit]');
+ },
+ methods: {
+ beforeDisplayResults() {
+ this.infoAlert?.dismiss();
+ this.$refs.container.scrollIntoView(false);
+
+ this.errors = null;
+ this.newToken = null;
+ },
+ onError(event) {
+ this.beforeDisplayResults();
+
+ const [{ errors }] = event.detail;
+ this.errors = errors;
+
+ this.submitButton.classList.remove('disabled');
+ },
+ onSuccess(event) {
+ this.beforeDisplayResults();
+
+ const [{ new_token: newToken }] = event.detail;
+ this.newToken = newToken;
+
+ this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO });
+
+ this.form.reset();
+ },
+ },
+};
+</script>
+
+<template>
+ <dom-element-listener
+ :selector="$options.FORM_SELECTOR"
+ @[$options.EVENT_ERROR]="onError"
+ @[$options.EVENT_SUCCESS]="onSuccess"
+ >
+ <div ref="container">
+ <template v-if="newToken">
+ <!--
+ After issue https://gitlab.com/gitlab-org/gitlab/-/issues/360921 is
+ closed remove the `initial-visibility`.
+ -->
+ <input-copy-toggle-visibility
+ :copy-button-title="copyButtonTitle"
+ :label="label"
+ :label-for="$options.tokenInputId"
+ :value="newToken"
+ initial-visibility
+ :form-input-group-props="formInputGroupProps"
+ >
+ <template #description>
+ {{ $options.i18n.description }}
+ </template>
+ </input-copy-toggle-visibility>
+ <hr />
+ </template>
+
+ <template v-if="errors">
+ <gl-alert :title="alertDangerTitle" variant="danger" @dismiss="errors = null">
+ <ul class="m-0">
+ <li v-for="error in errors" :key="error">
+ {{ error }}
+ </li>
+ </ul>
+ </gl-alert>
+ <hr />
+ </template>
+ </div>
+ </dom-element-listener>
+</template>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index c59bd445539..a7a03523e7f 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -3,12 +3,57 @@ import Vue from 'vue';
import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
+import AccessTokenTableApp from './components/access_token_table_app.vue';
import ExpiresAtField from './components/expires_at_field.vue';
+import NewAccessTokenApp from './components/new_access_token_app.vue';
import TokensApp from './components/tokens_app.vue';
import { FEED_TOKEN, INCOMING_EMAIL_TOKEN, STATIC_OBJECT_TOKEN } from './constants';
+export const initAccessTokenTableApp = () => {
+ const el = document.querySelector('#js-access-token-table-app');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens: initialActiveAccessTokensJson,
+ noActiveTokensMessage: noTokensMessage,
+ } = el.dataset;
+
+ // Default values
+ const noActiveTokensMessage =
+ noTokensMessage ||
+ sprintf(__('This user has no active %{accessTokenTypePlural}.'), { accessTokenTypePlural });
+ const showRole = 'showRole' in el.dataset;
+
+ const initialActiveAccessTokens = convertObjectPropsToCamelCase(
+ JSON.parse(initialActiveAccessTokensJson),
+ {
+ deep: true,
+ },
+ );
+
+ return new Vue({
+ el,
+ name: 'AccessTokenTableRoot',
+ provide: {
+ accessTokenType,
+ accessTokenTypePlural,
+ initialActiveAccessTokens,
+ noActiveTokensMessage,
+ showRole,
+ },
+ render(h) {
+ return h(AccessTokenTableApp);
+ },
+ });
+};
+
export const initExpiresAtField = () => {
const el = document.querySelector('.js-access-tokens-expires-at');
@@ -17,7 +62,7 @@ export const initExpiresAtField = () => {
}
const { expiresAt: inputAttrs } = parseRailsFormFields(el);
- const { maxDate } = el.dataset;
+ const { minDate, maxDate } = el.dataset;
return new Vue({
el,
@@ -25,6 +70,7 @@ export const initExpiresAtField = () => {
return h(ExpiresAtField, {
props: {
inputAttrs,
+ minDate: minDate ? new Date(minDate) : undefined,
maxDate: maxDate ? new Date(maxDate) : undefined,
},
});
@@ -32,6 +78,27 @@ export const initExpiresAtField = () => {
});
};
+export const initNewAccessTokenApp = () => {
+ const el = document.querySelector('#js-new-access-token-app');
+
+ if (!el) {
+ return null;
+ }
+
+ const { accessTokenType } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'NewAccessTokenRoot',
+ provide: {
+ accessTokenType,
+ },
+ render(h) {
+ return h(NewAccessTokenApp);
+ },
+ });
+};
+
export const initProjectsField = () => {
const el = document.querySelector('.js-access-tokens-projects');
diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js
index 7a78ccdb0cd..6fc37e9331f 100644
--- a/app/assets/javascripts/activities.js
+++ b/app/assets/javascripts/activities.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
-import createFlash from '~/flash';
+import { createAlert } from '~/flash';
import { s__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
@@ -31,7 +31,7 @@ export default class Activities {
prepareData: (data) => data,
successCallback: () => this.updateTooltips(),
errorCallback: () =>
- createFlash({
+ createAlert({
message: s__(
'Activity|An error occurred while retrieving activity. Reload the page to try again.',
),
diff --git a/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue b/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue
new file mode 100644
index 00000000000..ef4a5319eec
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/inactive_project_deletion/components/form.vue
@@ -0,0 +1,249 @@
+<script>
+import {
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlFormInput,
+ GlFormText,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { __, s__ } from '~/locale';
+
+export default {
+ name: 'InactiveProjectDeletionForm',
+ components: {
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlFormInput,
+ GlFormText,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ deleteInactiveProjects: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ inactiveProjectsDeleteAfterMonths: {
+ type: Number,
+ required: false,
+ default: 2,
+ },
+ inactiveProjectsMinSizeMb: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ inactiveProjectsSendWarningEmailAfterMonths: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ },
+ data() {
+ return {
+ enabled: this.deleteInactiveProjects,
+ deleteAfterMonths: this.inactiveProjectsDeleteAfterMonths,
+ minSizeMb: this.inactiveProjectsMinSizeMb,
+ sendWarningEmailAfterMonths: this.inactiveProjectsSendWarningEmailAfterMonths,
+ };
+ },
+ computed: {
+ isMinSizeMbValid() {
+ return parseInt(this.minSizeMb, 10) >= 0;
+ },
+ isDeleteAfterMonthsValid() {
+ return (
+ parseInt(this.deleteAfterMonths, 10) > 0 &&
+ parseInt(this.deleteAfterMonths, 10) > parseInt(this.sendWarningEmailAfterMonths, 10)
+ );
+ },
+ isSendWarningEmailAfterMonthsValid() {
+ return parseInt(this.sendWarningEmailAfterMonths, 10) > 0;
+ },
+ },
+ methods: {
+ checkValidity(ref, feedback, valid) {
+ // These form fields are used within a HAML created form and we don't have direct access to the submit button
+ // So we set the validity of the field so the HAML form can't be submitted until this is set back to blank
+ if (valid) {
+ ref.$el.setCustomValidity('');
+ } else {
+ ref.$el.setCustomValidity(feedback);
+ }
+ },
+ },
+ i18n: {
+ checkboxLabel: s__('AdminSettings|Delete inactive projects'),
+ checkboxHelp: s__(
+ 'AdminSettings|Configure when inactive projects should be automatically deleted. %{linkStart}What are inactive projects?%{linkEnd}',
+ ),
+ checkboxHelpDocLink: helpPagePath('administration/inactive_project_deletion'),
+ minSizeMbLabel: s__('AdminSettings|When to delete inactive projects'),
+ minSizeMbDescription: s__('AdminSettings|Delete inactive projects that exceed'),
+ minSizeMbInvalidFeedback: s__('AdminSettings|Minimum size must be at least 0.'),
+ deleteAfterMonthsLabel: s__('AdminSettings|Delete project after'),
+ deleteAfterMonthsInvalidFeedback: s__(
+ "AdminSettings|You can't delete projects before the warning email is sent.",
+ ),
+ sendWarningEmailAfterMonthsLabel: s__('AdminSettings|Send warning email'),
+ sendWarningEmailAfterMonthsDescription: s__(
+ 'AdminSettings|Send email to maintainers after project is inactive for',
+ ),
+ sendWarningEmailAfterMonthsHelp: s__(
+ 'AdminSettings|Requires %{linkStart}email notifications%{linkEnd}',
+ ),
+ sendWarningEmailAfterMonthsDocLink: helpPagePath('user/profile/notifications'),
+ sendWarningEmailAfterMonthsInvalidFeedback: s__(
+ 'AdminSettings|Setting must be greater than 0.',
+ ),
+ mbAppend: __('MB'),
+ monthsAppend: __('months'),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-form-group>
+ <input name="application_setting[delete_inactive_projects]" type="hidden" :value="enabled" />
+ <gl-form-checkbox v-model="enabled">
+ {{ $options.i18n.checkboxLabel }}
+
+ <template #help>
+ <gl-sprintf :message="$options.i18n.checkboxHelp">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.checkboxHelpDocLink" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-checkbox>
+ </gl-form-group>
+
+ <div v-if="enabled" class="gl-ml-6" data-testid="inactive-project-deletion-settings">
+ <gl-form-group
+ :label="$options.i18n.minSizeMbLabel"
+ :state="isMinSizeMbValid"
+ data-testid="min-size-group"
+ >
+ <template #invalid-feedback>
+ <div class="gl-w-40p">{{ $options.i18n.minSizeMbInvalidFeedback }}</div>
+ </template>
+ <gl-form-text class="gl-mt-0 gl-mb-3 gl-text-body!">
+ {{ $options.i18n.minSizeMbDescription }}
+ </gl-form-text>
+ <gl-form-input-group data-testid="min-size-input-group">
+ <gl-form-input
+ ref="minSizeMbInput"
+ v-model="minSizeMb"
+ :state="isMinSizeMbValid"
+ name="application_setting[inactive_projects_min_size_mb]"
+ size="md"
+ type="number"
+ :min="0"
+ data-testid="min-size-input"
+ @change="
+ checkValidity(
+ $refs.minSizeMbInput,
+ $options.i18n.minSizeMbInvalidFeedback,
+ isMinSizeMbValid,
+ )
+ "
+ />
+
+ <template #append>
+ <div class="input-group-text">{{ $options.i18n.mbAppend }}</div>
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <div class="gl-pl-6 gl-border-l">
+ <gl-form-group
+ :label="$options.i18n.deleteAfterMonthsLabel"
+ :state="isDeleteAfterMonthsValid"
+ data-testid="delete-after-months-group"
+ >
+ <template #invalid-feedback>
+ <div class="gl-w-30p">{{ $options.i18n.deleteAfterMonthsInvalidFeedback }}</div>
+ </template>
+ <gl-form-input-group data-testid="delete-after-months-input-group">
+ <gl-form-input
+ ref="deleteAfterMonthsInput"
+ v-model="deleteAfterMonths"
+ :state="isDeleteAfterMonthsValid"
+ name="application_setting[inactive_projects_delete_after_months]"
+ size="sm"
+ type="number"
+ :min="0"
+ data-testid="delete-after-months-input"
+ @change="
+ checkValidity(
+ $refs.deleteAfterMonthsInput,
+ $options.i18n.deleteAfterMonthsInvalidFeedback,
+ isDeleteAfterMonthsValid,
+ )
+ "
+ />
+
+ <template #append>
+ <div class="input-group-text">{{ $options.i18n.monthsAppend }}</div>
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <gl-form-group
+ :label="$options.i18n.sendWarningEmailAfterMonthsLabel"
+ :state="isSendWarningEmailAfterMonthsValid"
+ data-testid="send-warning-email-after-months-group"
+ >
+ <template #invalid-feedback>
+ <div class="gl-w-30p">
+ {{ $options.i18n.sendWarningEmailAfterMonthsInvalidFeedback }}
+ </div>
+ </template>
+ <gl-form-text class="gl-max-w-26 gl-mt-0 gl-mb-3 gl-text-body!">
+ {{ $options.i18n.sendWarningEmailAfterMonthsDescription }}
+ </gl-form-text>
+ <gl-form-input-group data-testid="send-warning-email-after-months-input-group">
+ <gl-form-input
+ ref="sendWarningEmailAfterMonthsInput"
+ v-model="sendWarningEmailAfterMonths"
+ :state="isSendWarningEmailAfterMonthsValid"
+ name="application_setting[inactive_projects_send_warning_email_after_months]"
+ size="sm"
+ type="number"
+ :min="0"
+ data-testid="send-warning-email-after-months-input"
+ @change="
+ checkValidity(
+ $refs.sendWarningEmailAfterMonthsInput,
+ $options.i18n.sendWarningEmailAfterMonthsInvalidFeedback,
+ isSendWarningEmailAfterMonthsValid,
+ )
+ "
+ />
+
+ <template #append>
+ <div class="input-group-text">{{ $options.i18n.monthsAppend }}</div>
+ </template>
+ </gl-form-input-group>
+
+ <template #description>
+ <gl-sprintf :message="$options.i18n.sendWarningEmailAfterMonthsHelp">
+ <template #link="{ content }">
+ <gl-link :href="$options.i18n.sendWarningEmailAfterMonthsDocLink" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-group>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/application_settings/inactive_project_deletion/index.js b/app/assets/javascripts/admin/application_settings/inactive_project_deletion/index.js
new file mode 100644
index 00000000000..43e6902885c
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/inactive_project_deletion/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Form from './components/form.vue';
+
+export default () => {
+ const el = document.querySelector('.js-inactive-project-deletion-form');
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ deleteInactiveProjects,
+ inactiveProjectsDeleteAfterMonths,
+ inactiveProjectsMinSizeMb,
+ inactiveProjectsSendWarningEmailAfterMonths,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'InactiveProjectDeletion',
+ render(createElement) {
+ return createElement(Form, {
+ props: {
+ deleteInactiveProjects: parseBoolean(deleteInactiveProjects),
+ inactiveProjectsDeleteAfterMonths: parseInt(inactiveProjectsDeleteAfterMonths, 10),
+ inactiveProjectsMinSizeMb: parseInt(inactiveProjectsMinSizeMb, 10),
+ inactiveProjectsSendWarningEmailAfterMonths: parseInt(
+ inactiveProjectsSendWarningEmailAfterMonths,
+ 10,
+ ),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/admin/users/components/user_actions.vue b/app/assets/javascripts/admin/users/components/user_actions.vue
index 829174d7593..40e5f8d9d70 100644
--- a/app/assets/javascripts/admin/users/components/user_actions.vue
+++ b/app/assets/javascripts/admin/users/components/user_actions.vue
@@ -100,7 +100,7 @@ export default {
<gl-button
v-else
v-gl-tooltip="$options.i18n.edit"
- icon="pencil-square"
+ icon="pencil"
v-bind="editButtonAttrs"
:aria-label="$options.i18n.edit"
/>
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
index ede5c26e487..b4b84594276 100644
--- a/app/assets/javascripts/admin/users/components/users_table.vue
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -2,7 +2,7 @@
import { GlSkeletonLoader, GlTable } from '@gitlab/ui';
import createFlash from '~/flash';
import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
-import { thWidthClass } from '~/lib/utils/table_utility';
+import { thWidthPercent } from '~/lib/utils/table_utility';
import { s__, __ } from '~/locale';
import UserDate from '~/vue_shared/components/user_date.vue';
import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql';
@@ -70,32 +70,32 @@ export default {
{
key: 'name',
label: __('Name'),
- thClass: thWidthClass(40),
+ thClass: thWidthPercent(40),
},
{
key: 'projectsCount',
label: __('Projects'),
- thClass: thWidthClass(10),
+ thClass: thWidthPercent(10),
},
{
key: 'groupCount',
label: __('Groups'),
- thClass: thWidthClass(10),
+ thClass: thWidthPercent(10),
},
{
key: 'createdAt',
label: __('Created on'),
- thClass: thWidthClass(15),
+ thClass: thWidthPercent(15),
},
{
key: 'lastActivityOn',
label: __('Last activity'),
- thClass: thWidthClass(15),
+ thClass: thWidthPercent(15),
},
{
key: 'settings',
label: '',
- thClass: thWidthClass(10),
+ thClass: thWidthPercent(10),
},
],
};
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 f4cc0678c38..3860831169e 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -42,6 +42,20 @@ const bodyTrClass =
export default {
i18n,
typeSet,
+ modal: {
+ actionPrimary: {
+ text: i18n.deleteIntegration,
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
components: {
GlButtonGroup,
GlButton,
@@ -204,8 +218,8 @@ export default {
<gl-modal
modal-id="deleteIntegration"
:title="$options.i18n.deleteIntegration"
- :ok-title="$options.i18n.deleteIntegration"
- ok-variant="danger"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
@ok="deleteIntegration"
>
<gl-sprintf
diff --git a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
index 6ac1bce4032..567e534d9cf 100644
--- a/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
+++ b/app/assets/javascripts/analytics/shared/components/value_stream_metrics.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { flatten, isEqual, keyBy } from 'lodash';
import createFlash from '~/flash';
import { sprintf, s__ } from '~/locale';
@@ -48,7 +48,7 @@ const groupRawMetrics = (groups = [], rawData = []) => {
export default {
name: 'ValueStreamMetrics',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
MetricTile,
},
props: {
@@ -119,8 +119,8 @@ export default {
};
</script>
<template>
- <div class="gl-display-flex gl-mt-6" data-testid="vsa-metrics">
- <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3 gl-pr-9 gl-my-6" />
+ <div class="gl-display-flex" data-testid="vsa-metrics" :class="isLoading ? 'gl-my-6' : 'gl-mt-6'">
+ <gl-skeleton-loader v-if="isLoading" />
<template v-else>
<div v-if="hasGroupedMetrics" class="gl-flex-direction-column">
<div
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 63ec40d4ec6..457a52d3807 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import createFlash from '~/flash';
import { number } from '~/lib/utils/unit_format';
@@ -11,7 +11,7 @@ const defaultPrecision = 0;
export default {
name: 'UsageCounts',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
GlSingleStat,
},
data() {
@@ -65,7 +65,7 @@ export default {
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-my-6 gl-align-items-flex-start"
>
- <gl-skeleton-loading v-if="$apollo.queries.counts.loading" />
+ <gl-skeleton-loader v-if="$apollo.queries.counts.loading" />
<template v-else>
<gl-single-stat
v-for="count in counts"
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 8d46ea76be1..0c870a89760 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,4 +1,4 @@
-import createFlash from '~/flash';
+import { createAlert } from '~/flash';
import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import { joinPaths } from './lib/utils/url_utility';
@@ -444,7 +444,7 @@ const Api = {
},
// Return group projects list. Filtered by query
- groupProjects(groupId, query, options, callback = () => {}, useCustomErrorHandler = false) {
+ groupProjects(groupId, query, options, callback = () => {}) {
const url = Api.buildUrl(Api.groupProjectsPath).replace(':id', groupId);
const defaults = {
search: query,
@@ -456,19 +456,7 @@ const Api = {
})
.then(({ data, headers }) => {
callback(data);
-
return { data, headers };
- })
- .catch((error) => {
- if (useCustomErrorHandler) {
- throw error;
- }
-
- createFlash({
- message: __('Something went wrong while fetching projects'),
- });
-
- callback();
});
},
@@ -654,7 +642,7 @@ const Api = {
})
.then(({ data }) => callback(data))
.catch(() =>
- createFlash({
+ createAlert({
message: __('Something went wrong while fetching projects'),
}),
);
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 7666f558eb5..667aa878261 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -2,10 +2,9 @@ import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
-export * from './alert_management_alerts_api';
-
const PROJECTS_PATH = '/api/:version/projects.json';
const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
+const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size';
export function getProjects(query, options, callback = () => {}) {
const url = buildApiUrl(PROJECTS_PATH);
@@ -35,3 +34,11 @@ export function importProjectMembers(sourceId, targetId) {
.replace(':project_id', targetId);
return axios.post(url);
}
+
+export function updateRepositorySize(projectPath) {
+ const url = buildApiUrl(PROJECT_REPOSITORY_SIZE_PATH).replace(
+ ':id',
+ encodeURIComponent(projectPath),
+ );
+ return axios.post(url);
+}
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 fe801cd460f..9cf41750efe 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
@@ -101,9 +101,9 @@ export default {
<template>
<div>
- <h3 class="page-title">
+ <h1 class="page-title gl-font-size-h-display">
{{ $options.i18n.pageTitle }}
- </h3>
+ </h1>
<hr />
<gl-alert variant="info" :dismissible="false">
{{ $options.i18n.alertTitle }}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index aa735df7da5..a030797c698 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -3,9 +3,9 @@
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import { uniq } from 'lodash';
+import { getEmojiScoreWithIntent } from '~/emoji/utils';
import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils';
import * as Emoji from '~/emoji';
-
import { dispose, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@@ -559,13 +559,45 @@ export class AwardsHandler {
}
}
+ getEmojiScore(emojis, value) {
+ const elem = $(value).find('[data-name]').get(0);
+ const emoji = emojis.filter((x) => x.emoji.name === elem.dataset.name)[0];
+ elem.dataset.score = emoji.score;
+
+ return emoji.score;
+ }
+
+ sortEmojiElements(emojis, $elements) {
+ const scores = new WeakMap();
+
+ return $elements.sort((a, b) => {
+ let aScore = scores.get(a);
+ let bScore = scores.get(b);
+
+ if (!aScore) {
+ aScore = this.getEmojiScore(emojis, a);
+ scores.set(a, aScore);
+ }
+
+ if (!bScore) {
+ bScore = this.getEmojiScore(emojis, b);
+ scores.set(b, bScore);
+ }
+
+ return aScore - bScore;
+ });
+ }
+
findMatchingEmojiElements(query) {
- const emojiMatches = this.emoji.searchEmoji(query).map((x) => x.emoji.name);
+ const matchingEmoji = this.emoji
+ .searchEmoji(query)
+ .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) }));
+ const matchingEmojiNames = matchingEmoji.map((x) => x.emoji.name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter(
- (i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
+ (i, elm) => matchingEmojiNames.indexOf(elm.dataset.name) >= 0,
);
- return $matchingElements.closest('li').clone();
+ return this.sortEmojiElements(matchingEmoji, $matchingElements.closest('li').clone());
}
/* showMenuElement and hideMenuElement are performance optimizations. We use
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index c8130c47f5b..2b1ab911fbe 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -113,7 +113,7 @@ export default {
class="referenced-commands draft-note-commands"
></div>
- <p class="draft-note-actions d-flex">
+ <p class="draft-note-actions d-flex" data-qa-selector="draft_note_content">
<publish-button
:show-count="true"
:should-publish="false"
diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue
index 61718b766d8..0cd093823bc 100644
--- a/app/assets/javascripts/batch_comments/components/drafts_count.vue
+++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue
@@ -6,13 +6,20 @@ export default {
components: {
GlBadge,
},
+ props: {
+ variant: {
+ type: String,
+ required: false,
+ default: 'info',
+ },
+ },
computed: {
...mapGetters('batchComments', ['draftsCount']),
},
};
</script>
<template>
- <gl-badge size="sm" variant="info" class="gl-ml-2">
+ <gl-badge size="sm" :variant="variant" 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 e90c29e939f..f839056daf8 100644
--- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue
@@ -1,7 +1,9 @@
<script>
import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import PreviewItem from './preview_item.vue';
+import DraftsCount from './drafts_count.vue';
export default {
components: {
@@ -9,7 +11,9 @@ export default {
GlDropdownItem,
GlIcon,
PreviewItem,
+ DraftsCount,
},
+ mixins: [glFeatureFlagMixin()],
computed: {
...mapState('diffs', ['viewDiffsFileByFile']),
...mapGetters('batchComments', ['draftsCount', 'sortedDrafts']),
@@ -39,6 +43,7 @@ export default {
>
<template #button-content>
{{ __('Pending comments') }}
+ <drafts-count v-if="glFeatures.mrReviewSubmitComment" variant="neutral" />
<gl-icon class="dropdown-chevron" name="chevron-up" />
</template>
<gl-dropdown-item
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index bce13751448..3cd1a2525e9 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -1,14 +1,18 @@
<script>
import { mapActions, mapGetters } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants';
import PreviewDropdown from './preview_dropdown.vue';
import PublishButton from './publish_button.vue';
+import SubmitDropdown from './submit_dropdown.vue';
export default {
components: {
PreviewDropdown,
PublishButton,
+ SubmitDropdown,
},
+ mixins: [glFeatureFlagMixin()],
computed: {
...mapGetters(['isNotesFetched']),
},
@@ -38,7 +42,8 @@ export default {
data-qa-selector="review_bar_content"
>
<preview-dropdown />
- <publish-button class="gl-ml-3" show-count />
+ <publish-button v-if="!glFeatures.mrReviewSubmitComment" class="gl-ml-3" show-count />
+ <submit-dropdown v-else />
</div>
</nav>
</div>
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
new file mode 100644
index 00000000000..5f4a1e44ea3
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -0,0 +1,115 @@
+<script>
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup } from '@gitlab/ui';
+import { mapGetters, mapActions } from 'vuex';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { scrollToElement } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlDropdown,
+ GlButton,
+ GlIcon,
+ GlForm,
+ GlFormGroup,
+ MarkdownField,
+ },
+ data() {
+ return {
+ isSubmitting: false,
+ note: '',
+ };
+ },
+ computed: {
+ ...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
+ },
+ methods: {
+ ...mapActions('batchComments', ['publishReview']),
+ async submitReview() {
+ const noteData = {
+ noteable_type: this.noteableType,
+ noteable_id: this.getNoteableData.id,
+ note: this.note,
+ };
+
+ this.isSubmitting = true;
+
+ await this.publishReview(noteData);
+
+ if (window.mrTabs && this.note) {
+ window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
+ window.mrTabs.tabShown('show');
+
+ setTimeout(() =>
+ scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)),
+ );
+ }
+
+ this.isSubmitting = false;
+ },
+ },
+ restrictedToolbarItems: ['full-screen'],
+};
+</script>
+
+<template>
+ <gl-dropdown right class="submit-review-dropdown" variant="info" category="secondary">
+ <template #button-content>
+ {{ __('Finish review') }}
+ <gl-icon class="dropdown-chevron" name="chevron-up" />
+ </template>
+ <gl-form data-testid="submit-gl-form" @submit.prevent="submitReview">
+ <gl-form-group
+ :label="__('Summary comment (optional)')"
+ label-for="review-note-body"
+ label-class="gl-mb-2"
+ >
+ <div class="common-note-form gfm-form">
+ <div
+ class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
+ >
+ <markdown-field
+ :is-submitting="isSubmitting"
+ :add-spacing-classes="false"
+ :textarea-value="note"
+ :markdown-preview-path="getNoteableData.preview_note_path"
+ :markdown-docs-path="getNotesData.markdownDocsPath"
+ :quick-actions-docs-path="getNotesData.quickActionsDocsPath"
+ :restricted-tool-bar-items="$options.restrictedToolbarItems"
+ :force-autosize="false"
+ class="js-no-autosize"
+ >
+ <template #textarea>
+ <textarea
+ id="review-note-body"
+ ref="textarea"
+ v-model="note"
+ dir="auto"
+ :disabled="isSubmitting"
+ name="review[note]"
+ class="note-textarea js-gfm-input markdown-area"
+ data-supports-quick-actions="true"
+ data-testid="comment-textarea"
+ :aria-label="__('Comment')"
+ :placeholder="__('Write a comment or drag your files hereā€¦')"
+ @keydown.meta.enter="submitReview"
+ @keydown.ctrl.enter="submitReview"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </div>
+ </div>
+ </gl-form-group>
+ <div class="gl-display-flex gl-justify-content-end gl-mt-5">
+ <gl-button
+ :loading="isSubmitting"
+ variant="confirm"
+ type="submit"
+ class="js-no-auto-disable"
+ data-testid="submit-review-button"
+ >
+ {{ __('Submit review') }}
+ </gl-button>
+ </div>
+ </gl-form>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/batch_comments/services/drafts_service.js b/app/assets/javascripts/batch_comments/services/drafts_service.js
index 36d2f8df612..b52e573d55d 100644
--- a/app/assets/javascripts/batch_comments/services/drafts_service.js
+++ b/app/assets/javascripts/batch_comments/services/drafts_service.js
@@ -19,8 +19,8 @@ export default {
fetchDrafts(endpoint) {
return axios.get(endpoint);
},
- publish(endpoint) {
- return axios.post(endpoint);
+ publish(endpoint, noteData) {
+ return axios.post(endpoint, noteData);
},
discard(endpoint) {
return axios.delete(endpoint);
diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
index 4ee22918463..908cbfd6dc8 100644
--- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
+++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js
@@ -77,28 +77,22 @@ export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => {
.catch(() => commit(types.RECEIVE_PUBLISH_DRAFT_ERROR, draftId));
};
-export const publishReview = ({ commit, dispatch, getters }) => {
+export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => {
commit(types.REQUEST_PUBLISH_REVIEW);
return service
- .publish(getters.getNotesData.draftsPublishPath)
+ .publish(getters.getNotesData.draftsPublishPath, noteData)
.then(() => dispatch('updateDiscussionsAfterPublish'))
.then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS))
.catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR));
};
export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => {
- if (window.gon?.features?.paginatedNotes) {
- await dispatch('stopPolling', null, { root: true });
- await dispatch('fetchData', null, { root: true });
- await dispatch('restartPolling', null, { root: true });
- } else {
- await dispatch(
- 'fetchDiscussions',
- { path: getters.getNotesData.discussionsPath },
- { root: true },
- );
- }
+ await dispatch(
+ 'fetchDiscussions',
+ { path: getters.getNotesData.discussionsPath },
+ { root: true },
+ );
dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, {
root: true,
diff --git a/app/assets/javascripts/behaviors/components/sandboxed_mermaid.vue b/app/assets/javascripts/behaviors/components/sandboxed_mermaid.vue
new file mode 100644
index 00000000000..6b4110cff02
--- /dev/null
+++ b/app/assets/javascripts/behaviors/components/sandboxed_mermaid.vue
@@ -0,0 +1,77 @@
+<script>
+import {
+ getSandboxFrameSrc,
+ BUFFER_IFRAME_HEIGHT,
+ SANDBOX_ATTRIBUTES,
+} from '../markdown/render_sandboxed_mermaid';
+
+export default {
+ name: 'SandboxedMermaid',
+
+ props: {
+ source: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ iframeHeight: BUFFER_IFRAME_HEIGHT,
+ sandboxFrameSrc: getSandboxFrameSrc(),
+ };
+ },
+
+ watch: {
+ source() {
+ this.updateDiagram();
+ },
+ },
+
+ mounted() {
+ window.addEventListener('message', this.onPostMessage, false);
+ },
+
+ destroyed() {
+ window.removeEventListener('message', this.onPostMessage);
+ },
+
+ methods: {
+ getSandboxFrameSrc,
+
+ onPostMessage(event) {
+ const container = this.$refs.diagramContainer;
+
+ if (event.source === container?.contentWindow) {
+ this.iframeHeight = Number(event.data.h) + BUFFER_IFRAME_HEIGHT;
+ }
+ },
+
+ updateDiagram() {
+ const container = this.$refs.diagramContainer;
+
+ // Potential risk associated with '*' discussed in below thread
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74414#note_735183398
+ container.contentWindow?.postMessage(this.source, '*');
+ container.addEventListener('load', () => {
+ container.contentWindow?.postMessage(this.source, '*');
+ });
+ },
+ },
+
+ sandboxFrameSrc: getSandboxFrameSrc(),
+ sandboxAttributes: SANDBOX_ATTRIBUTES,
+};
+</script>
+<template>
+ <iframe
+ ref="diagramContainer"
+ :src="$options.sandboxFrameSrc"
+ :sandbox="$options.sandboxAttributes"
+ frameborder="0"
+ scrolling="no"
+ width="100%"
+ :height="iframeHeight"
+ >
+ </iframe>
+</template>
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 063393c9cd1..c9ae3706383 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -1,10 +1,8 @@
import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight';
-import initUserPopovers from '../../user_popovers';
import highlightCurrentUser from './highlight_current_user';
import { renderKroki } from './render_kroki';
import renderMath from './render_math';
-import renderMermaid from './render_mermaid';
import renderSandboxedMermaid from './render_sandboxed_mermaid';
import renderMetrics from './render_metrics';
@@ -16,19 +14,15 @@ $.fn.renderGFM = function renderGFM() {
syntaxHighlight(this.find('.js-syntax-highlight').get());
renderKroki(this.find('.js-render-kroki[hidden]').get());
renderMath(this.find('.js-render-math'));
- if (gon.features?.sandboxedMermaid) {
- renderSandboxedMermaid(this.find('.js-render-mermaid'));
- } else {
- renderMermaid(this.find('.js-render-mermaid'));
- }
+ renderSandboxedMermaid(this.find('.js-render-mermaid'));
+
highlightCurrentUser(this.find('.gfm-project_member').get());
- initUserPopovers(this.find('.js-user-link').get());
- const mrPopoverElements = this.find('.gfm-merge_request').get();
- if (mrPopoverElements.length) {
- import(/* webpackChunkName: 'MrPopoverBundle' */ '~/mr_popover')
- .then(({ default: initMRPopovers }) => {
- initMRPopovers(mrPopoverElements);
+ const issuablePopoverElements = this.find('.gfm-issue, .gfm-merge_request').get();
+ if (issuablePopoverElements.length) {
+ import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover')
+ .then(({ default: initIssuablePopovers }) => {
+ initIssuablePopovers(issuablePopoverElements);
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_kroki.js b/app/assets/javascripts/behaviors/markdown/render_kroki.js
index abe71694d73..5fd910dd6cc 100644
--- a/app/assets/javascripts/behaviors/markdown/render_kroki.js
+++ b/app/assets/javascripts/behaviors/markdown/render_kroki.js
@@ -55,8 +55,8 @@ export function renderKroki(krokiImages) {
// A single Kroki image is processed multiple times for some reason,
// so this condition ensures we only create one alert per Kroki image
- if (!parent.hasAttribute('data-kroki-processed')) {
- parent.setAttribute('data-kroki-processed', 'true');
+ if (!Object.prototype.hasOwnProperty.call(parent.dataset, 'krokiProcessed')) {
+ parent.dataset.krokiProcessed = 'true';
parent.after(createAlert(krokiImage));
}
});
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index 12f47255bdf..af7aac4cf36 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -1,6 +1,7 @@
import { spriteIcon } from '~/lib/utils/common_utils';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
+import { unrestrictedPages } from './constants';
// Renders math using KaTeX in any element with the
// `js-render-math` class
@@ -48,6 +49,7 @@ class SafeMathRenderer {
this.renderElement = this.renderElement.bind(this);
this.render = this.render.bind(this);
this.attachEvents = this.attachEvents.bind(this);
+ this.pageName = document.querySelector('body').dataset.page;
}
renderElement(chosenEl) {
@@ -56,7 +58,7 @@ class SafeMathRenderer {
}
const el = chosenEl || this.queue.shift();
- const forceRender = Boolean(chosenEl);
+ const forceRender = Boolean(chosenEl) || unrestrictedPages.includes(this.pageName);
const text = el.textContent;
el.removeAttribute('style');
@@ -79,7 +81,7 @@ class SafeMathRenderer {
'math|Displaying this math block may cause performance issues on this page',
)}</div>
<div class="gl-alert-actions">
- <button class="js-lazy-render-math btn gl-alert-action btn-primary btn-md gl-button">Display anyway</button>
+ <button class="js-lazy-render-math btn gl-alert-action btn-confirm btn-md gl-button">Display anyway</button>
</div>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
@@ -110,7 +112,7 @@ class SafeMathRenderer {
try {
displayContainer.innerHTML = this.katex.renderToString(text, {
- displayMode: el.getAttribute('data-math-style') === 'display',
+ displayMode: el.dataset.mathStyle === 'display',
throwOnError: true,
maxSize: 20,
maxExpand: 20,
@@ -143,7 +145,7 @@ class SafeMathRenderer {
this.elements.forEach((el) => {
const placeholder = document.createElement('span');
placeholder.style.display = 'none';
- placeholder.setAttribute('data-math-style', el.getAttribute('data-math-style'));
+ placeholder.dataset.mathStyle = el.dataset.mathStyle;
placeholder.textContent = el.textContent;
el.parentNode.replaceChild(placeholder, el);
this.queue.push(placeholder);
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index f9cf3af98bb..2df0f7387fb 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -160,7 +160,7 @@ function renderMermaids($els) {
'Warning: Displaying this diagram might cause performance issues on this page.',
)}</div>
<div class="gl-alert-actions">
- <button class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button>
+ <button class="js-lazy-render-mermaid btn gl-alert-action btn-confirm btn-md gl-button">Display</button>
</div>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
index 3b9f6011c6d..077e96b2fee 100644
--- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
@@ -32,7 +32,8 @@ const MAX_CHAR_LIMIT = 2000;
const MAX_MERMAID_BLOCK_LIMIT = 50;
// Max # of `&` allowed in Chaining of links syntax
const MAX_CHAINING_OF_LINKS_LIMIT = 30;
-const BUFFER_IFRAME_HEIGHT = 10;
+export const BUFFER_IFRAME_HEIGHT = 10;
+export const SANDBOX_ATTRIBUTES = 'allow-scripts allow-popups';
// Keep a map of mermaid blocks we've already rendered.
const elsProcessingMap = new WeakMap();
let renderedMermaidBlocks = 0;
@@ -56,7 +57,7 @@ function fixElementSource(el) {
return { source };
}
-function getSandboxFrameSrc() {
+export function getSandboxFrameSrc() {
const path = joinPaths(gon.relative_url_root || '', SANDBOX_FRAME_PATH);
if (!darkModeEnabled()) {
return path;
@@ -69,7 +70,7 @@ function renderMermaidEl(el, source) {
const iframeEl = document.createElement('iframe');
setAttributes(iframeEl, {
src: getSandboxFrameSrc(),
- sandbox: 'allow-scripts allow-popups',
+ sandbox: SANDBOX_ATTRIBUTES,
frameBorder: 0,
scrolling: 'no',
width: '100%',
@@ -138,7 +139,7 @@ function renderMermaids($els) {
<div>
<div class="js-warning-text"></div>
<div class="gl-alert-actions">
- <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-warning btn-md gl-button">Display</button>
+ <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-confirm btn-md gl-button">Display</button>
</div>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js
index a3dd241604d..0a5bcf326a1 100644
--- a/app/assets/javascripts/blob/blob_line_permalink_updater.js
+++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js
@@ -9,10 +9,11 @@ const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => {
[].concat(Array.prototype.slice.call(linksToUpdate)).forEach((permalinkButton) => {
const baseHref =
- permalinkButton.getAttribute('data-original-href') ||
+ permalinkButton.dataset.originalHref ||
(() => {
const href = permalinkButton.getAttribute('href');
- permalinkButton.setAttribute('data-original-href', href);
+ // eslint-disable-next-line no-param-reassign
+ permalinkButton.dataset.originalHref = href;
return href;
})();
permalinkButton.setAttribute('href', `${baseHref}${hashUrlString}`);
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index f78d921fa90..716321430d2 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -47,6 +47,11 @@ export default {
required: false,
default: true,
},
+ overrideCopy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -106,6 +111,7 @@ export default {
:environment-name="blob.environmentFormattedExternalUrl"
:environment-path="blob.environmentExternalUrlForRouteMap"
:is-empty="isEmpty"
+ :override-copy="overrideCopy"
@copy="proxyCopyRequest"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
index 61baf4fa495..12a198f78ea 100644
--- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue
+++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue
@@ -54,6 +54,11 @@ export default {
required: false,
default: false,
},
+ overrideCopy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
downloadUrl() {
@@ -63,6 +68,10 @@ export default {
return this.activeViewer === RICH_BLOB_VIEWER;
},
getBlobHashTarget() {
+ if (this.overrideCopy) {
+ return null;
+ }
+
return `[data-blob-hash="${this.blobHash}"]`;
},
showCopyButton() {
@@ -74,6 +83,13 @@ export default {
});
},
},
+ methods: {
+ onCopy() {
+ if (this.overrideCopy) {
+ this.$emit('copy');
+ }
+ },
+ },
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
BTN_RAW_TITLE,
@@ -94,6 +110,7 @@ export default {
category="primary"
variant="default"
class="js-copy-blob-source-btn"
+ @click="onCopy"
/>
<gl-button
v-if="!isBinary"
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 62355306655..fb99392ff48 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -46,7 +46,7 @@ export default {
<slot name="filepath-prepend"></slot>
<template v-if="fileName">
- <file-icon :file-name="fileName" :size="16" aria-hidden="true" css-classes="mr-2" />
+ <file-icon :file-name="fileName" :size="16" aria-hidden="true" css-classes="gl-mr-3" />
<strong
class="file-title-name mr-1 js-blob-header-filepath"
data-qa-selector="file_title_content"
@@ -62,7 +62,7 @@ export default {
css-class="btn-clipboard btn-transparent lh-100 position-static"
/>
- <small class="mr-2">{{ blobSize }}</small>
+ <small class="gl-mr-3">{{ blobSize }}</small>
<gl-badge v-if="showLfsBadge">{{ __('LFS') }}</gl-badge>
</div>
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index a6eed4ecae3..a0d4f7ef4f2 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -36,19 +36,19 @@ const loadRichBlobViewer = (type) => {
const loadViewer = (viewerParam) => {
const viewer = viewerParam;
- const url = viewer.getAttribute('data-url');
+ const { url } = viewer.dataset;
- if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
+ if (!url || viewer.dataset.loaded || viewer.dataset.loading) {
return Promise.resolve(viewer);
}
- viewer.setAttribute('data-loading', 'true');
+ viewer.dataset.loading = 'true';
return axios.get(url).then(({ data }) => {
viewer.innerHTML = data.html;
window.requestIdleCallback(() => {
- viewer.removeAttribute('data-loading');
+ delete viewer.dataset.loading;
});
return viewer;
@@ -108,7 +108,7 @@ export class BlobViewer {
switchToInitialViewer() {
const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)');
- let initialViewerName = initialViewer.getAttribute('data-type');
+ let initialViewerName = initialViewer.dataset.type;
if (this.switcher && window.location.hash.indexOf('#L') === 0) {
initialViewerName = 'simple';
@@ -138,12 +138,12 @@ export class BlobViewer {
e.preventDefault();
- this.switchToViewer(target.getAttribute('data-viewer'));
+ this.switchToViewer(target.dataset.viewer);
}
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
- if (this.simpleViewer.getAttribute('data-loaded')) {
+ if (this.simpleViewer.dataset.loaded) {
this.copySourceBtnTooltip.setAttribute('title', __('Copy file contents'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
@@ -199,7 +199,8 @@ export class BlobViewer {
this.$fileHolder.trigger('highlight:line');
handleLocationHash();
- viewer.setAttribute('data-loaded', 'true');
+ // eslint-disable-next-line no-param-reassign
+ viewer.dataset.loaded = 'true';
this.toggleCopyButtonState();
eventHub.$emit('showBlobInteractionZones', viewer.dataset.path);
});
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
index 858aabb0f05..af753151be8 100644
--- a/app/assets/javascripts/boards/components/board_app.vue
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -1,5 +1,6 @@
<script>
-import { mapActions, mapGetters } from 'vuex';
+import { mapGetters } from 'vuex';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
import BoardContent from '~/boards/components/board_content.vue';
import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
import BoardTopBar from '~/boards/components/board_top_bar.vue';
@@ -14,11 +15,11 @@ export default {
computed: {
...mapGetters(['isSidebarOpen']),
},
- mounted() {
- this.performSearch();
+ created() {
+ window.addEventListener('popstate', refreshCurrentPage);
},
- methods: {
- ...mapActions(['performSearch']),
+ destroyed() {
+ window.removeEventListener('popstate', refreshCurrentPage);
},
};
</script>
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 9d972860d06..9f359a25234 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,7 +1,8 @@
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { visitUrl, updateHistory, getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { formType } from '../constants';
@@ -170,17 +171,7 @@ export default {
}
},
methods: {
- ...mapActions(['setError', 'unsetError']),
- boardCreateResponse(data) {
- return data.createBoard.board.webPath;
- },
- boardUpdateResponse(data) {
- const path = data.updateBoard.board.webPath;
- const param = getParameterByName('group_by')
- ? `?group_by=${getParameterByName('group_by')}`
- : '';
- return `${path}${param}`;
- },
+ ...mapActions(['setError', 'unsetError', 'setBoard']),
cancel() {
this.$emit('cancel');
},
@@ -191,10 +182,10 @@ export default {
});
if (!this.board.id) {
- return this.boardCreateResponse(response.data);
+ return response.data.createBoard.board;
}
- return this.boardUpdateResponse(response.data);
+ return response.data.updateBoard.board;
},
async deleteBoard() {
await this.$apollo.mutate({
@@ -218,8 +209,14 @@ export default {
}
} else {
try {
- const url = await this.createOrUpdateBoard();
- visitUrl(url);
+ const board = await this.createOrUpdateBoard();
+ this.setBoard(board);
+ this.cancel();
+
+ const param = getParameterByName('group_by')
+ ? `?group_by=${getParameterByName('group_by')}`
+ : '';
+ updateHistory({ url: `${this.boardBaseUrl}/${getIdFromGraphQLId(board.id)}${param}` });
} catch {
this.setError({ message: this.$options.i18n.saveErrorMessage });
} finally {
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index a4298eb2544..a65269de743 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -45,9 +45,6 @@ export default {
},
mixins: [Tracking.mixin(), glFeatureFlagMixin()],
inject: {
- boardId: {
- default: '',
- },
weightFeatureAvailable: {
default: false,
},
@@ -78,7 +75,7 @@ export default {
},
},
computed: {
- ...mapState(['activeId', 'filterParams']),
+ ...mapState(['activeId', 'filterParams', 'boardId']),
...mapGetters(['isEpicBoard', 'isSwimlanesOn']),
isLoggedIn() {
return Boolean(this.currentUserId);
@@ -155,6 +152,12 @@ export default {
isLoading() {
return this.$apollo.queries.boardList.loading;
},
+ totalWeight() {
+ return this.boardList?.totalWeight;
+ },
+ canShowTotalWeight() {
+ return this.weightFeatureAvailable && !this.isLoading;
+ },
},
apollo: {
boardList: {
@@ -359,7 +362,7 @@ export default {
<div v-if="weightFeatureAvailable && !isLoading">
ā€¢
<gl-sprintf :message="__('%{totalWeight} total weight')">
- <template #totalWeight>{{ boardList.totalWeight }}</template>
+ <template #totalWeight>{{ totalWeight }}</template>
</gl-sprintf>
</div>
</gl-tooltip>
@@ -384,11 +387,11 @@ export default {
/>
</span>
<!-- EE start -->
- <template v-if="weightFeatureAvailable && !isEpicBoard && !isLoading">
+ <template v-if="canShowTotalWeight">
<gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
- <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
+ <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3" data-testid="weight">
<gl-icon class="gl-mr-2" name="weight" />
- {{ boardList.totalWeight }}
+ {{ totalWeight }}
</span>
</template>
<!-- EE end -->
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 2951eda1112..eaf3facb450 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -14,15 +14,16 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import BoardForm from 'ee_else_ce/boards/components/board_form.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { isMetaKey } from '~/lib/utils/common_utils';
+import { updateHistory } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import eventHub from '../eventhub';
import groupBoardsQuery from '../graphql/group_boards.query.graphql';
import projectBoardsQuery from '../graphql/project_boards.query.graphql';
-import groupBoardQuery from '../graphql/group_board.query.graphql';
-import projectBoardQuery from '../graphql/project_board.query.graphql';
import groupRecentBoardsQuery from '../graphql/group_recent_boards.query.graphql';
import projectRecentBoardsQuery from '../graphql/project_recent_boards.query.graphql';
+import { fullBoardId } from '../boards_util';
const MIN_BOARDS_TO_VIEW_RECENT = 10;
@@ -69,48 +70,15 @@ export default {
maxPosition: 0,
filterTerm: '',
currentPage: '',
- board: {},
};
},
- apollo: {
- board: {
- query() {
- return this.currentBoardQuery;
- },
- variables() {
- return {
- fullPath: this.fullPath,
- boardId: this.fullBoardId,
- };
- },
- update(data) {
- const board = data.workspace?.board;
- this.setBoardConfig(board);
- return {
- ...board,
- labels: board?.labels?.nodes,
- };
- },
- error() {
- this.setError({ message: this.$options.i18n.errorFetchingBoard });
- },
- },
- },
+
computed: {
- ...mapState(['boardType', 'fullBoardId']),
+ ...mapState(['boardType', 'board', 'isBoardLoading']),
...mapGetters(['isGroupBoard', 'isProjectBoard']),
parentType() {
return this.boardType;
},
- currentBoardQueryCE() {
- return this.isGroupBoard ? groupBoardQuery : projectBoardQuery;
- },
- currentBoardQuery() {
- return this.currentBoardQueryCE;
- },
- isBoardLoading() {
- return this.$apollo.queries.board.loading;
- },
loading() {
return this.loadingRecentBoards || Boolean(this.loadingBoards);
},
@@ -147,6 +115,9 @@ export default {
this.scrollFadeInitialized = false;
this.$nextTick(this.setScrollFade);
},
+ board(newBoard) {
+ document.title = newBoard.name;
+ },
},
created() {
eventHub.$on('showBoardModal', this.showPage);
@@ -155,7 +126,7 @@ export default {
eventHub.$off('showBoardModal', this.showPage);
},
methods: {
- ...mapActions(['setError', 'setBoardConfig']),
+ ...mapActions(['setError', 'fetchBoard', 'unsetActiveId']),
showPage(page) {
this.currentPage = page;
},
@@ -231,6 +202,22 @@ export default {
this.hasScrollFade = this.isScrolledUp();
},
+ fetchCurrentBoard(boardId) {
+ this.fetchBoard({
+ fullPath: this.fullPath,
+ fullBoardId: fullBoardId(boardId),
+ boardType: this.boardType,
+ });
+ },
+ async switchBoard(boardId, e) {
+ if (isMetaKey(e)) {
+ window.open(`${this.boardBaseUrl}/${boardId}`, '_blank');
+ } else {
+ this.unsetActiveId();
+ this.fetchCurrentBoard(boardId);
+ updateHistory({ url: `${this.boardBaseUrl}/${boardId}` });
+ }
+ },
},
i18n: {
errorFetchingBoard: s__('Board|An error occurred while fetching the board, please try again.'),
@@ -277,8 +264,8 @@ export default {
<gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
- :href="`${boardBaseUrl}/${recentBoard.id}`"
data-testid="dropdown-item"
+ @click.prevent="switchBoard(recentBoard.id, $event)"
>
{{ recentBoard.name }}
</gl-dropdown-item>
@@ -293,8 +280,8 @@ export default {
<gl-dropdown-item
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
- :href="`${boardBaseUrl}/${otherBoard.id}`"
data-testid="dropdown-item"
+ @click.prevent="switchBoard(otherBoard.id, $event)"
>
{{ otherBoard.name }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/boards/graphql/board_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_create.mutation.graphql
index b3ea79d6443..42e164f4f3c 100644
--- a/app/assets/javascripts/boards/graphql/board_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_create.mutation.graphql
@@ -1,8 +1,9 @@
+#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql"
+
mutation createBoard($input: CreateBoardInput!) {
createBoard(input: $input) {
board {
- id
- webPath
+ ...BoardScopeFragment
}
errors
}
diff --git a/app/assets/javascripts/boards/graphql/board_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_update.mutation.graphql
index 3abe09079c7..90de7713ff3 100644
--- a/app/assets/javascripts/boards/graphql/board_update.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_update.mutation.graphql
@@ -1,8 +1,9 @@
+#import "ee_else_ce/boards/graphql/board_scope.fragment.graphql"
+
mutation UpdateBoard($input: UpdateBoardInput!) {
updateBoard(input: $input) {
board {
- id
- webPath
+ ...BoardScopeFragment
}
errors
}
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index 8af7da1e0aa..854717ed4c4 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -54,7 +54,6 @@ function mountBoardApp(el) {
apolloProvider,
provide: {
disabled: parseBoolean(el.dataset.disabled),
- boardId,
groupId: Number(groupId),
rootPath,
fullPath,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index a84b678a5d9..791182af806 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -31,10 +31,12 @@ import {
import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql';
import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql';
import totalCountAndWeightQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql';
+import { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { queryToObject } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
+import eventHub from '../eventhub';
import { gqlClient } from '../graphql';
import projectBoardQuery from '../graphql/project_board.query.graphql';
import groupBoardQuery from '../graphql/group_board.query.graphql';
@@ -49,6 +51,8 @@ import * as types from './mutation_types';
export default {
fetchBoard: ({ commit, dispatch }, { fullPath, fullBoardId, boardType }) => {
+ commit(types.REQUEST_CURRENT_BOARD);
+
const variables = {
fullPath,
boardId: fullBoardId,
@@ -60,9 +64,12 @@ export default {
variables,
})
.then(({ data }) => {
- const board = data.workspace?.board;
- commit(types.RECEIVE_BOARD_SUCCESS, board);
- dispatch('setBoardConfig', board);
+ if (data.workspace?.errors) {
+ commit(types.RECEIVE_BOARD_FAILURE);
+ } else {
+ const board = data.workspace?.board;
+ dispatch('setBoard', board);
+ }
})
.catch(() => commit(types.RECEIVE_BOARD_FAILURE));
},
@@ -87,6 +94,13 @@ export default {
commit(types.SET_BOARD_CONFIG, config);
},
+ setBoard: async ({ commit, dispatch }, board) => {
+ commit(types.RECEIVE_BOARD_SUCCESS, board);
+ await dispatch('setBoardConfig', board);
+ dispatch('performSearch', { resetLists: true });
+ eventHub.$emit('updateTokens');
+ },
+
setActiveId({ commit }, { id, sidebarType }) {
commit(types.SET_ACTIVE_ID, { id, sidebarType });
},
@@ -107,16 +121,16 @@ export default {
);
},
- performSearch({ dispatch }) {
+ performSearch({ dispatch }, { resetLists = false } = {}) {
dispatch(
'setFilters',
convertObjectPropsToCamelCase(queryToObject(window.location.search, { gatherArrays: true })),
);
- dispatch('fetchLists');
+ dispatch('fetchLists', { resetLists });
dispatch('resetIssues');
},
- fetchLists: ({ commit, state, dispatch }) => {
+ fetchLists: ({ commit, state, dispatch }, { resetLists = false } = {}) => {
const { boardType, filterParams, fullPath, fullBoardId, issuableType } = state;
const variables = {
@@ -133,6 +147,7 @@ export default {
.query({
query: listsQuery[issuableType].query,
variables,
+ ...(resetLists ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}),
})
.then(({ data }) => {
const { lists, hideBacklogList } = data[boardType].board;
@@ -404,9 +419,6 @@ export default {
fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => {
if (!listId) return null;
- if (!fetchNext) {
- commit(types.RESET_ITEMS_FOR_LIST, listId);
- }
commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext });
const { fullPath, fullBoardId, boardType, filterParams } = state;
@@ -428,6 +440,7 @@ export default {
isSingleRequest: true,
},
variables,
+ ...(!fetchNext ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}),
})
.then(({ data }) => {
const { lists } = data[boardType].board;
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 668a3b5e0f9..43268f21f96 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -1,3 +1,4 @@
+export const REQUEST_CURRENT_BOARD = 'REQUEST_CURRENT_BOARD';
export const RECEIVE_BOARD_SUCCESS = 'RECEIVE_BOARD_SUCCESS';
export const RECEIVE_BOARD_FAILURE = 'RECEIVE_BOARD_FAILURE';
export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA';
@@ -17,7 +18,6 @@ export const MOVE_LISTS = 'MOVE_LISTS';
export const TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED';
export const REMOVE_LIST = 'REMOVE_LIST';
export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE';
-export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST';
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';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 9a50dcf05b8..04e7d3643e7 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -1,5 +1,6 @@
import { cloneDeep, pull, union } from 'lodash';
import Vue from 'vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__, __ } from '~/locale';
import { formatIssue } from '../boards_util';
import { issuableTypes } from '../constants';
@@ -33,15 +34,23 @@ export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId
};
export default {
+ [mutationTypes.REQUEST_CURRENT_BOARD]: (state) => {
+ state.isBoardLoading = true;
+ },
+
[mutationTypes.RECEIVE_BOARD_SUCCESS]: (state, board) => {
state.board = {
...board,
labels: board?.labels?.nodes || [],
};
+ state.fullBoardId = board.id;
+ state.boardId = getIdFromGraphQLId(board.id);
+ state.isBoardLoading = false;
},
[mutationTypes.RECEIVE_BOARD_FAILURE]: (state) => {
state.error = s__('Boards|An error occurred while fetching the board. Please reload the page.');
+ state.isBoardLoading = false;
},
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
@@ -136,11 +145,6 @@ export default {
state.boardLists = listsBackup;
},
- [mutationTypes.RESET_ITEMS_FOR_LIST]: (state, listId) => {
- Vue.set(state, 'backupItemsList', state.boardItemsByListId[listId]);
- Vue.set(state.boardItemsByListId, listId, []);
- },
-
[mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => {
Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
},
@@ -176,7 +180,6 @@ export default {
'Boards|An error occurred while fetching the board issues. Please reload the page.',
);
Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false });
- Vue.set(state.boardItemsByListId, listId, state.backupItemsList);
},
[mutationTypes.RESET_ISSUES]: (state) => {
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 7af4e5a8798..b62c032b921 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -2,6 +2,7 @@ import { inactiveId, ListType } from '~/boards/constants';
export default () => ({
board: {},
+ isBoardLoading: false,
boardType: null,
issuableType: null,
fullPath: null,
@@ -12,7 +13,6 @@ export default () => ({
boardLists: {},
listsFlags: {},
boardItemsByListId: {},
- backupItemsList: [],
isSettingAssignees: false,
pageInfoByListId: {},
boardItems: {},
diff --git a/app/assets/javascripts/breadcrumb.js b/app/assets/javascripts/breadcrumb.js
index b9d3742974c..113840dbc52 100644
--- a/app/assets/javascripts/breadcrumb.js
+++ b/app/assets/javascripts/breadcrumb.js
@@ -5,7 +5,7 @@ export const addTooltipToEl = (el) => {
if (textEl && textEl.scrollWidth > textEl.offsetWidth) {
el.setAttribute('title', el.textContent);
- el.setAttribute('data-container', 'body');
+ el.dataset.container = 'body';
el.classList.add('has-tooltip');
}
};
diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
index dbc4565b19d..ebcc4b85ac4 100644
--- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
+++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue
@@ -191,7 +191,7 @@ export default {
<div class="col-md-12 col-lg-6">
<div class="gl-display-flex gl-flex-wrap gl-justify-content-end">
- <gl-button v-if="admin" class="gl-mt-3" variant="info" @click="loadFileSelctor">
+ <gl-button v-if="admin" class="gl-mt-3" variant="confirm" @click="loadFileSelctor">
<span v-if="uploading">
<gl-loading-icon size="sm" class="gl-my-5" inline />
{{ $options.i18n.uploadingLabel }}
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 3af89dc4a2c..557a8d6b5ba 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -369,7 +369,7 @@ export default {
:href="awsTipLearnLink"
target="_blank"
category="secondary"
- variant="info"
+ variant="confirm"
class="gl-overflow-wrap-break"
>{{ __('Learn more about deploying to AWS') }}</gl-button
>
@@ -416,6 +416,7 @@ export default {
:disabled="!canSubmit"
variant="confirm"
category="primary"
+ data-testid="ciUpdateOrAddVariableBtn"
data-qa-selector="ci_variable_save_button"
@click="updateOrAddVariable"
>{{ modalActionText }}
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
index 12bc5ad3549..4cc00eb01d9 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue
@@ -1,32 +1,9 @@
<script>
-import { mapState, mapActions } from 'vuex';
-import CiVariableModal from './ci_variable_modal.vue';
-import CiVariableTable from './ci_variable_table.vue';
-
-export default {
- components: {
- CiVariableModal,
- CiVariableTable,
- },
- computed: {
- ...mapState(['isGroup']),
- },
- mounted() {
- if (!this.isGroup) {
- this.fetchEnvironments();
- }
- },
- methods: {
- ...mapActions(['fetchEnvironments']),
- },
-};
+export default {};
</script>
<template>
<div class="row">
- <div class="col-lg-12">
- <ci-variable-table />
- <ci-variable-modal />
- </div>
+ <div class="col-lg-12"></div>
</div>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue
new file mode 100644
index 00000000000..ecb39f214ec
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_environments_dropdown.vue
@@ -0,0 +1,81 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
+import { __, sprintf } from '~/locale';
+
+export default {
+ name: 'CiEnvironmentsDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ };
+ },
+ computed: {
+ ...mapGetters(['joinedEnvironments']),
+ composedCreateButtonLabel() {
+ return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
+ },
+ shouldRenderCreateButton() {
+ return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm);
+ },
+ filteredResults() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.joinedEnvironments.filter((resultString) =>
+ resultString.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ },
+ methods: {
+ selectEnvironment(selected) {
+ this.$emit('selectEnvironment', selected);
+ this.searchTerm = '';
+ },
+ createClicked() {
+ this.$emit('createClicked', this.searchTerm);
+ this.searchTerm = '';
+ },
+ isSelected(env) {
+ return this.value === env;
+ },
+ clearSearch() {
+ this.searchTerm = '';
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="value" @show="clearSearch">
+ <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
+ <gl-dropdown-item
+ v-for="environment in filteredResults"
+ :key="environment"
+ :is-checked="isSelected(environment)"
+ is-check-item
+ @click="selectEnvironment(environment)"
+ >
+ {{ environment }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ __('No matching results')
+ }}</gl-dropdown-item>
+ <template v-if="shouldRenderCreateButton">
+ <gl-dropdown-divider />
+ <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked">
+ {{ composedCreateButtonLabel }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
new file mode 100644
index 00000000000..7dcc5ce42d7
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue
@@ -0,0 +1,426 @@
+<script>
+import {
+ GlAlert,
+ GlButton,
+ GlCollapse,
+ GlFormCheckbox,
+ GlFormCombobox,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormInput,
+ GlFormTextarea,
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { getCookie, setCookie } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { mapComputed } from '~/vuex_shared/bindings';
+import {
+ AWS_TOKEN_CONSTANTS,
+ ADD_CI_VARIABLE_MODAL_ID,
+ AWS_TIP_DISMISSED_COOKIE_NAME,
+ AWS_TIP_MESSAGE,
+ CONTAINS_VARIABLE_REFERENCE_MESSAGE,
+ ENVIRONMENT_SCOPE_LINK_TITLE,
+ EVENT_LABEL,
+ EVENT_ACTION,
+} from '../constants';
+import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
+import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
+
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
+
+export default {
+ modalId: ADD_CI_VARIABLE_MODAL_ID,
+ tokens: awsTokens,
+ tokenList: awsTokenList,
+ awsTipMessage: AWS_TIP_MESSAGE,
+ containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
+ environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
+ components: {
+ CiEnvironmentsDropdown,
+ GlAlert,
+ GlButton,
+ GlCollapse,
+ GlFormCheckbox,
+ GlFormCombobox,
+ GlFormGroup,
+ GlFormSelect,
+ GlFormInput,
+ GlFormTextarea,
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlSprintf,
+ },
+ mixins: [glFeatureFlagsMixin(), trackingMixin],
+ data() {
+ return {
+ isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ validationErrorEventProperty: '',
+ };
+ },
+ computed: {
+ ...mapState([
+ 'projectId',
+ 'environments',
+ 'typeOptions',
+ 'variable',
+ 'variableBeingEdited',
+ 'isGroup',
+ 'maskableRegex',
+ 'selectedEnvironment',
+ 'isProtectedByDefault',
+ 'awsLogoSvgPath',
+ 'awsTipDeployLink',
+ 'awsTipCommandsLink',
+ 'awsTipLearnLink',
+ 'containsVariableReferenceLink',
+ 'protectedEnvironmentVariablesLink',
+ 'maskedEnvironmentVariablesLink',
+ 'environmentScopeLink',
+ ]),
+ ...mapComputed(
+ [
+ { key: 'key', updateFn: 'updateVariableKey' },
+ { key: 'secret_value', updateFn: 'updateVariableValue' },
+ { key: 'variable_type', updateFn: 'updateVariableType' },
+ { key: 'environment_scope', updateFn: 'setEnvironmentScope' },
+ { key: 'protected_variable', updateFn: 'updateVariableProtected' },
+ { key: 'masked', updateFn: 'updateVariableMasked' },
+ ],
+ false,
+ 'variable',
+ ),
+ isTipVisible() {
+ return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
+ },
+ canSubmit() {
+ return (
+ this.variableValidationState &&
+ this.variable.key !== '' &&
+ this.variable.secret_value !== ''
+ );
+ },
+ canMask() {
+ const regex = RegExp(this.maskableRegex);
+ return regex.test(this.variable.secret_value);
+ },
+ containsVariableReference() {
+ const regex = /\$/;
+ return regex.test(this.variable.secret_value);
+ },
+ displayMaskedError() {
+ return !this.canMask && this.variable.masked;
+ },
+ maskedState() {
+ if (this.displayMaskedError) {
+ return false;
+ }
+ return true;
+ },
+ modalActionText() {
+ return this.variableBeingEdited ? __('Update variable') : __('Add variable');
+ },
+ maskedFeedback() {
+ return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ },
+ tokenValidationFeedback() {
+ const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage;
+ if (!this.tokenValidationState && tokenSpecificFeedback) {
+ return tokenSpecificFeedback;
+ }
+ return '';
+ },
+ tokenValidationState() {
+ const validator = this.$options.tokens?.[this.variable.key]?.validation;
+
+ if (validator) {
+ return validator(this.variable.secret_value);
+ }
+
+ return true;
+ },
+ scopedVariablesAvailable() {
+ return !this.isGroup || this.glFeatures.groupScopedCiVariables;
+ },
+ variableValidationFeedback() {
+ return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
+ },
+ variableValidationState() {
+ return this.variable.secret_value === '' || (this.tokenValidationState && this.maskedState);
+ },
+ },
+ watch: {
+ variable: {
+ handler() {
+ this.trackVariableValidationErrors();
+ },
+ deep: true,
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'addVariable',
+ 'updateVariable',
+ 'resetEditing',
+ 'displayInputValue',
+ 'clearModal',
+ 'deleteVariable',
+ 'setEnvironmentScope',
+ 'addWildCardScope',
+ 'resetSelectedEnvironment',
+ 'setSelectedEnvironment',
+ 'setVariableProtected',
+ ]),
+ dismissTip() {
+ setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
+ this.isTipDismissed = true;
+ },
+ deleteVarAndClose() {
+ this.deleteVariable();
+ this.hideModal();
+ },
+ hideModal() {
+ this.$refs.modal.hide();
+ },
+ resetModalHandler() {
+ if (this.variableBeingEdited) {
+ this.resetEditing();
+ }
+
+ this.clearModal();
+ this.resetSelectedEnvironment();
+ this.resetValidationErrorEvents();
+ },
+ updateOrAddVariable() {
+ if (this.variableBeingEdited) {
+ this.updateVariable();
+ } else {
+ this.addVariable();
+ }
+ this.hideModal();
+ },
+ setVariableProtectedByDefault() {
+ if (this.isProtectedByDefault && !this.variableBeingEdited) {
+ this.setVariableProtected();
+ }
+ },
+ trackVariableValidationErrors() {
+ const property = this.getTrackingErrorProperty();
+ if (!this.validationErrorEventProperty && property) {
+ this.track(EVENT_ACTION, { property });
+ this.validationErrorEventProperty = property;
+ }
+ },
+ getTrackingErrorProperty() {
+ let property;
+ if (this.variable.secret_value?.length && !property) {
+ if (this.displayMaskedError && this.maskableRegex?.length) {
+ const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, '');
+ const regex = new RegExp(supportedChars, 'g');
+ property = this.variable.secret_value.replace(regex, '');
+ }
+ if (this.containsVariableReference) {
+ property = '$';
+ }
+ }
+
+ return property;
+ },
+ resetValidationErrorEvents() {
+ this.validationErrorEventProperty = '';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ :title="modalActionText"
+ static
+ lazy
+ @hidden="resetModalHandler"
+ @shown="setVariableProtectedByDefault"
+ >
+ <form>
+ <gl-form-combobox
+ v-model="key"
+ :token-list="$options.tokenList"
+ :label-text="__('Key')"
+ data-qa-selector="ci_variable_key_field"
+ />
+
+ <gl-form-group
+ :label="__('Value')"
+ label-for="ci-variable-value"
+ :state="variableValidationState"
+ :invalid-feedback="variableValidationFeedback"
+ >
+ <gl-form-textarea
+ id="ci-variable-value"
+ ref="valueField"
+ v-model="secret_value"
+ :state="variableValidationState"
+ rows="3"
+ max-rows="6"
+ data-qa-selector="ci_variable_value_field"
+ class="gl-font-monospace!"
+ />
+ </gl-form-group>
+
+ <div class="d-flex">
+ <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
+ <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" />
+ </gl-form-group>
+
+ <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope">
+ <template #label>
+ {{ __('Environment scope') }}
+ <gl-link
+ :title="$options.environmentScopeLinkTitle"
+ :href="environmentScopeLink"
+ target="_blank"
+ data-testid="environment-scope-link"
+ >
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ </template>
+ <ci-environments-dropdown
+ v-if="scopedVariablesAvailable"
+ class="w-100"
+ :value="environment_scope"
+ @selectEnvironment="setEnvironmentScope"
+ @createClicked="addWildCardScope"
+ />
+
+ <gl-form-input v-else v-model="environment_scope" class="w-100" readonly />
+ </gl-form-group>
+ </div>
+
+ <gl-form-group :label="__('Flags')" label-for="ci-variable-flags">
+ <gl-form-checkbox
+ v-model="protected_variable"
+ class="mb-0"
+ data-testid="ci-variable-protected-checkbox"
+ >
+ {{ __('Protect variable') }}
+ <gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ <p class="gl-mt-2 text-secondary">
+ {{ __('Export variable to pipelines running on protected branches and tags only.') }}
+ </p>
+ </gl-form-checkbox>
+
+ <gl-form-checkbox
+ ref="masked-ci-variable"
+ v-model="masked"
+ data-testid="ci-variable-masked-checkbox"
+ >
+ {{ __('Mask variable') }}
+ <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ <p class="gl-mt-2 gl-mb-0 text-secondary">
+ {{ __('Variable will be masked in job logs.') }}
+ <span
+ :class="{
+ 'bold text-plain': displayMaskedError,
+ }"
+ >
+ {{ __('Requires values to meet regular expression requirements.') }}</span
+ >
+ <gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{
+ __('More information')
+ }}</gl-link>
+ </p>
+ </gl-form-checkbox>
+ </gl-form-group>
+ </form>
+ <gl-collapse :visible="isTipVisible">
+ <gl-alert
+ :title="__('Deploying to AWS is easy with GitLab')"
+ variant="tip"
+ data-testid="aws-guidance-tip"
+ @dismiss="dismissTip"
+ >
+ <div class="gl-display-flex gl-flex-direction-row">
+ <div>
+ <p>
+ <gl-sprintf :message="$options.awsTipMessage">
+ <template #deployLink="{ content }">
+ <gl-link :href="awsTipDeployLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #commandsLink="{ content }">
+ <gl-link :href="awsTipCommandsLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-button
+ :href="awsTipLearnLink"
+ target="_blank"
+ category="secondary"
+ variant="info"
+ class="gl-overflow-wrap-break"
+ >{{ __('Learn more about deploying to AWS') }}</gl-button
+ >
+ </p>
+ </div>
+ <img
+ class="gl-mt-3"
+ :alt="__('Amazon Web Services Logo')"
+ :src="awsLogoSvgPath"
+ height="32"
+ />
+ </div>
+ </gl-alert>
+ </gl-collapse>
+ <gl-alert
+ v-if="containsVariableReference"
+ :title="__('Value might contain a variable reference')"
+ :dismissible="false"
+ variant="warning"
+ data-testid="contains-variable-reference"
+ >
+ <gl-sprintf :message="$options.containsVariableReferenceMessage">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ <template #docsLink="{ content }">
+ <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <template #modal-footer>
+ <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ v-if="variableBeingEdited"
+ ref="deleteCiVariable"
+ variant="danger"
+ category="secondary"
+ data-qa-selector="ci_variable_delete_button"
+ @click="deleteVarAndClose"
+ >{{ __('Delete variable') }}</gl-button
+ >
+ <gl-button
+ ref="updateOrAddVariable"
+ :disabled="!canSubmit"
+ variant="confirm"
+ category="primary"
+ data-testid="ciUpdateOrAddVariableBtn"
+ data-qa-selector="ci_variable_save_button"
+ @click="updateOrAddVariable"
+ >{{ modalActionText }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue
new file mode 100644
index 00000000000..9acc9fbffb6
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_settings.vue
@@ -0,0 +1,32 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import LegacyCiVariableModal from './legacy_ci_variable_modal.vue';
+import LegacyCiVariableTable from './legacy_ci_variable_table.vue';
+
+export default {
+ components: {
+ LegacyCiVariableModal,
+ LegacyCiVariableTable,
+ },
+ computed: {
+ ...mapState(['isGroup']),
+ },
+ mounted() {
+ if (!this.isGroup) {
+ this.fetchEnvironments();
+ }
+ },
+ methods: {
+ ...mapActions(['fetchEnvironments']),
+ },
+};
+</script>
+
+<template>
+ <div class="row">
+ <div class="col-lg-12">
+ <legacy-ci-variable-table />
+ <legacy-ci-variable-modal />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue
new file mode 100644
index 00000000000..f078234829a
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_table.vue
@@ -0,0 +1,199 @@
+<script>
+import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { s__, __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
+import CiVariablePopover from './ci_variable_popover.vue';
+
+export default {
+ modalId: ADD_CI_VARIABLE_MODAL_ID,
+ trueIcon: 'mobile-issue-close',
+ falseIcon: 'close',
+ iconSize: 16,
+ fields: [
+ {
+ key: 'variable_type',
+ label: s__('CiVariables|Type'),
+ customStyle: { width: '70px' },
+ },
+ {
+ key: 'key',
+ label: s__('CiVariables|Key'),
+ tdClass: 'text-plain',
+ sortable: true,
+ customStyle: { width: '40%' },
+ },
+ {
+ key: 'value',
+ label: s__('CiVariables|Value'),
+ customStyle: { width: '40%' },
+ },
+ {
+ key: 'protected',
+ label: s__('CiVariables|Protected'),
+ customStyle: { width: '100px' },
+ },
+ {
+ key: 'masked',
+ label: s__('CiVariables|Masked'),
+ customStyle: { width: '100px' },
+ },
+ {
+ key: 'environment_scope',
+ label: s__('CiVariables|Environments'),
+ customStyle: { width: '20%' },
+ },
+ {
+ key: 'actions',
+ label: '',
+ tdClass: 'text-right',
+ customStyle: { width: '35px' },
+ },
+ ],
+ components: {
+ CiVariablePopover,
+ GlButton,
+ GlIcon,
+ GlTable,
+ TooltipOnTruncate,
+ },
+ directives: {
+ GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ computed: {
+ ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']),
+ valuesButtonText() {
+ return this.valuesHidden ? __('Reveal values') : __('Hide values');
+ },
+ isTableEmpty() {
+ return !this.variables || this.variables.length === 0;
+ },
+ fields() {
+ return this.$options.fields;
+ },
+ },
+ mounted() {
+ this.fetchVariables();
+ },
+ methods: {
+ ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']),
+ },
+};
+</script>
+
+<template>
+ <div class="ci-variable-table" data-testid="ci-variable-table">
+ <gl-table
+ :fields="fields"
+ :items="variables"
+ tbody-tr-class="js-ci-variable-row"
+ data-qa-selector="ci_variable_table_content"
+ sort-by="key"
+ sort-direction="asc"
+ stacked="lg"
+ table-class="text-secondary"
+ fixed
+ show-empty
+ sort-icon-left
+ no-sort-reset
+ >
+ <template #table-colgroup="scope">
+ <col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
+ </template>
+ <template #cell(key)="{ item }">
+ <div class="gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="item.key" truncate-target="child">
+ <span
+ :id="`ci-variable-key-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.key }}</span
+ >
+ </tooltip-on-truncate>
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ :title="__('Copy key')"
+ :data-clipboard-text="item.key"
+ :aria-label="__('Copy to clipboard')"
+ />
+ </div>
+ </template>
+ <template #cell(value)="{ item }">
+ <div class="gl-display-flex gl-align-items-center">
+ <span v-if="valuesHidden">*********************</span>
+ <span
+ v-else
+ :id="`ci-variable-value-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.value }}</span
+ >
+ <gl-button
+ v-gl-tooltip
+ category="tertiary"
+ icon="copy-to-clipboard"
+ :title="__('Copy value')"
+ :data-clipboard-text="item.value"
+ :aria-label="__('Copy to clipboard')"
+ />
+ </div>
+ </template>
+ <template #cell(protected)="{ item }">
+ <gl-icon v-if="item.protected" :size="$options.iconSize" :name="$options.trueIcon" />
+ <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
+ </template>
+ <template #cell(masked)="{ item }">
+ <gl-icon v-if="item.masked" :size="$options.iconSize" :name="$options.trueIcon" />
+ <gl-icon v-else :size="$options.iconSize" :name="$options.falseIcon" />
+ </template>
+ <template #cell(environment_scope)="{ item }">
+ <div class="gl-display-flex">
+ <span
+ :id="`ci-variable-env-${item.id}`"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ >{{ item.environment_scope }}</span
+ >
+ <ci-variable-popover
+ :target="`ci-variable-env-${item.id}`"
+ :value="item.environment_scope"
+ :tooltip-text="__('Copy environment')"
+ />
+ </div>
+ </template>
+ <template #cell(actions)="{ item }">
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ icon="pencil"
+ :aria-label="__('Edit')"
+ data-qa-selector="edit_ci_variable_button"
+ @click="editVariable(item)"
+ />
+ </template>
+ <template #empty>
+ <p class="gl-text-center gl-py-6 gl-text-black-normal gl-mb-0">
+ {{ __('There are no variables yet.') }}
+ </p>
+ </template>
+ </gl-table>
+ <div class="ci-variable-actions gl-display-flex gl-mt-5">
+ <gl-button
+ 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="!isTableEmpty"
+ data-qa-selector="reveal_ci_variable_value_button"
+ @click="toggleValues(!valuesHidden)"
+ >{{ valuesButtonText }}</gl-button
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index f771751194c..2b54af6a2a4 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -1,10 +1,63 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import CiVariableSettings from './components/ci_variable_settings.vue';
+import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
import createStore from './store';
const mountCiVariableListApp = (containerEl) => {
const {
+ awsLogoSvgPath,
+ awsTipCommandsLink,
+ awsTipDeployLink,
+ awsTipLearnLink,
+ containsVariableReferenceLink,
+ environmentScopeLink,
+ group,
+ maskedEnvironmentVariablesLink,
+ maskableRegex,
+ projectFullPath,
+ projectId,
+ protectedByDefault,
+ protectedEnvironmentVariablesLink,
+ } = containerEl.dataset;
+
+ const isGroup = parseBoolean(group);
+ const isProtectedByDefault = parseBoolean(protectedByDefault);
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el: containerEl,
+ apolloProvider,
+ provide: {
+ awsLogoSvgPath,
+ awsTipCommandsLink,
+ awsTipDeployLink,
+ awsTipLearnLink,
+ containsVariableReferenceLink,
+ environmentScopeLink,
+ isGroup,
+ isProtectedByDefault,
+ maskedEnvironmentVariablesLink,
+ maskableRegex,
+ projectFullPath,
+ projectId,
+ protectedEnvironmentVariablesLink,
+ },
+ render(createElement) {
+ return createElement(CiVariableSettings);
+ },
+ });
+};
+
+const mountLegacyCiVariableListApp = (containerEl) => {
+ const {
endpoint,
projectId,
group,
@@ -42,7 +95,7 @@ const mountCiVariableListApp = (containerEl) => {
el: containerEl,
store,
render(createElement) {
- return createElement(CiVariableSettings);
+ return createElement(LegacyCiVariableSettings);
},
});
};
@@ -50,5 +103,11 @@ const mountCiVariableListApp = (containerEl) => {
export default (containerId = 'js-ci-project-variables') => {
const el = document.getElementById(containerId);
- return !el ? {} : mountCiVariableListApp(el);
+ if (el) {
+ if (gon.features?.ciVariableSettingsGraphql) {
+ mountCiVariableListApp(el);
+ } else {
+ mountLegacyCiVariableListApp(el);
+ }
+ }
};
diff --git a/app/assets/javascripts/clusters/agents/components/create_token_button.vue b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
index 3e1a8994fb8..74155d7819a 100644
--- a/app/assets/javascripts/clusters/agents/components/create_token_button.vue
+++ b/app/assets/javascripts/clusters/agents/components/create_token_button.vue
@@ -205,7 +205,12 @@ export default {
</gl-form-group>
</template>
- <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" />
+ <agent-token
+ v-else
+ :agent-name="agentName"
+ :agent-token="agentToken"
+ :modal-id="$options.modalId"
+ />
<template #modal-footer>
<gl-button
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index dca89133931..8a997624a36 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -2,29 +2,9 @@
import { GlModal, GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { s__ } from '~/locale';
-import SplitButton from '~/vue_shared/components/split_button.vue';
-
-const splitButtonActionItems = [
- {
- title: s__('ClusterIntegration|Remove integration and resources'),
- description: s__(
- 'ClusterIntegration|Deletes all GitLab resources attached to this cluster during removal',
- ),
- eventName: 'remove-cluster-and-cleanup',
- },
- {
- title: s__('ClusterIntegration|Remove integration'),
- description: s__(
- 'ClusterIntegration|Removes cluster from project but keeps associated resources',
- ),
- eventName: 'remove-cluster',
- },
-];
export default {
- splitButtonActionItems,
components: {
- SplitButton,
GlModal,
GlButton,
GlFormInput,
@@ -79,6 +59,9 @@ export default {
canCleanupResources() {
return !this.hasManagementProject;
},
+ buttonCategory() {
+ return !this.hasManagementProject ? 'secondary' : 'primary';
+ },
},
methods: {
handleClickRemoveCluster(cleanup = false) {
@@ -99,19 +82,20 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-justify-content-end">
- <split-button
+ <div class="gl-display-flex">
+ <gl-button
v-if="canCleanupResources"
- :action-items="$options.splitButtonActionItems"
- menu-class="dropdown-menu-large"
+ data-testid="remove-integration-and-resources-button"
+ class="gl-mr-3"
variant="danger"
- @remove-cluster="handleClickRemoveCluster(false)"
- @remove-cluster-and-cleanup="handleClickRemoveCluster(true)"
- />
+ @click="handleClickRemoveCluster(true)"
+ >
+ {{ s__('ClusterIntegration|Remove integration and resources') }}
+ </gl-button>
<gl-button
- v-else
+ data-testid="remove-integration-button"
+ :category="buttonCategory"
variant="danger"
- data-testid="btnRemove"
@click="handleClickRemoveCluster(false)"
>
{{ s__('ClusterIntegration|Remove integration') }}
@@ -163,13 +147,7 @@ export default {
<template v-if="confirmCleanup">
<gl-button
:disabled="!canSubmit"
- variant="warning"
- category="primary"
- @click="handleSubmit"
- >{{ s__('ClusterIntegration|Remove integration') }}</gl-button
- >
- <gl-button
- :disabled="!canSubmit"
+ data-testid="remove-integration-and-resources-modal-button"
variant="danger"
category="primary"
@click="handleSubmit(true)"
@@ -179,6 +157,7 @@ export default {
<template v-else>
<gl-button
:disabled="!canSubmit"
+ data-testid="remove-integration-modal-button"
variant="danger"
category="primary"
@click="handleSubmit"
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
index 9eb01f593f5..e2d01723dde 100644
--- a/app/assets/javascripts/clusters_list/clusters_util.js
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -1,12 +1,12 @@
-export function generateAgentRegistrationCommand(agentToken, kasAddress, kasVersion) {
+export function generateAgentRegistrationCommand({ name, token, version, address }) {
return `helm repo add gitlab https://charts.gitlab.io
helm repo update
-helm upgrade --install gitlab-agent gitlab/gitlab-agent \\
+helm upgrade --install ${name} gitlab/gitlab-agent \\
--namespace gitlab-agent \\
--create-namespace \\
- --set image.tag=v${kasVersion} \\
- --set config.token=${agentToken} \\
- --set config.kasAddress=${kasAddress}`;
+ --set image.tag=v${version} \\
+ --set config.token=${token} \\
+ --set config.kasAddress=${address}`;
}
export function getAgentConfigPath(clusterAgentName) {
diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue
index 1597fcb9914..4dd6d84566c 100644
--- a/app/assets/javascripts/clusters_list/components/agent_token.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_token.vue
@@ -21,6 +21,10 @@ export default {
},
inject: ['kasAddress', 'kasVersion'],
props: {
+ agentName: {
+ required: true,
+ type: String,
+ },
agentToken: {
required: true,
type: String,
@@ -32,7 +36,12 @@ export default {
},
computed: {
agentRegistrationCommand() {
- return generateAgentRegistrationCommand(this.agentToken, this.kasAddress, this.kasVersion);
+ return generateAgentRegistrationCommand({
+ name: this.agentName,
+ token: this.agentToken,
+ version: this.kasVersion,
+ address: this.kasAddress,
+ });
},
},
};
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index fb3c8ff66b0..1ea5eff35d4 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -4,7 +4,7 @@ import {
GlLink,
GlLoadingIcon,
GlPagination,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlSkeletonLoader,
GlSprintf,
GlTableLite,
GlTooltipDirective,
@@ -25,7 +25,7 @@ export default {
GlLink,
GlLoadingIcon,
GlPagination,
- GlSkeletonLoading,
+ GlSkeletonLoader,
GlSprintf,
GlTableLite,
NodeErrorHelpText,
@@ -267,7 +267,7 @@ export default {
<template #cell(node_size)="{ item }">
<span v-if="item.nodes">{{ item.nodes.length }}</span>
- <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+ <gl-skeleton-loader v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<node-error-help-text
v-else-if="item.kubernetes_errors"
@@ -288,7 +288,7 @@ export default {
</gl-sprintf>
</span>
- <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+ <gl-skeleton-loader v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<node-error-help-text
v-else-if="item.kubernetes_errors"
@@ -309,7 +309,7 @@ export default {
</gl-sprintf>
</span>
- <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
+ <gl-skeleton-loader v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<node-error-help-text
v-else-if="item.kubernetes_errors"
diff --git a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
index 3b39c3aac45..444b9ac2a14 100644
--- a/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
+++ b/app/assets/javascripts/clusters_list/components/install_agent_modal.vue
@@ -268,7 +268,12 @@ export default {
</p>
</template>
- <agent-token v-else :agent-token="agentToken" :modal-id="$options.modalId" />
+ <agent-token
+ v-else
+ :agent-name="agentName"
+ :agent-token="agentToken"
+ :modal-id="$options.modalId"
+ />
</template>
<template v-else>
diff --git a/app/assets/javascripts/code_navigation/utils/index.js b/app/assets/javascripts/code_navigation/utils/index.js
index 0d72153d8fe..46038df2f86 100644
--- a/app/assets/javascripts/code_navigation/utils/index.js
+++ b/app/assets/javascripts/code_navigation/utils/index.js
@@ -32,8 +32,8 @@ export const addInteractionClass = ({ path, d, wrapTextNodes }) => {
});
if (el && !isTextNode(el)) {
- el.setAttribute('data-char-index', d.start_char);
- el.setAttribute('data-line-index', d.start_line);
+ el.dataset.charIndex = d.start_char;
+ el.dataset.lineIndex = d.start_line;
el.classList.add('cursor-pointer', 'code-navigation', 'js-code-navigation');
el.closest('.line').classList.add('code-navigation-line');
}
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 29530ddb7a2..4ff49433749 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -9,6 +9,7 @@ import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { s__, __ } from '~/locale';
export default {
PipelineKeyOptions,
@@ -170,6 +171,20 @@ export default {
}
},
},
+ modal: {
+ actionPrimary: {
+ text: s__('Pipeline|Run pipeline'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
};
</script>
<template>
@@ -229,9 +244,9 @@ export default {
ref="modal"
:modal-id="modalId"
:title="s__('Pipelines|Are you sure you want to run this pipeline?')"
- :ok-title="s__('Pipeline|Run pipeline')"
- ok-variant="danger"
- @ok="onClickRunPipeline"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="onClickRunPipeline"
>
<p>
{{
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
index 518ddd7a09c..6c0ac8e54d2 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
@@ -1,5 +1,8 @@
<script>
import {
+ GlDropdownForm,
+ GlFormInput,
+ GlDropdownDivider,
GlButton,
GlButtonGroup,
GlDropdown,
@@ -20,23 +23,32 @@ const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatte
export default {
components: {
BubbleMenu,
+ GlDropdownForm,
+ GlFormInput,
GlButton,
GlButtonGroup,
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
GlSearchBoxByType,
EditorStateObserver,
},
directives: {
GlTooltip,
},
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'contentEditor'],
data() {
return {
codeBlockType: undefined,
- selectedLanguage: {},
filterTerm: '',
filteredLanguages: [],
+
+ showCustomLanguageInput: false,
+ customLanguageType: '',
+
+ selectedLanguage: {},
+ isDiagram: false,
+ showPreview: false,
};
},
watch: {
@@ -52,24 +64,39 @@ export default {
return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type));
},
- updateSelectedLanguage() {
+ async updateCodeBlockInfoToState() {
this.codeBlockType = CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type));
- if (this.codeBlockType) {
- const { language } = this.tiptapEditor.getAttributes(this.codeBlockType);
- this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language);
- }
+ if (!this.codeBlockType) return;
+
+ const { language, isDiagram, showPreview } = this.tiptapEditor.getAttributes(
+ this.codeBlockType,
+ );
+ this.selectedLanguage = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(
+ language,
+ isDiagram,
+ );
+ this.isDiagram = isDiagram;
+ this.showPreview = showPreview;
},
- copyCodeBlockText() {
+ getCodeBlockText() {
const { view } = this.tiptapEditor;
const { from } = this.tiptapEditor.state.selection;
const node = getParentByTagName(view.domAtPos(from).node, 'pre');
+ return node?.textContent || '';
+ },
- navigator.clipboard.writeText(node?.textContent || '');
+ copyCodeBlockText() {
+ navigator.clipboard.writeText(this.getCodeBlockText());
},
- async applySelectedLanguage(language) {
+ togglePreview() {
+ this.showPreview = !this.showPreview;
+ this.tiptapEditor.commands.updateAttributes(Diagram.name, { showPreview: this.showPreview });
+ },
+
+ async applyLanguage(language) {
this.selectedLanguage = language;
await codeBlockLanguageLoader.loadLanguage(language.syntax);
@@ -77,6 +104,21 @@ export default {
this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax });
},
+ clearCustomLanguageForm() {
+ this.showCustomLanguageInput = false;
+ this.customLanguageType = '';
+ },
+
+ applyCustomLanguage() {
+ this.showCustomLanguageInput = false;
+
+ const language = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(
+ this.customLanguageType,
+ );
+
+ this.applyLanguage(language);
+ },
+
getReferenceClientRect() {
const { view } = this.tiptapEditor;
const { from } = this.tiptapEditor.state.selection;
@@ -101,15 +143,36 @@ export default {
getReferenceClientRect,
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
>
- <editor-state-observer @transaction="updateSelectedLanguage">
+ <editor-state-observer @transaction="updateCodeBlockInfoToState">
<gl-button-group>
<gl-dropdown
category="tertiary"
contenteditable="false"
boundary="viewport"
:text="selectedLanguage.label"
+ @hide="clearCustomLanguageForm"
>
- <template #header>
+ <template v-if="showCustomLanguageInput" #header>
+ <div class="gl-relative">
+ <gl-button
+ v-gl-tooltip
+ class="gl-absolute gl-mt-n3 gl-ml-2"
+ variant="default"
+ category="tertiary"
+ size="medium"
+ :aria-label="__('Go back')"
+ :title="__('Go back')"
+ icon="arrow-left"
+ @click.prevent.stop="showCustomLanguageInput = false"
+ />
+ <p
+ class="gl-text-center gl-new-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!"
+ >
+ {{ __('Create custom type') }}
+ </p>
+ </div>
+ </template>
+ <template v-else #header>
<gl-search-box-by-type
v-model="filterTerm"
:clear-button-title="__('Clear')"
@@ -117,20 +180,59 @@ export default {
/>
</template>
- <template #highlighted-items>
+ <template v-if="!showCustomLanguageInput" #highlighted-items>
<gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
{{ selectedLanguage.label }}
</gl-dropdown-item>
</template>
- <gl-dropdown-item
- v-for="language in filteredLanguages"
- v-show="selectedLanguage.syntax !== language.syntax"
- :key="language.syntax"
- @click="applySelectedLanguage(language)"
- >
- {{ language.label }}
- </gl-dropdown-item>
+ <template v-if="!showCustomLanguageInput" #default>
+ <gl-dropdown-item
+ v-for="language in filteredLanguages"
+ v-show="selectedLanguage.syntax !== language.syntax"
+ :key="language.syntax"
+ @click="applyLanguage(language)"
+ >
+ {{ language.label }}
+ </gl-dropdown-item>
+ </template>
+ <template v-else #default>
+ <gl-dropdown-form @submit.prevent="applyCustomLanguage">
+ <div class="gl-mx-4 gl-mt-2 gl-mb-3">
+ <gl-form-input v-model="customLanguageType" :placeholder="__('Language type')" />
+ </div>
+ <gl-dropdown-divider />
+ <div class="gl-mx-4 gl-mt-3 gl-display-flex gl-justify-content-end">
+ <gl-button
+ variant="default"
+ size="medium"
+ category="primary"
+ class="gl-mr-2 gl-w-auto!"
+ @click.prevent.stop="showCustomLanguageInput = false"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ variant="confirm"
+ size="medium"
+ category="primary"
+ type="submit"
+ class="gl-w-auto!"
+ >
+ {{ __('Apply') }}
+ </gl-button>
+ </div>
+ </gl-dropdown-form>
+ </template>
+
+ <template v-if="!showCustomLanguageInput" #footer>
+ <gl-dropdown-item
+ data-testid="create-custom-type"
+ @click.capture.native.stop="showCustomLanguageInput = true"
+ >
+ {{ __('Create custom type') }}
+ </gl-dropdown-item>
+ </template>
</gl-dropdown>
<gl-button
v-gl-tooltip
@@ -144,6 +246,19 @@ export default {
@click="copyCodeBlockText"
/>
<gl-button
+ v-if="isDiagram"
+ v-gl-tooltip
+ variant="default"
+ category="tertiary"
+ size="medium"
+ :class="{ active: showPreview }"
+ data-testid="preview-diagram"
+ :aria-label="__('Preview diagram')"
+ :title="__('Preview diagram')"
+ icon="eye"
+ @click="togglePreview"
+ />
+ <gl-button
v-gl-tooltip
variant="default"
category="tertiary"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
new file mode 100644
index 00000000000..ecde593147c
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ directives: {
+ GlTooltip,
+ },
+ inject: ['tiptapEditor'],
+ methods: {
+ execute(contentType, attrs) {
+ this.tiptapEditor.chain().focus().setNode(contentType, attrs).run();
+
+ this.$emit('execute', { contentType });
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown size="small" category="tertiary" icon="plus">
+ <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })">
+ {{ __('Mermaid diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })">
+ {{ __('PlantUML diagram') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="execute('horizontalRule')">
+ {{ __('Horizontal rule') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 19e150a4da9..b652e634b0c 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -5,6 +5,7 @@ import ToolbarImageButton from './toolbar_image_button.vue';
import ToolbarLinkButton from './toolbar_link_button.vue';
import ToolbarTableButton from './toolbar_table_button.vue';
import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue';
+import ToolbarMoreDropdown from './toolbar_more_dropdown.vue';
export default {
components: {
@@ -13,6 +14,7 @@ export default {
ToolbarLinkButton,
ToolbarTableButton,
ToolbarImageButton,
+ ToolbarMoreDropdown,
},
methods: {
trackToolbarControlExecution({ contentType, value }) {
@@ -117,16 +119,8 @@ export default {
:label="__('Add a collapsible section')"
@execute="trackToolbarControlExecution"
/>
- <toolbar-button
- data-testid="horizontal-rule"
- content-type="horizontalRule"
- icon-name="dash"
- class="gl-mx-2"
- editor-command="setHorizontalRule"
- :label="__('Add a horizontal rule')"
- @execute="trackToolbarControlExecution"
- />
- <toolbar-table-button @execute="trackToolbarControlExecution" />
+ <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" />
+ <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" />
</div>
</template>
<style>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
index 1390b9b2daf..81f9b1f0af5 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue
@@ -1,15 +1,26 @@
<script>
+import { debounce } from 'lodash';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
import { __ } from '~/locale';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue';
import codeBlockLanguageLoader from '../../services/code_block_language_loader';
+import EditorStateObserver from '../editor_state_observer.vue';
export default {
name: 'CodeBlock',
components: {
NodeViewWrapper,
NodeViewContent,
+ EditorStateObserver,
+ SandboxedMermaid,
},
+ inject: ['contentEditor'],
props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
node: {
type: Object,
required: true,
@@ -18,27 +29,75 @@ export default {
type: Function,
required: true,
},
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ diagramUrl: '',
+ diagramSource: '',
+ };
},
async mounted() {
- const lang = codeBlockLanguageLoader.findLanguageBySyntax(this.node.attrs.language);
+ this.updateDiagramPreview = debounce(
+ this.updateDiagramPreview,
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
+
+ const lang = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(this.node.attrs.language);
await codeBlockLanguageLoader.loadLanguage(lang.syntax);
this.updateAttributes({ language: this.node.attrs.language });
},
+ methods: {
+ async updateDiagramPreview() {
+ if (!this.node.attrs.showPreview) {
+ this.diagramSource = '';
+ return;
+ }
+
+ if (!this.editor.isActive('diagram')) return;
+
+ this.diagramSource = this.$refs.nodeViewContent.$el.textContent;
+
+ if (this.node.attrs.language !== 'mermaid') {
+ this.diagramUrl = await this.contentEditor.renderDiagram(
+ this.diagramSource,
+ this.node.attrs.language,
+ );
+ }
+ },
+ },
i18n: {
frontmatter: __('frontmatter'),
},
+ userColorScheme: gon.user_color_scheme,
};
</script>
<template>
- <node-view-wrapper class="content-editor-code-block gl-relative code highlight" as="pre">
- <span
- v-if="node.attrs.isFrontmatter"
- data-testid="frontmatter-label"
- class="gl-absolute gl-top-0 gl-right-3"
- contenteditable="false"
- >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ <editor-state-observer @transaction="updateDiagramPreview">
+ <node-view-wrapper
+ :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`"
+ as="pre"
>
- <node-view-content as="code" />
- </node-view-wrapper>
+ <div
+ v-if="node.attrs.showPreview"
+ class="gl-mt-n3! gl-ml-n4! gl-mr-n4! gl-mb-3 gl-bg-white! gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ >
+ <sandboxed-mermaid v-if="node.attrs.language === 'mermaid'" :source="diagramSource" />
+ <img v-else ref="diagramContainer" :src="diagramUrl" />
+ </div>
+ <span
+ v-if="node.attrs.isFrontmatter"
+ data-testid="frontmatter-label"
+ class="gl-absolute gl-top-0 gl-right-3"
+ contenteditable="false"
+ >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span
+ >
+ <node-view-content ref="nodeViewContent" as="code" />
+ </node-view-wrapper>
+ </editor-state-observer>
</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
new file mode 100644
index 00000000000..8b7b02605f7
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue
@@ -0,0 +1,28 @@
+<script>
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+
+export default {
+ name: 'FootnoteDefinitionWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ },
+ props: {
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper class="gl-display-flex gl-font-sm" as="div">
+ <span
+ data-testid="footnote-label"
+ contenteditable="false"
+ class="gl-display-inline-flex gl-mr-2"
+ >{{ node.attrs.label }}:</span
+ >
+ <node-view-content />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
index 209e4629830..c0d6e32a739 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -101,6 +101,9 @@ export default {
deleteTable: __('Delete table'),
editTableActions: __('Edit table'),
},
+ dropdownPopperOpts: {
+ positionFixed: true,
+ },
};
</script>
<template>
@@ -124,9 +127,7 @@ export default {
no-caret
text-sr-only
:text="$options.i18n.editTableActions"
- :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- positionFixed: true,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :popper-opts="$options.dropdownPopperOpts"
@hide="handleHide($event)"
>
<gl-dropdown-item @click="runCommand('addColumnBefore')">
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 cc4ba84a29d..61f6a233694 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -4,7 +4,8 @@ import { VueNodeViewRenderer } from '@tiptap/vue-2';
import languageLoader from '../services/code_block_language_loader';
import CodeBlockWrapper from '../components/wrappers/code_block.vue';
-const extractLanguage = (element) => element.getAttribute('lang');
+const extractLanguage = (element) => element.dataset.canonicalLang ?? element.getAttribute('lang');
+
export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js
index f9dfeb92e9a..c59ca8a28b8 100644
--- a/app/assets/javascripts/content_editor/extensions/diagram.js
+++ b/app/assets/javascripts/content_editor/extensions/diagram.js
@@ -1,6 +1,10 @@
+import { textblockTypeInputRule } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import languageLoader from '../services/code_block_language_loader';
import CodeBlockHighlight from './code_block_highlight';
+const backtickInputRegex = /^```(mermaid|plantuml)[\s\n]$/;
+
export default CodeBlockHighlight.extend({
name: 'diagram',
@@ -17,6 +21,9 @@ export default CodeBlockHighlight.extend({
isDiagram: {
default: true,
},
+ showPreview: {
+ default: true,
+ },
};
},
@@ -24,6 +31,11 @@ export default CodeBlockHighlight.extend({
return [
{
priority: PARSE_HTML_PRIORITY_HIGHEST,
+ tag: 'pre[lang="mermaid"]',
+ getAttrs: () => ({ language: 'mermaid' }),
+ },
+ {
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
tag: '[data-diagram]',
getContent(element, schema) {
const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', ''));
@@ -54,6 +66,14 @@ export default CodeBlockHighlight.extend({
},
addInputRules() {
- return [];
+ const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {};
+
+ return [
+ textblockTypeInputRule({
+ find: backtickInputRegex,
+ type: this.type,
+ getAttributes,
+ }),
+ ];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js
index dbab0de3421..bf752918934 100644
--- a/app/assets/javascripts/content_editor/extensions/footnote_definition.js
+++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js
@@ -1,12 +1,27 @@
import { mergeAttributes, Node } from '@tiptap/core';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import FootnoteDefinitionWrapper from '../components/wrappers/footnote_definition.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+const extractFootnoteIdentifier = (idAttribute) => /^fn-(\w+)-\d+$/.exec(idAttribute)?.[1];
+
export default Node.create({
name: 'footnoteDefinition',
-
content: 'paragraph',
-
group: 'block',
+ isolating: true,
+ addAttributes() {
+ return {
+ identifier: {
+ default: null,
+ parseHTML: (element) => extractFootnoteIdentifier(element.getAttribute('id')),
+ },
+ label: {
+ default: null,
+ parseHTML: (element) => extractFootnoteIdentifier(element.getAttribute('id')),
+ },
+ };
+ },
parseHTML() {
return [
@@ -15,7 +30,11 @@ export default Node.create({
];
},
- renderHTML({ HTMLAttributes }) {
- return ['li', mergeAttributes(HTMLAttributes), 0];
+ renderHTML({ label, ...HTMLAttributes }) {
+ return ['div', mergeAttributes(HTMLAttributes), 0];
+ },
+
+ addNodeView() {
+ return new VueNodeViewRenderer(FootnoteDefinitionWrapper);
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
index 1ac8016f774..ae5b8edc7af 100644
--- a/app/assets/javascripts/content_editor/extensions/footnote_reference.js
+++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js
@@ -1,6 +1,9 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+const extractFootnoteIdentifier = (element) =>
+ /^fnref-(\w+)-\d+$/.exec(element.querySelector('a')?.getAttribute('id'))?.[1];
+
export default Node.create({
name: 'footnoteReference',
@@ -16,13 +19,13 @@ export default Node.create({
addAttributes() {
return {
- footnoteId: {
+ identifier: {
default: null,
- parseHTML: (element) => element.querySelector('a').getAttribute('id'),
+ parseHTML: extractFootnoteIdentifier,
},
- footnoteNumber: {
+ label: {
default: null,
- parseHTML: (element) => element.textContent,
+ parseHTML: extractFootnoteIdentifier,
},
};
},
@@ -31,7 +34,7 @@ export default Node.create({
return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }];
},
- renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) {
- return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber];
+ renderHTML({ HTMLAttributes: { label, ...HTMLAttributes } }) {
+ return ['sup', mergeAttributes(HTMLAttributes), label];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
index 914a8934734..2b2c4177e1d 100644
--- a/app/assets/javascripts/content_editor/extensions/footnotes_section.js
+++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js
@@ -10,7 +10,10 @@ export default Node.create({
isolating: true,
parseHTML() {
- return [{ tag: 'section.footnotes > ol' }];
+ return [
+ { tag: 'section.footnotes', skip: true },
+ { tag: 'section.footnotes > ol', skip: true },
+ ];
},
renderHTML({ HTMLAttributes }) {
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 94236e2e70e..87118074462 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -4,6 +4,8 @@ import Bold from './bold';
import BulletList from './bullet_list';
import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
+import FootnoteReference from './footnote_reference';
+import FootnoteDefinition from './footnote_definition';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
@@ -13,6 +15,13 @@ import Link from './link';
import ListItem from './list_item';
import OrderedList from './ordered_list';
import Paragraph from './paragraph';
+import Strike from './strike';
+import TaskList from './task_list';
+import TaskItem from './task_item';
+import Table from './table';
+import TableCell from './table_cell';
+import TableHeader from './table_header';
+import TableRow from './table_row';
export default Extension.create({
addGlobalAttributes() {
@@ -24,6 +33,8 @@ export default Extension.create({
BulletList.name,
Code.name,
CodeBlockHighlight.name,
+ FootnoteReference.name,
+ FootnoteDefinition.name,
HardBreak.name,
Heading.name,
HorizontalRule.name,
@@ -33,6 +44,13 @@ export default Extension.create({
ListItem.name,
OrderedList.name,
Paragraph.name,
+ Strike.name,
+ TaskList.name,
+ TaskItem.name,
+ Table.name,
+ TableCell.name,
+ TableHeader.name,
+ TableRow.name,
],
attributes: {
sourceMarkdown: {
diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js
index 942457b9664..c0bcddbe58d 100644
--- a/app/assets/javascripts/content_editor/services/asset_resolver.js
+++ b/app/assets/javascripts/content_editor/services/asset_resolver.js
@@ -1,13 +1,24 @@
import { memoize } from 'lodash';
+const parser = new DOMParser();
+
export default ({ renderMarkdown }) => ({
resolveUrl: memoize(async (canonicalSrc) => {
const html = await renderMarkdown(`[link](${canonicalSrc})`);
if (!html) return canonicalSrc;
- const parser = new DOMParser();
const { body } = parser.parseFromString(html, 'text/html');
-
return body.querySelector('a').getAttribute('href');
}),
+
+ renderDiagram: memoize(async (code, language) => {
+ const backticks = '`'.repeat(4);
+ const html = await renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`);
+
+ const { body } = parser.parseFromString(html, 'text/html');
+ const img = body.querySelector('img');
+ if (!img) return '';
+
+ return img.dataset.src || img.getAttribute('src');
+ }),
});
diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
index 1afaf4bfef6..b7cf1bb087c 100644
--- a/app/assets/javascripts/content_editor/services/code_block_language_loader.js
+++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js
@@ -8,7 +8,7 @@ const codeBlockLanguageLoader = {
allLanguages: CODE_BLOCK_LANGUAGES,
- findLanguageBySyntax(value) {
+ findOrCreateLanguageBySyntax(value, isDiagram) {
const lowercaseValue = value?.toLowerCase() || 'plaintext';
return (
this.allLanguages.find(
@@ -16,7 +16,9 @@ const codeBlockLanguageLoader = {
syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue),
) || {
syntax: lowercaseValue,
- label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }),
+ label: sprintf(isDiagram ? __(`Diagram (%{language})`) : __(`Custom (%{language})`), {
+ language: lowercaseValue,
+ }),
}
);
},
@@ -38,7 +40,7 @@ const codeBlockLanguageLoader = {
},
loadLanguageFromInputRule(match) {
- const { syntax } = this.findLanguageBySyntax(match[1]);
+ const { syntax } = this.findOrCreateLanguageBySyntax(match[1]);
this.loadLanguage(syntax);
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 52dacb84153..06757e7a280 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -52,6 +52,10 @@ export class ContentEditor {
return this._assetResolver.resolveUrl(canonicalSrc);
}
+ renderDiagram(code, language) {
+ return this._assetResolver.renderDiagram(code, language);
+ }
+
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _eventHub: eventHub } = this;
const { doc, tr } = editor.state;
diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
index b6a3e0bc26a..2c462cdde91 100644
--- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
+++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js
@@ -20,9 +20,9 @@
*/
import { Mark } from 'prosemirror-model';
-import { visitParents } from 'unist-util-visit-parents';
+import { visitParents, SKIP } from 'unist-util-visit-parents';
import { toString } from 'hast-util-to-string';
-import { isFunction } from 'lodash';
+import { isFunction, isString, noop } from 'lodash';
/**
* Merges two ProseMirror text nodes if both text nodes
@@ -63,10 +63,12 @@ function maybeMerge(a, b) {
function createSourceMapAttributes(hastNode, source) {
const { position } = hastNode;
- return {
- sourceMapKey: `${position.start.offset}:${position.end.offset}`,
- sourceMarkdown: source.substring(position.start.offset, position.end.offset),
- };
+ return position && position.end
+ ? {
+ sourceMapKey: `${position.start.offset}:${position.end.offset}`,
+ sourceMarkdown: source.substring(position.start.offset, position.end.offset),
+ }
+ : {};
}
/**
@@ -141,6 +143,20 @@ class HastToProseMirrorConverterState {
return this.stack.length === 0;
}
+ findInStack(fn) {
+ const last = this.stack.length - 1;
+
+ for (let i = last; i >= 0; i -= 1) {
+ const item = this.stack[i];
+
+ if (fn(item) === true) {
+ return item;
+ }
+ }
+
+ return null;
+ }
+
/**
* Creates a text node and adds it to
* the top node in the stack.
@@ -249,33 +265,38 @@ class HastToProseMirrorConverterState {
* @returns An object that contains ProseMirror node factories
*/
const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => {
- const handlers = {
- root: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}),
- text: (state, hastNode) => {
- const { factorySpec } = state.top;
-
- if (/^\s+$/.test(hastNode.value)) {
- return;
- }
+ const factories = {
+ root: {
+ selector: 'root',
+ wrapInParagraph: true,
+ handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}),
+ },
+ text: {
+ selector: 'text',
+ handle: (state, hastNode) => {
+ const found = state.findInStack((node) => isFunction(node.factorySpec.processText));
+ const { value: text } = hastNode;
+
+ if (/^\s+$/.test(text)) {
+ return;
+ }
- if (factorySpec.wrapTextInParagraph === true) {
- state.openNode(schema.nodeType('paragraph'));
- state.addText(schema, hastNode.value);
- state.closeNode();
- } else {
- state.addText(schema, hastNode.value);
- }
+ state.addText(schema, found ? found.factorySpec.processText(text) : text);
+ },
},
};
-
- for (const [hastNodeTagName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) {
- if (factorySpec.block) {
- handlers[hastNodeTagName] = (state, hastNode, parent, ancestors) => {
- const nodeType = schema.nodeType(
- isFunction(factorySpec.block)
- ? factorySpec.block(hastNode, parent, ancestors)
- : factorySpec.block,
- );
+ for (const [proseMirrorName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) {
+ const factory = {
+ selector: factorySpec.selector,
+ skipChildren: factorySpec.skipChildren,
+ processText: factorySpec.processText,
+ parent: factorySpec.parent,
+ wrapInParagraph: factorySpec.wrapInParagraph,
+ };
+
+ if (factorySpec.type === 'block') {
+ factory.handle = (state, hastNode, parent) => {
+ const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
state.openNode(
@@ -297,9 +318,9 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
state.closeNode();
}
};
- } else if (factorySpec.inline) {
- const nodeType = schema.nodeType(factorySpec.inline);
- handlers[hastNodeTagName] = (state, hastNode, parent) => {
+ } else if (factorySpec.type === 'inline') {
+ const nodeType = schema.nodeType(proseMirrorName);
+ factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
state.openNode(
nodeType,
@@ -310,23 +331,115 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
// Inline nodes do not have children therefore they are immediately closed
state.closeNode();
};
- } else if (factorySpec.mark) {
- const markType = schema.marks[factorySpec.mark];
- handlers[hastNodeTagName] = (state, hastNode, parent) => {
+ } else if (factorySpec.type === 'mark') {
+ const markType = schema.marks[proseMirrorName];
+ factory.handle = (state, hastNode, parent) => {
state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source));
if (factorySpec.inlineContent) {
state.addText(schema, hastNode.value);
}
};
+ } else if (factorySpec.type === 'ignore') {
+ factory.handle = noop;
} else {
- throw new RangeError(`Unrecognized node factory spec ${JSON.stringify(factorySpec)}`);
+ throw new RangeError(
+ `Unrecognized ProseMirror object type ${JSON.stringify(factorySpec.type)}`,
+ );
}
+
+ factories[proseMirrorName] = factory;
}
- return handlers;
+ return factories;
};
+const findFactory = (hastNode, ancestors, factories) =>
+ Object.entries(factories).find(([, factorySpec]) => {
+ const { selector } = factorySpec;
+
+ return isFunction(selector)
+ ? selector(hastNode, ancestors)
+ : [hastNode.tagName, hastNode.type].includes(selector);
+ })?.[1];
+
+const findParent = (ancestors, parent) => {
+ if (isString(parent)) {
+ return ancestors.reverse().find((ancestor) => ancestor.tagName === parent);
+ }
+
+ return ancestors[ancestors.length - 1];
+};
+
+const calcTextNodePosition = (textNode) => {
+ const { position, value, type } = textNode;
+
+ if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) {
+ return textNode.position;
+ }
+
+ const span = value.length - 1;
+
+ if (position.start && !position.end) {
+ const { start } = position;
+
+ return {
+ start,
+ end: {
+ row: start.row,
+ column: start.column + span,
+ offset: start.offset + span,
+ },
+ };
+ }
+
+ const { end } = position;
+
+ return {
+ start: {
+ row: end.row,
+ column: end.column - span,
+ offset: end.offset - span,
+ },
+ end,
+ };
+};
+
+const removeEmptyTextNodes = (nodes) =>
+ nodes.filter(
+ (node) => node.type !== 'text' || (node.type === 'text' && !/^\s+$/.test(node.value)),
+ );
+
+const wrapInlineElements = (nodes, wrappableTags) =>
+ nodes.reduce((children, child) => {
+ const previous = children[children.length - 1];
+
+ if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) {
+ return [...children, child];
+ }
+
+ const wrapperExists = previous?.properties.wrapper;
+
+ if (wrapperExists) {
+ const wrapper = previous;
+
+ wrapper.position.end = child.position.end;
+ wrapper.children.push(child);
+
+ return children;
+ }
+
+ const wrapper = {
+ type: 'element',
+ tagName: 'p',
+ position: calcTextNodePosition(child),
+ children: [child],
+ properties: { wrapper: true },
+ };
+
+ return [...children, wrapper];
+ }, []);
+
/**
* Converts a Hast AST to a ProseMirror document based on a series
* of specifications that describe how to map all the nodes of the former
@@ -339,8 +452,9 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* The object should have the following shape:
*
* {
- * [hastNode.tagName]: {
- * [block|node|mark]: [ProseMirror.Node.name],
+ * [ProseMirrorNodeOrMarkName]: {
+ * type: 'block' | 'inline' | 'mark',
+ * selector: String | hastNode -> Boolean,
* ...configurationOptions
* }
* }
@@ -348,57 +462,21 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* Where each property in the object represents a HAST node with a given tag name, for example:
*
* {
- * h1: {},
- * h2: {},
- * table: {},
- * strong: {},
- * // etc
- * }
- *
- * You can specify the type of ProseMirror object adding one the following
- * properties:
- *
- * 1. "block": A ProseMirror node that contains one or more children.
- * 2. "inline": A ProseMirror node that doesnā€™t contain any children although
- * it can have inline content like a code block or a reference.
- * 3. "mark": A ProseMirror mark.
- *
- * The value of that property should be the name of the ProseMirror node or mark, i.e:
- *
- * {
- * h1: {
- * block: 'heading',
+ * horizontalRule: {
+ * type: 'block',
+ * selector: 'hr',
* },
- * h2: {
- * block: 'heading',
+ * heading: {
+ * type: 'block',
+ * selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode),
* },
- * img: {
- * node: 'image',
+ * bold: {
+ * type: 'mark'
+ * selector: (hastNode) => ['b', 'strong'].includes(hastNode),
* },
- * strong: {
- * mark: 'bold',
- * }
- * }
+ * // etc
+ * }
*
- * You can compute a ProseMirrorā€™s node or mark name based on the HAST node
- * by passing a function instead of a String. The converter invokes the function
- * and provides a HAST node object:
- *
- * {
- * list: {
- * block: (hastNode) => {
- * let type = 'bulletList';
-
- * if (hastNode.children.some(isTaskItem)) {
- * type = 'taskList';
- * } else if (hastNode.ordered) {
- * type = 'orderedList';
- * }
-
- * return type;
- * }
- * }
- * }
*
* Configuration options
* ----------------------
@@ -406,6 +484,28 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* You can customize the conversion process for every node or mark
* setting the following properties in the specification object:
*
+ * **type**
+ *
+ * The `type` property should have one of following three values:
+ *
+ * 1. "block": A ProseMirror node that contains one or more children.
+ * 2. "inline": A ProseMirror node that doesnā€™t contain any children although
+ * it can have inline content like an image or a mention object.
+ * 3. "mark": A ProseMirror mark.
+ * 4. "ignore": A hast node that should be ignored and wonā€™t be mapped to a
+ * ProseMirror node.
+ *
+ * **selector**
+ *
+ * The `selector` property matches a HastNode to a ProseMirror node or
+ * Mark. If you assign a string value to this property, the converter
+ * will match the first hast node with a `tagName` or `type` property
+ * that equals the string value.
+ *
+ * If you assign a function, the converter will invoke the function with
+ * the hast node and its ancestors. The function should return `true`
+ * if the hastNode matches the custom criteria implemented in the function
+ *
* **getAttrs**
*
* Computes a ProseMirror node or mark attributes. The converter will invoke
@@ -415,12 +515,19 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* 2. hasParents: All the hast nodeā€™s ancestors up to the root node
* 3. source: Markdown source fileā€™s content
*
- * **wrapTextInParagraph**
+ * **wrapInParagraph**
*
- * This property only applies to block nodes. If a block node contains text,
- * it will wrap that text in a paragraph. This is useful for ProseMirror block
+ * This property only applies to block nodes. If a block node contains inline
+ * elements like text, images, links, etc, the converter will wrap those inline
+ * elements in a paragraph. This is useful for ProseMirror block
* nodes that donā€™t allow text directly such as list items and tables.
*
+ * **processText**
+ *
+ * This property only applies to block nodes. If a block node contains text,
+ * it allows applying a processing function to that text. This is useful when
+ * you can transform the text node, i.e trim(), substring(), etc.
+ *
* **skipChildren**
*
* Skips a hast nodeā€™s children while traversing the tree.
@@ -434,6 +541,13 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
* Use this property along skipChildren to provide custom processing of child nodes
* for a block node.
*
+ * **parent**
+ *
+ * Specifies what is the nodeā€™s parent. This is useful when the nodeā€™s parent is not
+ * its direct ancestor in Abstract Syntax Tree. For example, imagine that you want
+ * to make <tr> elements a direct children of tables and skip `<thead>` and `<tbody>`
+ * altogether.
+ *
* @param {model.Document_Schema} params.schema A ProseMirror schema that specifies the shape
* of the ProseMirror document.
* @param {Object} params.factorySpec A factory specification as described above
@@ -442,17 +556,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source)
*
* @returns A ProseMirror document
*/
-export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, source }) => {
+export const createProseMirrorDocFromMdastTree = ({
+ schema,
+ factorySpecs,
+ wrappableTags,
+ tree,
+ source,
+}) => {
const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source);
const state = new HastToProseMirrorConverterState();
visitParents(tree, (hastNode, ancestors) => {
- const parent = ancestors[ancestors.length - 1];
- const skipChildren = factorySpecs[hastNode.tagName]?.skipChildren;
-
- const handler = proseMirrorNodeFactories[hastNode.tagName || hastNode.type];
+ const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories);
- if (!handler) {
+ if (!factory) {
throw new Error(
`Hast node of type "${
hastNode.tagName || hastNode.type
@@ -460,9 +577,25 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree,
);
}
- handler(state, hastNode, parent, ancestors);
+ const parent = findParent(ancestors, factory.parent);
+
+ if (factory.wrapInParagraph) {
+ /**
+ * Modifying parameters is a bad practice. For performance reasons,
+ * the author of the unist-util-visit-parents function recommends
+ * modifying nodes in place to avoid traversing the Abstract Syntax
+ * Tree more than once
+ */
+ // eslint-disable-next-line no-param-reassign
+ hastNode.children = wrapInlineElements(
+ removeEmptyTextNodes(hastNode.children),
+ wrappableTags,
+ );
+ }
+
+ factory.handle(state, hastNode, parent);
- return skipChildren === true ? 'skip' : true;
+ return factory.skipChildren === true ? SKIP : true;
});
let doc;
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index d665f24bba1..2d33a16f1a5 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -17,7 +17,6 @@ import Diagram from '../extensions/diagram';
import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure';
import FigureCaption from '../extensions/figure_caption';
-import FootnotesSection from '../extensions/footnotes_section';
import FootnoteDefinition from '../extensions/footnote_definition';
import FootnoteReference from '../extensions/footnote_reference';
import Frontmatter from '../extensions/frontmatter';
@@ -60,11 +59,13 @@ import {
renderPlayable,
renderHTMLNode,
renderContent,
+ renderBulletList,
preserveUnchanged,
bold,
italic,
link,
code,
+ strike,
} from './serialization_helpers';
const defaultSerializerConfig = {
@@ -89,12 +90,7 @@ const defaultSerializerConfig = {
close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`,
escape: false,
},
- [Strike.name]: {
- open: '~~',
- close: '~~',
- mixable: true,
- expelEnclosingWhitespace: true,
- },
+ [Strike.name]: strike,
...HTMLMarks.reduce(
(acc, { name }) => ({
...acc,
@@ -124,7 +120,7 @@ const defaultSerializerConfig = {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
}
}),
- [BulletList.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.bullet_list),
+ [BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
[Diagram.name]: renderCodeBlock,
[Division.name]: (state, node) => {
@@ -157,15 +153,14 @@ const defaultSerializerConfig = {
state.write(`:${name}:`);
},
- [FootnoteDefinition.name]: (state, node) => {
+ [FootnoteDefinition.name]: preserveUnchanged((state, node) => {
+ state.write(`[^${node.attrs.identifier}]: `);
state.renderInline(node);
- },
- [FootnoteReference.name]: (state, node) => {
- state.write(`[^${node.attrs.footnoteNumber}]`);
- },
- [FootnotesSection.name]: (state, node) => {
- state.renderList(node, '', (index) => `[^${index + 1}]: `);
- },
+ state.ensureNewLine();
+ }),
+ [FootnoteReference.name]: preserveUnchanged((state, node) => {
+ state.write(`[^${node.attrs.identifier}]`);
+ }),
[Frontmatter.name]: (state, node) => {
const { language } = node.attrs;
const syntax = {
@@ -196,18 +191,18 @@ const defaultSerializerConfig = {
state.write('[[_TOC_]]');
state.closeBlock(node);
},
- [Table.name]: renderTable,
+ [Table.name]: preserveUnchanged(renderTable),
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
[TableRow.name]: renderTableRow,
- [TaskItem.name]: (state, node) => {
+ [TaskItem.name]: preserveUnchanged((state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
- },
- [TaskList.name]: (state, node) => {
+ }),
+ [TaskList.name]: preserveUnchanged((state, node) => {
if (node.attrs.numeric) renderOrderedList(state, node);
- else defaultMarkdownSerializer.nodes.bullet_list(state, node);
- },
+ else renderBulletList(state, node);
+ }),
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Video.name]: renderPlayable,
[WordBreak.name]: (state) => state.write('<wbr>'),
diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
index 770de1df0d0..da10c684b0b 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -2,39 +2,51 @@ import { isString } from 'lodash';
import { render } from '~/lib/gfm';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
+const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
+
+const isTaskItem = (hastNode) => {
+ const { className } = hastNode.properties;
+
+ return (
+ hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item')
+ );
+};
+
+const getTableCellAttrs = (hastNode) => ({
+ colspan: parseInt(hastNode.properties.colSpan, 10) || 1,
+ rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1,
+});
+
const factorySpecs = {
- blockquote: { block: 'blockquote' },
- p: { block: 'paragraph' },
- li: { block: 'listItem', wrapTextInParagraph: true },
- ul: { block: 'bulletList' },
- ol: { block: 'orderedList' },
- h1: {
- block: 'heading',
- getAttrs: () => ({ level: 1 }),
- },
- h2: {
- block: 'heading',
- getAttrs: () => ({ level: 2 }),
- },
- h3: {
- block: 'heading',
- getAttrs: () => ({ level: 3 }),
- },
- h4: {
- block: 'heading',
- getAttrs: () => ({ level: 4 }),
- },
- h5: {
- block: 'heading',
- getAttrs: () => ({ level: 5 }),
- },
- h6: {
- block: 'heading',
- getAttrs: () => ({ level: 6 }),
- },
- pre: {
- block: 'codeBlock',
+ blockquote: { type: 'block', selector: 'blockquote' },
+ paragraph: { type: 'block', selector: 'p' },
+ listItem: {
+ type: 'block',
+ wrapInParagraph: true,
+ selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className,
+ processText: (text) => text.trimRight(),
+ },
+ orderedList: {
+ type: 'block',
+ selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className,
+ },
+ bulletList: {
+ type: 'block',
+ selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className,
+ },
+ heading: {
+ type: 'block',
+ selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode.tagName),
+ getAttrs: (hastNode) => {
+ const level = parseInt(/(\d)$/.exec(hastNode.tagName)?.[1], 10) || 1;
+
+ return { level };
+ },
+ },
+ codeBlock: {
+ type: 'block',
skipChildren: true,
+ selector: 'pre',
getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''),
getAttrs: (hastNode) => {
const languageClass = hastNode.children[0]?.properties.className?.[0];
@@ -43,28 +55,111 @@ const factorySpecs = {
return { language };
},
},
- hr: { inline: 'horizontalRule' },
- img: {
- inline: 'image',
+ horizontalRule: {
+ type: 'block',
+ selector: 'hr',
+ },
+ taskList: {
+ type: 'block',
+ selector: (hastNode) => {
+ const { className } = hastNode.properties;
+
+ return (
+ ['ul', 'ol'].includes(hastNode.tagName) &&
+ Array.isArray(className) &&
+ className.includes('contains-task-list')
+ );
+ },
+ getAttrs: (hastNode) => ({
+ numeric: hastNode.tagName === 'ol',
+ }),
+ },
+ taskItem: {
+ type: 'block',
+ wrapInParagraph: true,
+ selector: isTaskItem,
+ getAttrs: (hastNode) => ({
+ checked: hastNode.children[0].properties.checked,
+ }),
+ processText: (text) => text.trimLeft(),
+ },
+ taskItemCheckbox: {
+ type: 'ignore',
+ selector: (hastNode, ancestors) =>
+ hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]),
+ },
+ table: {
+ type: 'block',
+ selector: 'table',
+ },
+ tableRow: {
+ type: 'block',
+ selector: 'tr',
+ parent: 'table',
+ },
+ tableHeader: {
+ type: 'block',
+ selector: 'th',
+ getAttrs: getTableCellAttrs,
+ wrapInParagraph: true,
+ },
+ tableCell: {
+ type: 'block',
+ selector: 'td',
+ getAttrs: getTableCellAttrs,
+ wrapInParagraph: true,
+ },
+ ignoredTableNodes: {
+ type: 'ignore',
+ selector: (hastNode) => ['thead', 'tbody', 'tfoot'].includes(hastNode.tagName),
+ },
+ footnoteDefinition: {
+ type: 'block',
+ selector: 'footnotedefinition',
+ getAttrs: (hastNode) => hastNode.properties,
+ },
+ image: {
+ type: 'inline',
+ selector: 'img',
getAttrs: (hastNode) => ({
src: hastNode.properties.src,
title: hastNode.properties.title,
alt: hastNode.properties.alt,
}),
},
- br: { inline: 'hardBreak' },
- code: { mark: 'code' },
- em: { mark: 'italic' },
- i: { mark: 'italic' },
- strong: { mark: 'bold' },
- b: { mark: 'bold' },
- a: {
- mark: 'link',
+ hardBreak: {
+ type: 'inline',
+ selector: 'br',
+ },
+ footnoteReference: {
+ type: 'inline',
+ selector: 'footnotereference',
+ getAttrs: (hastNode) => hastNode.properties,
+ },
+ code: {
+ type: 'mark',
+ selector: 'code',
+ },
+ italic: {
+ type: 'mark',
+ selector: (hastNode) => ['em', 'i'].includes(hastNode.tagName),
+ },
+ bold: {
+ type: 'mark',
+ selector: (hastNode) => ['strong', 'b'].includes(hastNode.tagName),
+ },
+ link: {
+ type: 'mark',
+ selector: 'a',
getAttrs: (hastNode) => ({
href: hastNode.properties.href,
title: hastNode.properties.title,
}),
},
+ strike: {
+ type: 'mark',
+ selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName),
+ },
};
export default () => {
@@ -77,6 +172,7 @@ export default () => {
schema,
factorySpecs,
tree,
+ wrappableTags,
source: markdown,
}),
});
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 089d30edec7..88f5192af77 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -1,4 +1,4 @@
-import { uniq, isString } from 'lodash';
+import { uniq, isString, omit } from 'lodash';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
@@ -12,22 +12,6 @@ const ignoreAttrs = {
const tableMap = new WeakMap();
-// Source taken from
-// prosemirror-markdown/src/to_markdown.js
-export function isPlainURL(link, parent, index, side) {
- if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
- const content = parent.child(index + (side < 0 ? -1 : 0));
- if (
- !content.isText ||
- content.text !== link.attrs.href ||
- content.marks[content.marks.length - 1] !== link
- )
- return false;
- if (index === (side < 0 ? 1 : parent.childCount - 1)) return true;
- const next = parent.child(index + (side < 0 ? -2 : 1));
- return !link.isInSet(next.marks);
-}
-
function containsOnlyText(node) {
if (node.childCount === 1) {
const child = node.child(0);
@@ -219,7 +203,7 @@ function renderTableRowAsHTML(state, node) {
node.forEach((cell, _, i) => {
const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
- renderTagOpen(state, tag, cell.attrs);
+ renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown'));
if (!containsParagraphWithOnlyText(cell)) {
state.closeBlock(node);
@@ -272,19 +256,6 @@ export function renderHTMLNode(tagName, forceRenderContentInline = false) {
};
}
-export function renderOrderedList(state, node) {
- const { parens } = node.attrs;
- const start = node.attrs.start || 1;
- const maxW = String(start + node.childCount - 1).length;
- const space = state.repeat(' ', maxW + 2);
- const delimiter = parens ? ')' : '.';
-
- state.renderList(node, space, (i) => {
- const nStr = String(start + i);
- return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
- });
-}
-
export function renderTableCell(state, node) {
if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
state.renderInline(node.child(0));
@@ -364,7 +335,72 @@ export function preserveUnchanged(render) {
};
}
-const generateBoldTags = (open = true) => {
+/**
+ * We extracted this function from
+ * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.ts#L350.
+ *
+ * We need to overwrite this function because we donā€™t want to wrap the list item nodes
+ * with the bullet delimiter when the list item node hasnā€™t changed
+ */
+const renderList = (state, node, delim, firstDelim) => {
+ if (state.closed && state.closed.type === node.type) state.flushClose(3);
+ else if (state.inTightList) state.flushClose(1);
+
+ const isTight =
+ typeof node.attrs.tight !== 'undefined' ? node.attrs.tight : state.options.tightLists;
+ const prevTight = state.inTightList;
+
+ state.inTightList = isTight;
+
+ node.forEach((child, _, i) => {
+ const same = state.options.changeTracker.get(child);
+
+ if (i && isTight) {
+ state.flushClose(1);
+ }
+
+ if (same) {
+ // Avoid wrapping list item when node hasnā€™t changed
+ state.render(child, node, i);
+ } else {
+ state.wrapBlock(delim, firstDelim(i), node, () => state.render(child, node, i));
+ }
+ });
+
+ state.inTightList = prevTight;
+};
+
+export const renderBulletList = (state, node) => {
+ const { sourceMarkdown, bullet: bulletAttr } = node.attrs;
+ const bullet = /^(\*|\+|-)\s/.exec(sourceMarkdown)?.[1] || bulletAttr || '*';
+
+ renderList(state, node, ' ', () => `${bullet} `);
+};
+
+export function renderOrderedList(state, node) {
+ const { sourceMarkdown } = node.attrs;
+ let start;
+ let delimiter;
+
+ if (sourceMarkdown) {
+ const match = /^(\d+)(\)|\.)/.exec(sourceMarkdown);
+ start = parseInt(match[1], 10) || 1;
+ [, , delimiter] = match;
+ } else {
+ start = node.attrs.start || 1;
+ delimiter = node.attrs.parens ? ')' : '.';
+ }
+
+ const maxW = String(start + node.childCount - 1).length;
+ const space = state.repeat(' ', maxW + 2);
+
+ renderList(state, node, space, (i) => {
+ const nStr = String(start + i);
+ return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
+ });
+}
+
+const generateBoldTags = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1];
@@ -375,7 +411,7 @@ const generateBoldTags = (open = true) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<strong':
case '<b':
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
default:
return '**';
}
@@ -384,12 +420,12 @@ const generateBoldTags = (open = true) => {
export const bold = {
open: generateBoldTags(),
- close: generateBoldTags(false),
+ close: generateBoldTags(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
-const generateItalicTag = (open = true) => {
+const generateItalicTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1];
@@ -400,7 +436,7 @@ const generateItalicTag = (open = true) => {
// eslint-disable-next-line @gitlab/require-i18n-strings
case '<em':
case '<i':
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
default:
return '_';
}
@@ -409,17 +445,17 @@ const generateItalicTag = (open = true) => {
export const italic = {
open: generateItalicTag(),
- close: generateItalicTag(false),
+ close: generateItalicTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
-const generateCodeTag = (open = true) => {
+const generateCodeTag = (wrapTagName = openTag) => {
return (_, mark) => {
const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1];
if (type === '<code') {
- return (open ? openTag : closeTag)(type.substring(1));
+ return wrapTagName(type.substring(1));
}
return '`';
@@ -428,7 +464,7 @@ const generateCodeTag = (open = true) => {
export const code = {
open: generateCodeTag(),
- close: generateCodeTag(false),
+ close: generateCodeTag(closeTag),
mixable: true,
expelEnclosingWhitespace: true,
};
@@ -446,10 +482,79 @@ const linkType = (sourceMarkdown) => {
return LINK_HTML;
};
+const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
+
+const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url));
+
+/**
+ * Validates that the provided URL is well-formed
+ *
+ * @param {String} url
+ * @returns Returns true when the browserā€™s URL constructor
+ * can successfully parse the URL string
+ */
+const isValidUrl = (url) => {
+ try {
+ return new URL(url) && true;
+ } catch {
+ return false;
+ }
+};
+
+const findChildWithMark = (mark, parent) => {
+ let child;
+ let offset;
+ let index;
+
+ parent.forEach((_child, _offset, _index) => {
+ if (mark.isInSet(_child.marks)) {
+ child = _child;
+ offset = _offset;
+ index = _index;
+ }
+ });
+
+ return child ? { child, offset, index } : null;
+};
+
+/**
+ * This function detects whether a link should be serialized
+ * as an autolink.
+ *
+ * See https://github.github.com/gfm/#autolinks-extension-
+ * to understand the parsing rules of autolinks.
+ * */
+const isAutoLink = (linkMark, parent) => {
+ const { title, href } = linkMark.attrs;
+
+ if (title || !/^\w+:/.test(href)) {
+ return false;
+ }
+
+ const { child } = findChildWithMark(linkMark, parent);
+
+ if (
+ !child ||
+ !child.isText ||
+ !isValidUrl(href) ||
+ normalizeUrl(child.text) !== normalizeUrl(href)
+ ) {
+ return false;
+ }
+
+ return true;
+};
+
+/**
+ * Returns true if the user used brackets to the define
+ * the autolink in the original markdown source
+ */
+const isBracketAutoLink = (sourceMarkdown) => /^<.+?>$/.test(sourceMarkdown);
+
export const link = {
- open(state, mark, parent, index) {
- if (isPlainURL(mark, parent, index, 1)) {
- return '<';
+ open(state, mark, parent) {
+ if (isAutoLink(mark, parent)) {
+ return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
@@ -466,9 +571,9 @@ export const link = {
return openTag('a', attrs);
},
- close(state, mark, parent, index) {
- if (isPlainURL(mark, parent, index, -1)) {
- return '>';
+ close(state, mark, parent) {
+ if (isAutoLink(mark, parent)) {
+ return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
@@ -480,3 +585,28 @@ export const link = {
return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`;
},
};
+
+const generateStrikeTag = (wrapTagName = openTag) => {
+ return (_, mark) => {
+ const type = /^(~~|<del|<strike|<s).*/.exec(mark.attrs.sourceMarkdown)?.[1];
+
+ switch (type) {
+ case '~~':
+ return type;
+ /* eslint-disable @gitlab/require-i18n-strings */
+ case '<del':
+ case '<strike':
+ case '<s':
+ return wrapTagName(type.substring(1));
+ default:
+ return '~~';
+ }
+ };
+};
+
+export const strike = {
+ open: generateStrikeTag(),
+ close: generateStrikeTag(closeTag),
+ mixable: true,
+ expelEnclosingWhitespace: true,
+};
diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
index 3158ae9b126..ccd22085470 100644
--- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
+++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue
@@ -22,7 +22,7 @@ export default {
type: Boolean,
required: true,
},
- editProjectServicePath: {
+ editIntegrationPath: {
type: String,
required: true,
},
@@ -79,7 +79,7 @@ export default {
<gl-button variant="success" category="primary" :disabled="!formIsValid" @click="submit">
{{ saveButtonText }}
</gl-button>
- <gl-button class="float-right" :href="editProjectServicePath">{{ __('Cancel') }}</gl-button>
+ <gl-button class="float-right" :href="editIntegrationPath">{{ __('Cancel') }}</gl-button>
<delete-custom-metric-modal
v-if="metricPersisted"
:delete-metric-url="customMetricsPath"
diff --git a/app/assets/javascripts/custom_metrics/index.js b/app/assets/javascripts/custom_metrics/index.js
index 4c279daf5f0..bf572217f5e 100644
--- a/app/assets/javascripts/custom_metrics/index.js
+++ b/app/assets/javascripts/custom_metrics/index.js
@@ -13,7 +13,7 @@ export default () => {
const domEl = document.querySelector(this.$options.el);
const {
customMetricsPath,
- editProjectServicePath,
+ editIntegrationPath,
validateQueryPath,
title,
query,
@@ -30,7 +30,7 @@ export default () => {
props: {
customMetricsPath,
metricPersisted,
- editProjectServicePath,
+ editIntegrationPath,
validateQueryPath,
formData: {
title,
diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
index af7334ecf2e..72a7659aac0 100644
--- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
+++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
@@ -1,10 +1,5 @@
<script>
-import {
- GlPath,
- GlPopover,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
+import { GlPath, GlPopover, GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import Tracking from '~/tracking';
import { OVERVIEW_STAGE_ID } from '../constants';
import FormattedStageCount from './formatted_stage_count.vue';
@@ -13,7 +8,7 @@ export default {
name: 'PathNavigation',
components: {
GlPath,
- GlSkeletonLoading,
+ GlSkeletonLoader,
GlPopover,
FormattedStageCount,
},
@@ -57,7 +52,7 @@ export default {
};
</script>
<template>
- <gl-skeleton-loading v-if="loading" :lines="2" />
+ <gl-skeleton-loader v-if="loading" :width="235" :lines="2" />
<gl-path v-else :key="selectedStage.id" :items="stages" @selected="onSelectStage">
<template #default="{ pathItem, pathId }">
<gl-popover
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index e4236968efc..85a40b89b77 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -13,6 +13,7 @@ import { __ } from '~/locale';
import Tracking from '~/tracking';
import {
NOT_ENOUGH_DATA_ERROR,
+ FIELD_KEY_TITLE,
PAGINATION_SORT_FIELD_END_EVENT,
PAGINATION_SORT_FIELD_DURATION,
PAGINATION_SORT_DIRECTION_ASC,
@@ -22,7 +23,8 @@ import TotalTime from './total_time.vue';
const DEFAULT_WORKFLOW_TITLE_PROPERTIES = {
thClass: 'gl-w-half',
- key: PAGINATION_SORT_FIELD_END_EVENT,
+ key: FIELD_KEY_TITLE,
+ sortable: false,
};
const WORKFLOW_COLUMN_TITLES = {
@@ -132,14 +134,16 @@ export default {
return [
this.workflowTitle,
{
+ key: PAGINATION_SORT_FIELD_END_EVENT,
+ label: __('Last event'),
+ sortable: this.sortable,
+ },
+ {
key: PAGINATION_SORT_FIELD_DURATION,
- label: __('Time'),
- thClass: 'gl-w-half',
+ label: __('Duration'),
+ sortable: this.sortable,
},
- ].map((field) => ({
- ...field,
- sortable: this.sortable,
- }));
+ ];
},
prevPage() {
return Math.max(this.pagination.page - 1, 0);
@@ -182,7 +186,7 @@ export default {
</script>
<template>
<div data-testid="vsa-stage-table">
- <gl-loading-icon v-if="isLoading" class="gl-mt-4" size="md" />
+ <gl-loading-icon v-if="isLoading" class="gl-mt-4" size="lg" />
<gl-empty-state
v-else-if="isEmptyStage"
:title="emptyStateTitleText"
@@ -201,7 +205,7 @@ export default {
:empty-text="emptyStateMessage"
@sort-changed="onSort"
>
- <template v-if="stageCount" #head(end_event)="data">
+ <template v-if="stageCount" #head(title)="data">
<span>{{ data.label }}</span
><gl-badge class="gl-ml-2" size="sm"
><formatted-stage-count :stage-count="stageCount"
@@ -210,7 +214,10 @@ export default {
<template #head(duration)="data">
<span data-testid="vsa-stage-header-duration">{{ data.label }}</span>
</template>
- <template #cell(end_event)="{ item }">
+ <template #head(end_event)="data">
+ <span data-testid="vsa-stage-header-last-event">{{ data.label }}</span>
+ </template>
+ <template #cell(title)="{ item }">
<div data-testid="vsa-stage-event">
<div v-if="item.id" data-testid="vsa-stage-content">
<p class="gl-m-0">
@@ -282,6 +289,9 @@ export default {
<template #cell(duration)="{ item }">
<total-time :time="item.totalTime" data-testid="vsa-stage-event-time" />
</template>
+ <template #cell(end_event)="{ item }">
+ <span data-testid="vsa-stage-last-event">{{ item.endEventTimestamp }}</span>
+ </template>
</gl-table>
<gl-pagination
v-if="pagination && !isLoading && !isEmptyStage"
diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/cycle_analytics/constants.js
index f0b2bd9dc5b..2758d686fb1 100644
--- a/app/assets/javascripts/cycle_analytics/constants.js
+++ b/app/assets/javascripts/cycle_analytics/constants.js
@@ -22,6 +22,7 @@ export const PAGINATION_SORT_FIELD_END_EVENT = 'end_event';
export const PAGINATION_SORT_FIELD_DURATION = 'duration';
export const PAGINATION_SORT_DIRECTION_DESC = 'desc';
export const PAGINATION_SORT_DIRECTION_ASC = 'asc';
+export const FIELD_KEY_TITLE = 'title';
export const I18N_VSA_ERROR_STAGES = __(
'There was an error fetching value stream analytics stages.',
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
index 37287b9d981..f10c2d82b61 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
@@ -107,10 +107,10 @@ function createLink(data, selected, options, index) {
}
if (options.trackSuggestionClickedLabel) {
- link.setAttribute('data-track-action', 'click_text');
- link.setAttribute('data-track-label', options.trackSuggestionClickedLabel);
- link.setAttribute('data-track-value', index);
- link.setAttribute('data-track-property', slugify(data.category || 'no-category'));
+ link.dataset.trackAction = 'click_text';
+ link.dataset.trackLabel = options.trackSuggestionClickedLabel;
+ link.dataset.trackValue = index;
+ link.dataset.trackProperty = slugify(data.category || 'no-category');
}
link.classList.toggle('is-active', selected);
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
index ae2ce7c3e5e..dec1038d2e3 100644
--- a/app/assets/javascripts/design_management/components/delete_button.vue
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -26,7 +26,7 @@ export default {
buttonVariant: {
type: String,
required: false,
- default: 'info',
+ default: 'default',
},
buttonCategory: {
type: String,
diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue
index 5116bacefa5..6bdd8568625 100644
--- a/app/assets/javascripts/design_management/components/design_presentation.vue
+++ b/app/assets/javascripts/design_management/components/design_presentation.vue
@@ -1,4 +1,5 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import DesignOverlay from './design_overlay.vue';
@@ -10,6 +11,7 @@ export default {
components: {
DesignImage,
DesignOverlay,
+ GlLoadingIcon,
},
props: {
image: {
@@ -40,6 +42,10 @@ export default {
type: Boolean,
required: true,
},
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -299,7 +305,12 @@ export default {
@touchend="onPresentationMouseup"
@touchcancel="onPresentationMouseup"
>
- <div class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative">
+ <gl-loading-icon
+ v-if="isLoading"
+ size="xl"
+ class="gl-display-flex gl-h-full gl-align-items-center"
+ />
+ <div v-else class="gl-h-full gl-w-full gl-display-flex gl-align-items-center gl-relative">
<design-image
v-if="image"
:image="image"
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index 81d0b6d0df4..8a6dd17a25b 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui';
+import { GlCollapse, GlButton, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
@@ -20,6 +20,7 @@ export default {
GlCollapse,
GlButton,
GlPopover,
+ GlSkeletonLoader,
DesignTodoButton,
},
mixins: [glFeatureFlagsMixin()],
@@ -50,6 +51,10 @@ export default {
type: String,
required: true,
},
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -65,11 +70,11 @@ export default {
issue() {
return {
...this.design.issue,
- webPath: this.design.issue.webPath.substr(1),
+ webPath: this.design.issue?.webPath.substr(1),
};
},
discussionParticipants() {
- return extractParticipants(this.issue.participants.nodes);
+ return extractParticipants(this.issue.participants?.nodes || []);
},
resolvedDiscussions() {
return this.discussions.filter((discussion) => discussion.resolved);
@@ -142,91 +147,94 @@ export default {
:show-participant-label="false"
class="gl-mb-4"
/>
- <h2
- v-if="isLoggedIn && unresolvedDiscussions.length === 0"
- class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
- data-testid="new-discussion-disclaimer"
- >
- {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
- </h2>
- <design-note-signed-out
- v-if="!isLoggedIn"
- class="gl-mb-4"
- :register-path="registerPath"
- :sign-in-path="signInPath"
- :is-add-discussion="true"
- />
- <design-discussion
- v-for="discussion in unresolvedDiscussions"
- :key="discussion.id"
- :discussion="discussion"
- :design-id="$route.params.id"
- :noteable-id="design.id"
- :markdown-preview-path="markdownPreviewPath"
- :register-path="registerPath"
- :sign-in-path="signInPath"
- :resolved-discussions-expanded="resolvedDiscussionsExpanded"
- :discussion-with-open-form="discussionWithOpenForm"
- data-testid="unresolved-discussion"
- @create-note-error="$emit('onDesignDiscussionError', $event)"
- @update-note-error="$emit('updateNoteError', $event)"
- @resolve-discussion-error="$emit('resolveDiscussionError', $event)"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
- @open-form="updateDiscussionWithOpenForm"
- />
- <template v-if="resolvedDiscussions.length > 0">
- <gl-button
- id="resolved-comments"
- ref="resolvedComments"
- data-testid="resolved-comments"
- :icon="resolvedCommentsToggleIcon"
- variant="link"
- class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
- @click="$emit('toggleResolvedComments')"
- >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
- </gl-button>
- <gl-popover
- v-if="!isResolvedCommentsPopoverHidden"
- :show="!isResolvedCommentsPopoverHidden"
- target="resolved-comments"
- container="popovercontainer"
- placement="top"
- :title="s__('DesignManagement|Resolved Comments')"
+ <gl-skeleton-loader v-if="isLoading" />
+ <template v-else>
+ <h2
+ v-if="isLoggedIn && unresolvedDiscussions.length === 0"
+ class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4"
+ data-testid="new-discussion-disclaimer"
>
- <p>
- {{
- s__(
- 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
- )
- }}
- </p>
- <a
- href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
- rel="noopener noreferrer"
- target="_blank"
- >{{ s__('DesignManagement|Learn more about resolving comments') }}</a
+ {{ s__("DesignManagement|Click the image where you'd like to start a new discussion") }}
+ </h2>
+ <design-note-signed-out
+ v-if="!isLoggedIn"
+ class="gl-mb-4"
+ :register-path="registerPath"
+ :sign-in-path="signInPath"
+ :is-add-discussion="true"
+ />
+ <design-discussion
+ v-for="discussion in unresolvedDiscussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="$route.params.id"
+ :noteable-id="design.id"
+ :markdown-preview-path="markdownPreviewPath"
+ :register-path="registerPath"
+ :sign-in-path="signInPath"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :discussion-with-open-form="discussionWithOpenForm"
+ data-testid="unresolved-discussion"
+ @create-note-error="$emit('onDesignDiscussionError', $event)"
+ @update-note-error="$emit('updateNoteError', $event)"
+ @resolve-discussion-error="$emit('resolveDiscussionError', $event)"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ @open-form="updateDiscussionWithOpenForm"
+ />
+ <template v-if="resolvedDiscussions.length > 0">
+ <gl-button
+ id="resolved-comments"
+ ref="resolvedComments"
+ data-testid="resolved-comments"
+ :icon="resolvedCommentsToggleIcon"
+ variant="link"
+ class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
+ @click="$emit('toggleResolvedComments')"
+ >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
+ </gl-button>
+ <gl-popover
+ v-if="!isResolvedCommentsPopoverHidden"
+ :show="!isResolvedCommentsPopoverHidden"
+ target="resolved-comments"
+ container="popovercontainer"
+ placement="top"
+ :title="s__('DesignManagement|Resolved Comments')"
>
- </gl-popover>
- <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
- <design-discussion
- v-for="discussion in resolvedDiscussions"
- :key="discussion.id"
- :discussion="discussion"
- :design-id="$route.params.id"
- :noteable-id="design.id"
- :markdown-preview-path="markdownPreviewPath"
- :register-path="registerPath"
- :sign-in-path="signInPath"
- :resolved-discussions-expanded="resolvedDiscussionsExpanded"
- :discussion-with-open-form="discussionWithOpenForm"
- data-testid="resolved-discussion"
- @error="$emit('onDesignDiscussionError', $event)"
- @updateNoteError="$emit('updateNoteError', $event)"
- @open-form="updateDiscussionWithOpenForm"
- @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
- />
- </gl-collapse>
+ <p>
+ {{
+ s__(
+ 'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
+ )
+ }}
+ </p>
+ <a
+ href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
+ rel="noopener noreferrer"
+ target="_blank"
+ >{{ s__('DesignManagement|Learn more about resolving comments') }}</a
+ >
+ </gl-popover>
+ <gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
+ <design-discussion
+ v-for="discussion in resolvedDiscussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="$route.params.id"
+ :noteable-id="design.id"
+ :markdown-preview-path="markdownPreviewPath"
+ :register-path="registerPath"
+ :sign-in-path="signInPath"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :discussion-with-open-form="discussionWithOpenForm"
+ data-testid="resolved-discussion"
+ @error="$emit('onDesignDiscussionError', $event)"
+ @updateNoteError="$emit('updateNoteError', $event)"
+ @open-form="updateDiscussionWithOpenForm"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ />
+ </gl-collapse>
+ </template>
+ <slot name="reply-form"></slot>
</template>
- <slot name="reply-form"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index b6163491abc..3092b8554ac 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -145,7 +145,7 @@ export default {
</span>
</div>
<gl-intersection-observer @appear="onAppear">
- <gl-loading-icon v-if="showLoadingSpinner" size="md" />
+ <gl-loading-icon v-if="showLoadingSpinner" size="lg" />
<gl-icon
v-else-if="showImageErrorIcon"
name="media-broken"
diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
index 3ebcde817f9..0bbbc795fff 100644
--- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue
@@ -87,7 +87,7 @@ export default {
:disabled="!previousDesign"
:title="$options.i18n.previousButton"
:aria-label="$options.i18n.previousButton"
- icon="angle-left"
+ icon="chevron-lg-left"
class="js-previous-design"
@click="navigateToDesign(previousDesign)"
/>
@@ -96,7 +96,7 @@ export default {
:disabled="!nextDesign"
:title="$options.i18n.nextButton"
:aria-label="$options.i18n.nextButton"
- icon="angle-right"
+ icon="chevron-lg-right"
class="js-next-design"
@click="navigateToDesign(nextDesign)"
/>
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
index b84fe45b77e..6d571365306 100644
--- a/app/assets/javascripts/design_management/components/toolbar/index.vue
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlIcon, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import { __, s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -14,6 +14,7 @@ export default {
components: {
GlButton,
GlIcon,
+ GlSkeletonLoader,
DesignNavigation,
DeleteButton,
},
@@ -61,6 +62,10 @@ export default {
type: String,
required: true,
},
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -113,7 +118,8 @@ export default {
<gl-icon name="close" />
</router-link>
<div class="gl-overflow-hidden gl-display-flex gl-align-items-center">
- <h2 class="gl-m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
+ <gl-skeleton-loader v-if="isLoading" :lines="1" />
+ <h2 v-else class="gl-m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
<small v-if="updatedAt" class="gl-text-gray-500">{{ updatedText }}</small>
</div>
</div>
@@ -130,7 +136,7 @@ export default {
v-gl-tooltip.bottom
class="gl-ml-3"
:is-deleting="isDeleting"
- button-variant="warning"
+ button-variant="default"
button-icon="archive"
button-category="secondary"
:title="s__('DesignManagement|Archive design')"
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 2b395921ee1..1825ce7f092 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { GlAlert } from '@gitlab/ui';
import { isNull } from 'lodash';
import Mousetrap from 'mousetrap';
import { ApolloMutation } from 'vue-apollo';
@@ -56,7 +56,6 @@ export default {
DesignScaler,
DesignDestroyer,
Toolbar,
- GlLoadingIcon,
GlAlert,
DesignSidebar,
},
@@ -118,10 +117,8 @@ export default {
},
},
computed: {
- isFirstLoading() {
- // We only want to show spinner on initial design load (when opened from a deep link to design)
- // If we already have cached a design, loading shouldn't be indicated to user
- return this.$apollo.queries.design.loading && !this.design.filename;
+ isLoading() {
+ return this.$apollo.queries.design.loading;
},
discussions() {
if (!this.design.discussions) {
@@ -343,88 +340,88 @@ export default {
<div
class="design-detail js-design-detail fixed-top gl-w-full gl-bottom-0 gl-display-flex gl-justify-content-center gl-flex-direction-column gl-lg-flex-direction-row"
>
- <gl-loading-icon v-if="isFirstLoading" size="xl" class="gl-align-self-center" />
- <template v-else>
- <div
- class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
+ <div
+ class="gl-display-flex gl-overflow-hidden gl-flex-grow-1 gl-flex-direction-column gl-relative"
+ >
+ <design-destroyer
+ :filenames="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
+ design.filename,
+ ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :project-path="projectPath"
+ :iid="issueIid"
+ @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
+ @error="onDesignDeleteError"
>
- <design-destroyer
- :filenames="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- design.filename,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- :project-path="projectPath"
- :iid="issueIid"
- @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
- @error="onDesignDeleteError"
- >
- <template #default="{ mutate, loading }">
- <toolbar
- :id="id"
- :is-deleting="loading"
- :is-latest-version="isLatestVersion"
- v-bind="design"
- @delete="mutate"
- />
- </template>
- </design-destroyer>
+ <template #default="{ mutate, loading }">
+ <toolbar
+ :id="id"
+ :is-deleting="loading"
+ :is-latest-version="isLatestVersion"
+ :is-loading="isLoading"
+ v-bind="design"
+ @delete="mutate"
+ />
+ </template>
+ </design-destroyer>
- <div v-if="errorMessage" class="gl-p-5">
- <gl-alert variant="danger" @dismiss="errorMessage = null">
- {{ errorMessage }}
- </gl-alert>
- </div>
- <design-presentation
- :image="design.image"
- :image-name="design.filename"
- :discussions="discussions"
- :is-annotating="isAnnotating"
- :scale="scale"
- :resolved-discussions-expanded="resolvedDiscussionsExpanded"
- @openCommentForm="openCommentForm"
- @closeCommentForm="closeCommentForm"
- @moveNote="onMoveNote"
- @setMaxScale="setMaxScale"
- />
-
- <div
- class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
- >
- <design-scaler :max-scale="maxScale" @scale="scale = $event" />
- </div>
+ <div v-if="errorMessage" class="gl-p-5">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
</div>
- <design-sidebar
- :design="design"
+ <design-presentation
+ :image="design.image"
+ :image-name="design.filename"
+ :discussions="discussions"
+ :is-annotating="isAnnotating"
+ :scale="scale"
:resolved-discussions-expanded="resolvedDiscussionsExpanded"
- :markdown-preview-path="markdownPreviewPath"
- @onDesignDiscussionError="onDesignDiscussionError"
- @onCreateImageDiffNoteError="onCreateImageDiffNoteError"
- @updateNoteError="onUpdateNoteError"
- @resolveDiscussionError="onResolveDiscussionError"
- @toggleResolvedComments="toggleResolvedComments"
- @todoError="onTodoError"
+ :is-loading="isLoading"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="onMoveNote"
+ @setMaxScale="setMaxScale"
+ />
+
+ <div
+ class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
>
- <template #reply-form>
- <apollo-mutation
- v-if="isAnnotating"
- #default="{ mutate, loading }"
- :mutation="$options.createImageDiffNoteMutation"
- :variables="{
- input: mutationPayload,
- }"
- :update="addImageDiffNoteToStore"
- @done="closeCommentForm"
- @error="onCreateImageDiffNoteError"
- >
- <design-reply-form
- ref="newDiscussionForm"
- v-model="comment"
- :is-saving="loading"
- :markdown-preview-path="markdownPreviewPath"
- @submit-form="mutate"
- @cancel-form="closeCommentForm"
- /> </apollo-mutation
- ></template>
- </design-sidebar>
- </template>
+ <design-scaler :max-scale="maxScale" @scale="scale = $event" />
+ </div>
+ </div>
+ <design-sidebar
+ :design="design"
+ :resolved-discussions-expanded="resolvedDiscussionsExpanded"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-loading="isLoading"
+ @onDesignDiscussionError="onDesignDiscussionError"
+ @onCreateImageDiffNoteError="onCreateImageDiffNoteError"
+ @updateNoteError="onUpdateNoteError"
+ @resolveDiscussionError="onResolveDiscussionError"
+ @toggleResolvedComments="toggleResolvedComments"
+ @todoError="onTodoError"
+ >
+ <template #reply-form>
+ <apollo-mutation
+ v-if="isAnnotating"
+ #default="{ mutate, loading }"
+ :mutation="$options.createImageDiffNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addImageDiffNoteToStore"
+ @done="closeCommentForm"
+ @error="onCreateImageDiffNoteError"
+ >
+ <design-reply-form
+ ref="newDiscussionForm"
+ v-model="comment"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ @submit-form="mutate"
+ @cancel-form="closeCommentForm"
+ /> </apollo-mutation
+ ></template>
+ </design-sidebar>
</div>
</template>
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 42d5d8fb359..f81d4f6662f 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -1,5 +1,6 @@
<script>
import { GlLoadingIcon, GlButton, GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import VueDraggable from 'vuedraggable';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
@@ -97,6 +98,9 @@ export default {
isSaving() {
return this.filesToBeSaved.length > 0;
},
+ isMobile() {
+ return GlBreakpointInstance.getBreakpointSize() === 'xs';
+ },
canCreateDesign() {
return this.permissions.createDesign;
},
@@ -354,7 +358,7 @@ export default {
>
<header
v-if="showToolbar"
- class="row-content-block gl-border-t-0 gl-py-3 gl-display-flex"
+ class="gl-display-flex gl-my-0 gl-text-gray-900"
data-testid="design-toolbar-wrapper"
>
<div
@@ -370,7 +374,7 @@ export default {
>
<gl-button
v-if="isLatestVersion"
- variant="link"
+ category="tertiary"
size="small"
class="gl-mr-3"
data-testid="select-all-designs-button"
@@ -407,7 +411,7 @@ export default {
</div>
</header>
<div class="gl-mt-6">
- <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
</gl-alert>
@@ -429,7 +433,7 @@ export default {
<vue-draggable
v-else
:value="designs"
- :disabled="!isLatestVersion || isReorderingInProgress"
+ :disabled="!isLatestVersion || isReorderingInProgress || isMobile"
v-bind="$options.dragOptions"
tag="ol"
draggable=".js-design-tile"
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index a12829f8420..9f3fb715150 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -26,7 +26,8 @@ export default class Diff {
FilesCommentButton.init($diffFile);
const firstFile = $('.files').first().get(0);
- const canCreateNote = firstFile && firstFile.hasAttribute('data-can-create-note');
+ const canCreateNote =
+ firstFile && Object.prototype.hasOwnProperty.call(firstFile.dataset, 'canCreateNote');
$diffFile.each((index, file) => initImageDiffHelper.initImageDiff(file, canCreateNote));
if (!isBound) {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index c3436159cea..530f3a3a7f7 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -384,14 +384,26 @@ export default {
this.unwatchDiscussions = this.$watch(
() => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`,
- () => this.setDiscussions(),
+ () => {
+ this.setDiscussions();
+
+ if (
+ this.$store.state.notes.doneFetchingBatchDiscussions &&
+ window.gon?.features?.paginatedMrDiscussions
+ ) {
+ this.unwatchDiscussions();
+ }
+ },
);
this.unwatchRetrievingBatches = this.$watch(
() => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`,
() => {
if (!this.retrievingBatches && this.$store.state.notes.discussions.length) {
- this.unwatchDiscussions();
+ if (!window.gon?.features?.paginatedMrDiscussions) {
+ this.unwatchDiscussions();
+ }
+
this.unwatchRetrievingBatches();
}
},
diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
index b7eea32e699..ebb6ec1e7c8 100644
--- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue
@@ -55,7 +55,7 @@ export default {
{{ __('For a faster browsing experience, some files are collapsed by default.') }}
</p>
<template #actions>
- <gl-button category="secondary" variant="warning" class="gl-alert-action" @click="expand">
+ <gl-button class="gl-alert-action" @click="expand">
{{ __('Expand all files') }}
</gl-button>
</template>
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 42f4ea8eb58..54b648e8d03 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -7,8 +7,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import initUserPopovers from '../../user_popovers';
-
/**
* CommitItem
*
@@ -82,11 +80,6 @@ export default {
return this.commit.description_html.replace(/^&#x000A;/, '');
},
},
- created() {
- this.$nextTick(() => {
- initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
- });
- },
safeHtmlConfig: {
ADD_TAGS: ['gl-emoji'],
},
@@ -128,7 +121,7 @@ export default {
<div class="d-flex float-left align-items-center align-self-start">
<input
v-if="isSelectable"
- class="mr-2"
+ class="gl-mr-3"
type="checkbox"
:checked="checked"
@change="$emit('handleCheckboxChange', $event.target.checked)"
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 1eba12a3ae9..bfe35e9346d 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -120,7 +120,7 @@ export default {
:help-page-path="helpPagePath"
:inline="isInlineView"
/>
- <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
+ <gl-loading-icon v-if="diffFile.renderingLines" size="lg" class="mt-3" />
</template>
<not-diffable-viewer v-else-if="notDiffable" />
<no-preview-viewer v-else-if="noPreview" />
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 4e7dc578193..fc5766a23ef 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -3,7 +3,6 @@ import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, GlLoadingIcon } from '
import { mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants';
import * as utils from '../store/utils';
@@ -24,7 +23,6 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
file: {
type: Object,
@@ -93,25 +91,16 @@ export default {
nextLineNumbers = {},
) {
this.loadMoreLines({ endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers })
- .then(() => {
- this.isRequesting = false;
- })
.catch(() => {
createFlash({
message: s__('Diffs|Something went wrong while fetching diff lines.'),
});
- this.isRequesting = false;
})
.finally(() => {
this.loading = { up: false, down: false, all: false };
});
},
handleExpandLines(type = EXPAND_ALL) {
- if (this.isRequesting) {
- return;
- }
-
- this.isRequesting = true;
const endpoint = this.file.context_lines_path;
const oldLineNumber = this.line.meta_data.old_pos || 0;
const newLineNumber = this.line.meta_data.new_pos || 0;
@@ -228,10 +217,7 @@ export default {
</script>
<template>
- <div
- v-if="glFeatures.updatedDiffExpansionButtons"
- class="diff-grid-row diff-grid-row-full diff-tr line_holder match expansion"
- >
+ <div class="diff-grid-row diff-grid-row-full diff-tr line_holder match expansion">
<div :class="{ parallel: !inline }" class="diff-grid-left diff-grid-2-col left-side">
<div
class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column"
@@ -240,6 +226,7 @@ export default {
v-if="showExpandDown"
v-gl-tooltip.left
:title="s__('Diffs|Next 20 lines')"
+ :disabled="loading.down"
type="button"
class="js-unfold-down gl-rounded-0 gl-border-0 diff-line-expand-button"
@click="handleExpandLines($options.EXPAND_DOWN)"
@@ -251,6 +238,7 @@ export default {
v-if="lineCountBetween !== -1 && lineCountBetween < 20"
v-gl-tooltip.left
:title="s__('Diffs|Expand all lines')"
+ :disabled="loading.all"
type="button"
class="js-unfold-all gl-rounded-0 gl-border-0 diff-line-expand-button"
@click="handleExpandLines()"
@@ -262,6 +250,7 @@ export default {
v-if="showExpandUp"
v-gl-tooltip.left
:title="s__('Diffs|Previous 20 lines')"
+ :disabled="loading.up"
type="button"
class="js-unfold gl-rounded-0 gl-border-0 diff-line-expand-button"
@click="handleExpandLines($options.EXPAND_UP)"
@@ -276,32 +265,4 @@ export default {
></div>
</div>
</div>
- <div v-else class="content js-line-expansion-content">
- <button
- type="button"
- :disabled="!canExpandDown"
- class="js-unfold-down gl-mx-2 gl-py-4 gl-cursor-pointer"
- @click="handleExpandLines($options.EXPAND_DOWN)"
- >
- <gl-icon :size="12" name="expand-down" />
- <span>{{ $options.i18n.showMore }}</span>
- </button>
- <button
- type="button"
- class="js-unfold-all gl-mx-2 gl-py-4 gl-cursor-pointer"
- @click="handleExpandLines()"
- >
- <gl-icon :size="12" name="expand" />
- <span>{{ $options.i18n.showAll }}</span>
- </button>
- <button
- type="button"
- :disabled="!canExpandUp"
- class="js-unfold gl-mx-2 gl-py-4 gl-cursor-pointer"
- @click="handleExpandLines($options.EXPAND_UP)"
- >
- <gl-icon :size="12" name="expand-up" />
- <span>{{ $options.i18n.showMore }}</span>
- </button>
- </div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 0b82be7140c..aec608007d5 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -456,12 +456,7 @@ export default {
<p class="gl-mb-5">
{{ $options.i18n.autoCollapsed }}
</p>
- <gl-button
- data-testid="expand-button"
- category="secondary"
- variant="warning"
- @click.prevent="handleToggle"
- >
+ <gl-button data-testid="expand-button" @click.prevent="handleToggle">
{{ $options.i18n.expand }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index a75262ee303..07316f9433a 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -19,8 +19,6 @@ import { scrollToElement } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import FileIcon from '~/vue_shared/components/file_icon.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants';
import { DIFF_FILE_HEADER } from '../i18n';
@@ -33,7 +31,6 @@ export default {
components: {
ClipboardButton,
GlIcon,
- FileIcon,
DiffStats,
GlBadge,
GlButton,
@@ -48,7 +45,7 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [glFeatureFlagsMixin(), IdState({ idProp: (vm) => vm.diffFile.file_hash })],
+ mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash })],
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
@@ -301,14 +298,6 @@ export default {
:href="titleLink"
@click="handleFileNameClick"
>
- <file-icon
- v-if="!glFeatures.removeDiffHeaderIcons"
- :file-name="filePath"
- :size="16"
- aria-hidden="true"
- css-classes="gl-mr-2"
- :submodule="diffFile.submodule"
- />
<span v-if="isFileRenamed">
<strong
v-gl-tooltip
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 a2f0e2c2653..ebc68bafb9a 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -3,6 +3,7 @@ import { mapState, mapGetters, mapActions } from 'vuex';
import { s__, __ } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
@@ -175,7 +176,10 @@ export default {
'saveDiffDiscussion',
'setSuggestPopoverDismissed',
]),
- async handleCancelCommentForm(shouldConfirm, isDirty) {
+ handleCancelCommentForm: ignoreWhilePending(async function handleCancelCommentForm(
+ shouldConfirm,
+ isDirty,
+ ) {
if (shouldConfirm && isDirty) {
const msg = s__('Notes|Are you sure you want to cancel creating this comment?');
@@ -195,7 +199,7 @@ export default {
this.$nextTick(() => {
this.resetAutoSave();
});
- },
+ }),
handleSaveNote(note) {
return this.saveDiffDiscussion({ note, formData: this.formData }).then(() =>
this.handleCancelCommentForm(),
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 529f8e0a2f9..d740d5adcb6 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -6,7 +6,6 @@ import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
@@ -23,11 +22,7 @@ export default {
directives: {
SafeHtml,
},
- mixins: [
- draftCommentsMixin,
- glFeatureFlagsMixin(),
- IdState({ idProp: (vm) => vm.diffFile.file_hash }),
- ],
+ mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
props: {
diffFile: {
type: Object,
@@ -171,7 +166,6 @@ export default {
<template v-for="(line, index) in diffLines">
<template v-if="line.isMatchLineLeft || line.isMatchLineRight">
<diff-expansion-cell
- v-if="glFeatures.updatedDiffExpansionButtons"
:key="`expand-${index}`"
:file="diffFile"
:line="line.left"
@@ -180,41 +174,6 @@ export default {
:inline="inline"
:line-count-between="getCountBetweenIndex(index)"
/>
- <template v-else>
- <div :key="`expand-${index}`" class="diff-tr line_expansion old-line_expansion match">
- <div class="diff-td text-center gl-font-regular">
- <diff-expansion-cell
- :file="diffFile"
- :context-lines-path="diffFile.context_lines_path"
- :line="line.left"
- :is-top="index === 0"
- :is-bottom="index + 1 === diffLinesLength"
- :inline="inline"
- />
- </div>
- </div>
- <div
- v-if="line.left.rich_text"
- :key="`expand-definition-${index}`"
- class="diff-grid-row diff-tr line_holder match"
- >
- <div class="diff-grid-left diff-grid-3-col left-side">
- <div class="diff-td diff-line-num"></div>
- <div v-if="inline" class="diff-td diff-line-num"></div>
- <div
- v-safe-html="line.left.rich_text"
- class="diff-td line_content left-side gl-white-space-normal!"
- ></div>
- </div>
- <div v-if="!inline" class="diff-grid-right diff-grid-3-col right-side">
- <div class="diff-td diff-line-num"></div>
- <div
- v-safe-html="line.left.rich_text"
- class="diff-td line_content right-side gl-white-space-normal!"
- ></div>
- </div>
- </div>
- </template>
</template>
<diff-row
v-if="!line.isMatchLineLeft && !line.isMatchLineRight"
diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
index 6e1e6f5c2d0..c37a1d75650 100644
--- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
+++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
@@ -44,8 +44,8 @@ export default {
<gl-button
v-if="resolutionPath"
:href="resolutionPath"
- variant="info"
- class="gl-mr-5 gl-alert-action"
+ variant="confirm"
+ class="gl-mr-3 gl-alert-action"
>
{{ __('Resolve conflicts') }}
</gl-button>
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 92f3cf83740..cf86ebea4a9 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -126,14 +126,6 @@ export function findDiffFile(files, match, matchKey = 'file_hash') {
return files.find((file) => file[matchKey] === match);
}
-export const getReversePosition = (linePosition) => {
- if (linePosition === LINE_POSITION_RIGHT) {
- return LINE_POSITION_LEFT;
- }
-
- return LINE_POSITION_RIGHT;
-};
-
export function getFormData(params) {
const {
commit,
diff --git a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
index b41eae88c54..b33dcba2b7d 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_ci_schema_ext.js
@@ -17,10 +17,14 @@ export class CiSchemaExtension {
const absoluteSchemaUrl = new URL(ciSchemaPath, gon.gitlab_url).href;
const modelFileName = instance.getModel().uri.path.split('/').pop();
- registerSchema({
- uri: absoluteSchemaUrl,
- fileMatch: [modelFileName],
- });
+ registerSchema(
+ {
+ uri: absoluteSchemaUrl,
+ fileMatch: [modelFileName],
+ },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ { customTags: ['!reference sequence'] },
+ );
},
};
}
diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
index 11cc85c659d..e4ad0bf8e76 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js
@@ -36,6 +36,8 @@ const setupDomElement = ({ injectToEl = null } = {}) => {
return previewEl;
};
+let dimResize = false;
+
export class EditorMarkdownPreviewExtension {
static get extensionName() {
return 'EditorMarkdownPreview';
@@ -50,6 +52,7 @@ export class EditorMarkdownPreviewExtension {
},
shown: false,
modelChangeListener: undefined,
+ layoutChangeListener: undefined,
path: setupOptions.previewMarkdownPath,
actionShowPreviewCondition: instance.createContextKey('toggleLivePreview', true),
};
@@ -59,6 +62,14 @@ export class EditorMarkdownPreviewExtension {
if (instance.toolbar) {
this.setupToolbar(instance);
}
+
+ this.preview.layoutChangeListener = instance.onDidLayoutChange(() => {
+ if (instance.markdownPreview?.shown && !dimResize) {
+ const { width } = instance.getLayoutInfo();
+ const newWidth = width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
+ EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
+ }
+ });
}
onBeforeUnuse(instance) {
@@ -70,6 +81,9 @@ export class EditorMarkdownPreviewExtension {
}
cleanup(instance) {
+ if (this.preview.layoutChangeListener) {
+ this.preview.layoutChangeListener.dispose();
+ }
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
@@ -82,6 +96,15 @@ export class EditorMarkdownPreviewExtension {
this.preview.shown = false;
}
+ static resizePreviewLayout(instance, width) {
+ const { height } = instance.getLayoutInfo();
+ dimResize = true;
+ instance.layout({ width, height });
+ window.requestAnimationFrame(() => {
+ dimResize = false;
+ });
+ }
+
setupToolbar(instance) {
this.toolbarButtons = [
{
@@ -99,11 +122,11 @@ export class EditorMarkdownPreviewExtension {
}
togglePreviewLayout(instance) {
- const { width, height } = instance.getLayoutInfo();
+ const { width } = instance.getLayoutInfo();
const newWidth = this.preview.shown
? width / EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH
: width * EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH;
- instance.layout({ width: newWidth, height });
+ EditorMarkdownPreviewExtension.resizePreviewLayout(instance, newWidth);
}
togglePreviewPanel(instance) {
diff --git a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
index 4e8c11bac54..6270517b3f3 100644
--- a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
+++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js
@@ -7,7 +7,6 @@
* @property {Object} options The Monaco editor options
*/
-import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
import Disposable from '~/ide/lib/common/disposable';
@@ -59,13 +58,10 @@ const renderSideBySide = (domElement) => {
return domElement.offsetWidth >= 700;
};
-const updateInstanceDimensions = (instance) => {
- instance.layout();
- if (isDiffEditorType(instance)) {
- instance.updateOptions({
- renderSideBySide: renderSideBySide(instance.getDomNode()),
- });
- }
+const updateDiffInstanceRendering = (instance) => {
+ instance.updateOptions({
+ renderSideBySide: renderSideBySide(instance.getDomNode()),
+ });
};
export class EditorWebIdeExtension {
@@ -85,15 +81,14 @@ export class EditorWebIdeExtension {
this.options = setupOptions.options;
this.disposable = new Disposable();
- this.debouncedUpdate = debounce(() => {
- updateInstanceDimensions(instance);
- }, UPDATE_DIMENSIONS_DELAY);
-
addActions(instance, setupOptions.store);
- }
- onUse(instance) {
- window.addEventListener('resize', this.debouncedUpdate, false);
+ if (isDiffEditorType(instance)) {
+ updateDiffInstanceRendering(instance);
+ instance.getModifiedEditor().onDidLayoutChange(() => {
+ updateDiffInstanceRendering(instance);
+ });
+ }
instance.onDidDispose(() => {
this.onUnuse();
@@ -101,8 +96,6 @@ export class EditorWebIdeExtension {
}
onUnuse() {
- window.removeEventListener('resize', this.debouncedUpdate);
-
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
@@ -149,7 +142,6 @@ export class EditorWebIdeExtension {
modified: model.getModel(),
});
},
- updateDimensions: (instance) => updateInstanceDimensions(instance),
setPos: (instance, { lineNumber, column }) => {
instance.revealPositionInCenter({
lineNumber,
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index 1352211b927..c8015f884b7 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://gitlab.com/.gitlab-ci.yml",
"title": "Gitlab CI configuration",
- "description": "Gitlab has a built-in solution for doing CI called Gitlab CI. It is configured by supplying a file called `.gitlab-ci.yml`, which will list all the jobs that are going to run for the project. A full list of all options can be found at https://docs.gitlab.com/ee/ci/yaml/. You can read more about Gitlab CI at https://docs.gitlab.com/ee/ci/README.html.",
+ "markdownDescription": "Gitlab has a built-in solution for doing CI called Gitlab CI. It is configured by supplying a file called `.gitlab-ci.yml`, which will list all the jobs that are going to run for the project. A full list of all options can be found [here](https://docs.gitlab.com/ee/ci/yaml). [Learn More](https://docs.gitlab.com/ee/ci/index.html).",
"type": "object",
"properties": {
"$schema": {
@@ -15,6 +15,7 @@
"after_script": { "$ref": "#/definitions/after_script" },
"variables": { "$ref": "#/definitions/globalVariables" },
"cache": { "$ref": "#/definitions/cache" },
+ "!reference": {"$ref" : "#/definitions/!reference"},
"default": {
"type": "object",
"properties": {
@@ -27,13 +28,14 @@
"retry": { "$ref": "#/definitions/retry" },
"services": { "$ref": "#/definitions/services" },
"tags": { "$ref": "#/definitions/tags" },
- "timeout": { "$ref": "#/definitions/timeout" }
+ "timeout": { "$ref": "#/definitions/timeout" },
+ "!reference": {"$ref" : "#/definitions/!reference"}
},
"additionalProperties": false
},
"stages": {
"type": "array",
- "description": "Groups jobs into stages. All jobs in one stage must complete before next stage is executed. Defaults to ['build', 'test', 'deploy'].",
+ "markdownDescription": "Groups jobs into stages. All jobs in one stage must complete before next stage is executed. Defaults to ['build', 'test', 'deploy']. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#stages).",
"default": ["build", "test", "deploy"],
"items": {
"type": "string"
@@ -42,7 +44,7 @@
"minItems": 1
},
"include": {
- "description": "Can be `IncludeItem` or `IncludeItem[]`. Each `IncludeItem` will be a string, or an object with properties for the method if including external YAML file. The external content will be fetched, included and evaluated along the `.gitlab-ci.yml`.",
+ "markdownDescription": "Can be `IncludeItem` or `IncludeItem[]`. Each `IncludeItem` will be a string, or an object with properties for the method if including external YAML file. The external content will be fetched, included and evaluated along the `.gitlab-ci.yml`. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#include).",
"oneOf": [
{ "$ref": "#/definitions/include_item" },
{
@@ -53,7 +55,7 @@
},
"pages": {
"$ref": "#/definitions/job",
- "description": "A special job used to upload static sites to Gitlab pages. Requires a `public/` directory with `artifacts.path` pointing to it."
+ "markdownDescription": "A special job used to upload static sites to Gitlab pages. Requires a `public/` directory with `artifacts.path` pointing to it. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#pages)."
},
"workflow": {
"type": "object",
@@ -61,7 +63,10 @@
"rules": {
"type": "array",
"items": {
- "type": "object",
+ "anyOf": [
+ {"type": "object"},
+ {"type": "array", "minLength": 1, "items": { "type": "string" }}
+ ],
"properties": {
"if": { "$ref": "#/definitions/if" },
"changes": { "$ref": "#/definitions/changes" },
@@ -93,12 +98,12 @@
"definitions": {
"artifacts": {
"type": "object",
- "description": "Used to specify a list of files and directories that should be attached to the job if it succeeds. Artifacts are sent to Gitlab where they can be downloaded.",
+ "markdownDescription": "Used to specify a list of files and directories that should be attached to the job if it succeeds. Artifacts are sent to Gitlab where they can be downloaded. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifacts).",
"additionalProperties": false,
"properties": {
"paths": {
"type": "array",
- "description": "A list of paths to files/folders that should be included in the artifact.",
+ "markdownDescription": "A list of paths to files/folders that should be included in the artifact. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactspaths).",
"items": {
"type": "string"
},
@@ -106,7 +111,7 @@
},
"exclude": {
"type": "array",
- "description": "A list of paths to files/folders that should be excluded in the artifact.",
+ "markdownDescription": "A list of paths to files/folders that should be excluded in the artifact. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsexclude).",
"items": {
"type": "string"
},
@@ -114,19 +119,19 @@
},
"expose_as": {
"type": "string",
- "description": "Can be used to expose job artifacts in the merge request UI. GitLab will add a link <expose_as> to the relevant merge request that points to the artifact."
+ "markdownDescription": "Can be used to expose job artifacts in the merge request UI. GitLab will add a link <expose_as> to the relevant merge request that points to the artifact. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsexpose_as)."
},
"name": {
"type": "string",
- "description": "Name for the archive created on job success. Can use variables in the name, e.g. '$CI_JOB_NAME'"
+ "markdownDescription": "Name for the archive created on job success. Can use variables in the name, e.g. '$CI_JOB_NAME' [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsname)."
},
"untracked": {
"type": "boolean",
- "description": "Whether to add all untracked files (along with 'artifacts.paths') to the artifact.",
+ "markdownDescription": "Whether to add all untracked files (along with 'artifacts.paths') to the artifact. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsuntracked).",
"default": false
},
"when": {
- "description": "Configure when artifacts are uploaded depended on job status.",
+ "markdownDescription": "Configure when artifacts are uploaded depended on job status. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactswhen).",
"default": "on_success",
"oneOf": [
{
@@ -145,12 +150,12 @@
},
"expire_in": {
"type": "string",
- "description": "How long artifacts should be kept. They are saved 30 days by default. Artifacts that have expired are removed periodically via cron job. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'.",
+ "markdownDescription": "How long artifacts should be kept. They are saved 30 days by default. Artifacts that have expired are removed periodically via cron job. Supports a wide variety of formats, e.g. '1 week', '3 mins 4 sec', '2 hrs 20 min', '2h20min', '6 mos 1 day', '47 yrs 6 mos and 4d', '3 weeks and 2 days'. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsexpire_in).",
"default": "30 days"
},
"reports": {
"type": "object",
- "description": "Reports will be uploaded as artifacts, and often displayed in the Gitlab UI, such as in Merge Requests.",
+ "markdownDescription": "Reports will be uploaded as artifacts, and often displayed in the Gitlab UI, such as in Merge Requests. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactsreports).",
"additionalProperties": false,
"properties": {
"junit": {
@@ -341,6 +346,13 @@
}
]
},
+ "!reference": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ }
+ },
"image": {
"oneOf": [
{
@@ -362,16 +374,43 @@
"type": "array",
"description": "Command or script that should be executed as the container's entrypoint. It will be translated to Docker's --entrypoint option while creating the container. The syntax is similar to Dockerfile's ENTRYPOINT directive, where each shell token is a separate string in the array.",
"minItems": 1
+ },
+ "pull_policy": {
+ "markdownDescription": "Specifies how to pull the image in Runner. It can be one of `always`, `never` or `if-not-present`. The default value is `always`. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#imagepull_policy).",
+ "default": "always",
+ "oneOf": [
+ {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "always",
+ "never",
+ "if-not-present"
+ ]
+ },
+ "minItems": 1,
+ "uniqueItems": true
+ }
+ ]
}
},
"required": ["name"]
}
],
- "description": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor."
+ "markdownDescription": "Specifies the docker image to use for the job or globally for all jobs. Job configuration takes precedence over global setting. Requires a certain kind of Gitlab runner executor. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#image)."
},
"services": {
"type": "array",
- "description": "Similar to `image` property, but will link the specified services to the `image` container.",
+ "markdownDescription": "Similar to `image` property, but will link the specified services to the `image` container. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#services).",
"items": {
"oneOf": [
{
@@ -418,7 +457,7 @@
},
"secrets": {
"type": "object",
- "description": "Defines secrets to be injected as environment variables",
+ "markdownDescription": "Defines secrets to be injected as environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secrets).",
"additionalProperties": {
"type": "object",
"description": "Environment variable name",
@@ -453,7 +492,7 @@
},
"before_script": {
"type": "array",
- "description": "Defines scripts that should run *before* the job. Can be set globally or per job.",
+ "markdownDescription": "Defines scripts that should run *before* the job. Can be set globally or per job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#before_script).",
"items": {
"anyOf": [
{
@@ -470,7 +509,7 @@
},
"after_script": {
"type": "array",
- "description": "Defines scripts that should run *after* the job. Can be set globally or per job.",
+ "markdownDescription": "Defines scripts that should run *after* the job. Can be set globally or per job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#after_script).",
"items": {
"anyOf": [
{
@@ -487,27 +526,41 @@
},
"rules": {
"type": "array",
- "description": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job.",
+ "markdownDescription": "Rules allows for an array of individual rule objects to be evaluated in order, until one matches and dynamically provides attributes to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rules).",
"items": {
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "if": { "$ref": "#/definitions/if" },
- "changes": { "$ref": "#/definitions/changes" },
- "exists": { "$ref": "#/definitions/exists" },
- "variables": { "$ref": "#/definitions/variables" },
- "when": { "$ref": "#/definitions/when" },
- "start_in": { "$ref": "#/definitions/start_in" },
- "allow_failure": { "$ref": "#/definitions/allow_failure" }
- }
+ "anyOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "if": { "$ref": "#/definitions/if" },
+ "changes": { "$ref": "#/definitions/changes" },
+ "exists": { "$ref": "#/definitions/exists" },
+ "variables": { "$ref": "#/definitions/variables" },
+ "when": { "$ref": "#/definitions/when" },
+ "start_in": { "$ref": "#/definitions/start_in" },
+ "allow_failure": { "$ref": "#/definitions/allow_failure" }
+ }
+ },
+ {"type": "string", "minLength": 1},
+ {"type": "array", "minLength": 1, "items": { "type": "string" }}
+ ]
}
},
"globalVariables": {
- "description": "Defines environment variables globally. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. You can use the value and description keywords to define variables that are prefilled when running a pipeline manually.",
- "type": "object",
+ "markdownDescription": "Defines environment variables globally. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. You can use the value and description keywords to define variables that are prefilled when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#variables).",
+ "anyOf": [
+ {"type": "object"},
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ],
"additionalProperties": {
"anyOf": [
- {"type": ["string", "integer"]},
+ {"type": ["string", "integer", "array"]},
{
"type": "object",
"properties": {
@@ -523,41 +576,51 @@
},
"if": {
"type": "string",
- "description": "Expression to evaluate whether additional attributes should be provided to the job"
+ "markdownDescription": "Expression to evaluate whether additional attributes should be provided to the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesif)."
},
"changes": {
"type": "array",
- "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file",
+ "markdownDescription": "Additional attributes will be provided to job if any of the provided paths matches a modified file. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#ruleschanges).",
"items": {
"type": "string"
}
},
"exists": {
"type": "array",
- "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository",
+ "markdownDescription": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesexists).",
"items": {
"type": "string"
}
},
"variables": {
- "type": "object",
- "description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.",
- "additionalProperties": {
- "type": ["string", "integer"]
- }
+ "markdownDescription": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#rulesvariables).",
+ "anyOf": [
+ {
+ "type": "object",
+ "additionalProperties": {
+ "type": ["string", "integer", "array"]
+ }
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
},
"timeout": {
"type": "string",
- "description": "Allows you to configure a timeout for a specific job (e.g. `1 minute`, `1h 30m 12s`). Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#timeout",
+ "markdownDescription": "Allows you to configure a timeout for a specific job (e.g. `1 minute`, `1h 30m 12s`). [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#timeout).",
"minLength": 1
},
"start_in": {
"type": "string",
- "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. Read more: https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay",
+ "markdownDescription": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. [Learn More](https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay).",
"minLength": 1
},
"allow_failure": {
- "description": "Allow job to fail. A failed job does not cause the pipeline to fail.",
+ "markdownDescription": "Allow job to fail. A failed job does not cause the pipeline to fail. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#allow_failure).",
"oneOf": [
{
"description": "Setting this option to true will allow the job to fail while still letting the pipeline pass.",
@@ -594,7 +657,7 @@
]
},
"when": {
- "description": "Describes the conditions for when to run the job. Defaults to 'on_success'.",
+ "markdownDescription": "Describes the conditions for when to run the job. Defaults to 'on_success'.",
"default": "on_success",
"oneOf": [
{
@@ -611,11 +674,11 @@
},
{
"enum": ["manual"],
- "description": "Execute the job manually from Gitlab UI or API. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-manual"
+ "markdownDescription": "Execute the job manually from Gitlab UI or API. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when)."
},
{
"enum": ["delayed"],
- "description": "Execute a job after the time limit in 'start_in' expires. Read more: https://docs.gitlab.com/ee/ci/yaml/#when-delayed"
+ "markdownDescription": "Execute a job after the time limit in 'start_in' expires. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#when)."
},
{
"enum": ["never"],
@@ -626,7 +689,7 @@
"cache": {
"properties": {
"when": {
- "description": "Defines when to save the cache, based on the status of the job.",
+ "markdownDescription": "Defines when to save the cache, based on the status of the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachewhen).",
"default": "on_success",
"oneOf": [
{
@@ -778,7 +841,7 @@
},
"variables": {
"type": "array",
- "description": "Filter job by checking comparing values of environment variables. Read more about variable expressions: https://docs.gitlab.com/ee/ci/variables/README.html#variables-expressions",
+ "markdownDescription": "Filter job by checking comparing values of CI/CD variables. [Learn More](https://docs.gitlab.com/ee/ci/jobs/job_control.html#cicd-variable-expressions).",
"items": {
"type": "string"
}
@@ -795,7 +858,7 @@
]
},
"retry": {
- "description": "Retry a job if it fails. Can be a simple integer or object definition.",
+ "markdownDescription": "Retry a job if it fails. Can be a simple integer or object definition. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#retry).",
"oneOf": [
{ "$ref": "#/definitions/retry_max" },
{
@@ -804,7 +867,7 @@
"properties": {
"max": { "$ref": "#/definitions/retry_max" },
"when": {
- "description": "Either a single or array of error types to trigger job retry.",
+ "markdownDescription": "Either a single or array of error types to trigger job retry. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#retrywhen).",
"oneOf": [
{ "$ref": "#/definitions/retry_errors" },
{
@@ -884,19 +947,12 @@
},
"interruptible": {
"type": "boolean",
- "description": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run.",
+ "markdownDescription": "Interruptible is used to indicate that a job should be canceled if made redundant by a newer pipeline run. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#interruptible).",
"default": false
},
"job": {
"allOf": [
- { "$ref": "#/definitions/job_template" },
- {
- "anyOf": [
- { "required": ["script"] },
- { "required": ["extends"] },
- { "required": ["trigger"] }
- ]
- }
+ { "$ref": "#/definitions/job_template" }
]
},
"job_template": {
@@ -912,7 +968,7 @@
"cache": { "$ref": "#/definitions/cache" },
"secrets": { "$ref": "#/definitions/secrets" },
"script": {
- "description": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues.",
+ "markdownDescription": "Shell scripts executed by the Runner. The only required property of jobs. Be careful with special characters (e.g. `:`, `{`, `}`, `&`) and use single or double quotes to avoid issues. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#script)",
"oneOf": [
{
"type": "string",
@@ -1241,11 +1297,11 @@
"description": "Limit job concurrency. Can be used to ensure that the Runner will not run certain jobs simultaneously."
},
"trigger": {
- "description": "Trigger allows you to define downstream pipeline trigger. When a job created from trigger definition is started by GitLab, a downstream pipeline gets created. Read more: https://docs.gitlab.com/ee/ci/yaml/README.html#trigger",
+ "markdownDescription": "Trigger allows you to define downstream pipeline trigger. When a job created from trigger definition is started by GitLab, a downstream pipeline gets created. [Learn More](https://docs.gitlab.com/ee/ci/yaml/index.html#trigger).",
"oneOf": [
{
"type": "object",
- "description": "Trigger a multi-project pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#specify-a-downstream-pipeline-branch",
+ "markdownDescription": "Trigger a multi-project pipeline. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#specify-a-downstream-pipeline-branch).",
"additionalProperties": false,
"properties": {
"project": {
@@ -1287,7 +1343,7 @@
},
{
"type": "object",
- "description": "Trigger a child pipeline. Read more: https://docs.gitlab.com/ee/ci/pipelines/parent_child_pipelines.html",
+ "description": "Trigger a child pipeline. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/parent_child_pipelines.html).",
"additionalProperties": false,
"properties": {
"include": {
@@ -1398,7 +1454,7 @@
}
},
{
- "description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. Read more: https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#define-multi-project-pipelines-in-your-gitlab-ciyml-file",
+ "markdownDescription": "Path to the project, e.g. `group/project`, or `group/sub-group/project`. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/multi_project_pipelines.html#define-multi-project-pipelines-in-your-gitlab-ciyml-file).",
"type": "string",
"pattern": "\\S/\\S"
}
@@ -1406,10 +1462,10 @@
},
"inherit": {
"type": "object",
- "description": "Controls inheritance of globally-defined defaults and variables. Boolean values control inheritance of all default: or variables: keywords. To inherit only a subset of default: or variables: keywords, specify what you wish to inherit. Anything not listed is not inherited.",
+ "markdownDescription": "Controls inheritance of globally-defined defaults and variables. Boolean values control inheritance of all default: or variables: keywords. To inherit only a subset of default: or variables: keywords, specify what you wish to inherit. Anything not listed is not inherited. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#inherit).",
"properties": {
"default": {
- "description": "Whether to inherit all globally-defined defaults or not. Or subset of inherited defaults",
+ "markdownDescription": "Whether to inherit all globally-defined defaults or not. Or subset of inherited defaults. [Learn more](https://docs.gitlab.com/ee/ci/yaml/#inheritdefault).",
"oneOf": [
{
"type": "boolean"
@@ -1435,7 +1491,7 @@
]
},
"variables": {
- "description": "Whether to inherit all globally-defined variables or not. Or subset of inherited variables",
+ "markdownDescription": "Whether to inherit all globally-defined variables or not. Or subset of inherited variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#inheritvariables).",
"oneOf": [
{ "type": "boolean" },
{
@@ -1470,7 +1526,7 @@
},
"tags": {
"type": "array",
- "description": "Used to select runners from the list of available runners. A runner must have all tags listed here to run the job.",
+ "markdownDescription": "Used to select runners from the list of available runners. A runner must have all tags listed here to run the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#tags).",
"items": {
"type": "string"
}
diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js
index fa749112ab5..d585dc009e6 100644
--- a/app/assets/javascripts/editor/source_editor.js
+++ b/app/assets/javascripts/editor/source_editor.js
@@ -75,6 +75,7 @@ export default class SourceEditor {
blobGlobalId,
instance,
isDiff,
+ language,
} = {}) {
if (!instance) {
return null;
@@ -82,7 +83,7 @@ export default class SourceEditor {
const uriFilePath = joinPaths(URI_PREFIX, blobGlobalId, blobPath);
const uri = Uri.file(uriFilePath);
const existingModel = monacoEditor.getModel(uri);
- const model = existingModel || monacoEditor.createModel(blobContent, undefined, uri);
+ const model = existingModel || monacoEditor.createModel(blobContent, language, uri);
if (!isDiff) {
instance.setModel(model);
return model;
@@ -132,6 +133,7 @@ export default class SourceEditor {
});
let model;
+ const language = instanceOptions.language || getBlobLanguage(blobPath);
if (instanceOptions.model !== null) {
model = SourceEditor.createEditorModel({
blobGlobalId,
@@ -140,6 +142,7 @@ export default class SourceEditor {
blobContent,
instance,
isDiff,
+ language,
});
}
diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js
index 6d47e1e2248..7b73da4465f 100644
--- a/app/assets/javascripts/editor/source_editor_extension.js
+++ b/app/assets/javascripts/editor/source_editor_extension.js
@@ -12,6 +12,6 @@ export default class EditorExtension {
}
get api() {
- return this.obj.provides?.();
+ return this.obj.provides?.() || {};
}
}
diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js
index a6eb4256561..7970a932095 100644
--- a/app/assets/javascripts/emoji/constants.js
+++ b/app/assets/javascripts/emoji/constants.js
@@ -19,3 +19,5 @@ export const CATEGORY_ROW_HEIGHT = 37;
export const CACHE_VERSION_KEY = 'gl-emoji-map-version';
export const CACHE_KEY = 'gl-emoji-map';
+
+export const NEUTRAL_INTENT_MULTIPLIER = 1;
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 4fdcdcc1b04..b9392fabcbd 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash';
import emojiRegexFactory from 'emoji-regex';
import emojiAliases from 'emojis/aliases.json';
import { setAttributes } from '~/lib/utils/dom_utils';
+import { getEmojiScoreWithIntent } from '~/emoji/utils';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
@@ -144,6 +145,11 @@ function getNameMatch(emoji, query) {
return null;
}
+// Sort emoji by emoji score falling back to a string comparison
+export function sortEmoji(a, b) {
+ return a.score - b.score || a.fieldValue.localeCompare(b.fieldValue);
+}
+
export function searchEmoji(query) {
const lowercaseQuery = query ? `${query}`.toLowerCase() : '';
@@ -156,16 +162,14 @@ export function searchEmoji(query) {
getDescriptionMatch(emoji, lowercaseQuery),
getAliasMatch(emoji, matchingAliases),
getNameMatch(emoji, lowercaseQuery),
- ].filter(Boolean);
+ ]
+ .filter(Boolean)
+ .map((x) => ({ ...x, score: getEmojiScoreWithIntent(x.emoji.name, x.score) }));
return minBy(matches, (x) => x.score);
})
- .filter(Boolean);
-}
-
-export function sortEmoji(items) {
- // Sort results by index of and string comparison
- return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue));
+ .filter(Boolean)
+ .sort(sortEmoji);
}
export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP);
diff --git a/app/assets/javascripts/emoji/utils.js b/app/assets/javascripts/emoji/utils.js
new file mode 100644
index 00000000000..eb3dcea73c0
--- /dev/null
+++ b/app/assets/javascripts/emoji/utils.js
@@ -0,0 +1,8 @@
+import emojiIntents from 'emojis/intents.json';
+import { NEUTRAL_INTENT_MULTIPLIER } from '~/emoji/constants';
+
+export function getEmojiScoreWithIntent(emojiName, baseScore) {
+ const intentMultiplier = emojiIntents[emojiName] || NEUTRAL_INTENT_MULTIPLIER;
+
+ return 2 ** baseScore * intentMultiplier;
+}
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index cec53869aa8..b2844ed5ad6 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -33,7 +33,7 @@ export default {
<template>
<div class="environments-container">
- <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" label="Loading environments" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" label="Loading environments" />
<slot name="empty-state"></slot>
diff --git a/app/assets/javascripts/environments/components/deploy_board_wrapper.vue b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
index d9d77088ad3..d1132bc6e24 100644
--- a/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
+++ b/app/assets/javascripts/environments/components/deploy_board_wrapper.vue
@@ -25,7 +25,7 @@ export default {
},
computed: {
icon() {
- return this.visible ? 'angle-down' : 'angle-right';
+ return this.visible ? 'chevron-lg-down' : 'chevron-lg-right';
},
label() {
return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
diff --git a/app/assets/javascripts/environments/components/environment_folder.vue b/app/assets/javascripts/environments/components/environment_folder.vue
index 788c3ba6fed..881f404340d 100644
--- a/app/assets/javascripts/environments/components/environment_folder.vue
+++ b/app/assets/javascripts/environments/components/environment_folder.vue
@@ -47,8 +47,8 @@ export default {
computed: {
icons() {
return this.visible
- ? { caret: 'angle-down', folder: 'folder-open' }
- : { caret: 'angle-right', folder: 'folder-o' };
+ ? { caret: 'chevron-lg-down', folder: 'folder-open' }
+ : { caret: 'chevron-lg-right', folder: 'folder-o' };
},
label() {
return this.visible ? this.$options.i18n.collapse : this.$options.i18n.expand;
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index 1d1d8d61b66..1bac0ef1359 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -81,27 +81,24 @@ export default {
</script>
<template>
<div>
- <h3 class="page-title">
+ <h1 class="page-title gl-font-size-h-display">
{{ title }}
- </h3>
- <hr />
- <div class="row gl-mt-3 gl-mb-3">
- <div class="col-lg-3">
- <h4 class="gl-mt-0">
- {{ $options.i18n.header }}
- </h4>
- <p>
- <gl-sprintf :message="$options.i18n.helpMessage">
- <template #link="{ content }">
- <gl-link :href="$options.helpPagePath">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- </div>
+ </h1>
+ <div class="row col-12">
+ <h4 class="gl-mt-0">
+ {{ $options.i18n.header }}
+ </h4>
+ <p class="gl-w-full">
+ <gl-sprintf :message="$options.i18n.helpMessage">
+ <template #link="{ content }">
+ <gl-link :href="$options.helpPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
<gl-form
id="new_environment"
:aria-label="title"
- class="col-lg-9"
+ class="gl-w-full"
@submit.prevent="$emit('submit')"
>
<gl-form-group
@@ -144,7 +141,7 @@ export default {
/>
</gl-form-group>
- <div class="form-actions">
+ <div class="gl-mr-6">
<gl-button
:loading="loading"
type="submit"
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index d71b553a878..13b9cf14f52 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -94,9 +94,9 @@ export default {
<template>
<header class="top-area gl-justify-content-between">
<div class="gl-display-flex gl-flex-grow-1 gl-align-items-center">
- <h3 class="page-title">
+ <h1 class="page-title gl-font-size-h-display">
{{ environment.name }}
- </h3>
+ </h1>
<p v-if="shouldShowCancelAutoStopButton" class="gl-mb-0 gl-ml-3" data-testid="auto-stops-at">
<gl-sprintf :message="$options.i18n.autoStopAtText">
<template #autoStopAt>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 7fcd6e5fff8..895a6cf2ccb 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -177,7 +177,7 @@ export default {
<template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
- <gl-loading-icon size="md" class="gl-mt-5" />
+ <gl-loading-icon size="lg" class="gl-mt-5" />
</div>
<template v-else>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index f5e9d612316..75bd473497b 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -83,7 +83,7 @@ export default {
},
computed: {
icon() {
- return this.visible ? 'angle-down' : 'angle-right';
+ return this.visible ? 'chevron-lg-down' : 'chevron-lg-right';
},
externalUrl() {
return this.environment.externalUrl;
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 0a8abdc90c6..a602c92a840 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -301,7 +301,7 @@ export default {
<gl-button
class="ml-2"
category="secondary"
- variant="info"
+ variant="confirm"
:loading="updatingResolveStatus"
data-testid="update-resolve-status-btn"
@click="onResolveStatusUpdate"
@@ -313,7 +313,7 @@ export default {
class="ml-2"
data-testid="view_issue_button"
:href="error.gitlabIssuePath"
- variant="success"
+ variant="confirm"
>
{{ __('View issue') }}
</gl-button>
@@ -364,7 +364,6 @@ export default {
v-if="error.gitlabIssuePath"
data-qa-selector="view_issue_button"
:href="error.gitlabIssuePath"
- variant="success"
>{{ __('View issue') }}</gl-dropdown-item
>
<gl-dropdown-item
@@ -382,7 +381,7 @@ export default {
<h2 class="text-truncate">{{ error.title }}</h2>
</tooltip-on-truncate>
<template v-if="error.tags">
- <gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="mr-2">
+ <gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="gl-mr-3">
{{ errorLevel }}
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="muted">{{ error.tags.logger }} </gl-badge>
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
index 9438900c736..a5e712f4fc2 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue
@@ -73,7 +73,7 @@ export default {
<gl-button
:href="detailsLink"
category="primary"
- variant="info"
+ variant="confirm"
class="gl-display-block d-md-none gl-mb-4 mb-md-0"
>
{{ __('More details') }}
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 86102fd54b1..d29d5aa0671 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -369,7 +369,7 @@ export default {
</div>
<div v-if="loading" class="py-3">
- <gl-loading-icon size="md" />
+ <gl-loading-icon size="lg" />
</div>
<template v-else>
diff --git a/app/assets/javascripts/feature_flags/components/empty_state.vue b/app/assets/javascripts/feature_flags/components/empty_state.vue
index a6de4972bb1..a66215cdae6 100644
--- a/app/assets/javascripts/feature_flags/components/empty_state.vue
+++ b/app/assets/javascripts/feature_flags/components/empty_state.vue
@@ -67,7 +67,7 @@ export default {
{{ message }}
</gl-alert>
- <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="md" class="gl-mt-4" />
+ <gl-loading-icon v-if="isLoading" :label="loadingLabel" size="lg" class="gl-mt-4" />
<gl-empty-state
v-else-if="errorState"
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 53909dcf42e..645c2456c6e 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -161,7 +161,7 @@ export default {
<gl-button
v-if="canUserConfigure"
v-gl-modal="'configure-feature-flags'"
- variant="info"
+ variant="confirm"
category="secondary"
data-qa-selector="configure_feature_flags_button"
data-testid="ff-configure-button"
@@ -184,7 +184,10 @@ export default {
class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
>
<div class="gl-display-flex gl-align-items-center">
- <h2 data-testid="feature-flags-tab-title" class="gl-font-size-h2 gl-my-0">
+ <h2
+ data-testid="feature-flags-tab-title"
+ class="page-title gl-font-size-h-display gl-my-0"
+ >
{{ s__('FeatureFlags|Feature Flags') }}
</h2>
<gl-badge v-if="count" class="gl-ml-4">{{ count }}</gl-badge>
@@ -197,7 +200,7 @@ export default {
:href="userListPath"
variant="confirm"
category="tertiary"
- class="gl-mb-0 gl-mr-4"
+ class="gl-mb-0 gl-mr-3"
data-testid="ff-user-list-button"
>
{{ s__('FeatureFlags|View user lists') }}
@@ -205,11 +208,11 @@ export default {
<gl-button
v-if="canUserConfigure"
v-gl-modal="'configure-feature-flags'"
- variant="info"
+ variant="confirm"
category="secondary"
data-qa-selector="configure_feature_flags_button"
data-testid="ff-configure-button"
- class="gl-mb-0 gl-mr-4"
+ class="gl-mb-0 gl-mr-3"
>
{{ s__('FeatureFlags|Configure') }}
</gl-button>
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index f8a8bed2467..f0f42d19ea5 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -81,6 +81,20 @@ export default {
});
},
},
+ modal: {
+ actionPrimary: {
+ text: s__('FeatureFlags|Delete feature flag'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
};
</script>
<template>
@@ -193,11 +207,11 @@ export default {
<gl-modal
:ref="modalId"
:title="modalTitle"
- :ok-title="s__('FeatureFlags|Delete feature flag')"
:modal-id="modalId"
title-tag="h4"
- ok-variant="danger"
- category="primary"
+ size="sm"
+ :action-primary="$options.modal.actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
@ok="onSubmit"
>
{{ deleteModalMessage }}
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 26da0d56f9a..8b79c661b12 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -188,8 +188,8 @@ export default {
<div class="row">
<div class="col-md-12">
<h4>{{ s__('FeatureFlags|Strategies') }}</h4>
- <div class="flex align-items-baseline justify-content-between">
- <p class="mr-3">{{ $options.translations.newHelpText }}</p>
+ <div class="gl-display-flex gl-align-items-baseline gl-justify-content-space-between">
+ <p class="gl-mr-5">{{ $options.translations.newHelpText }}</p>
<gl-button variant="confirm" category="secondary" @click="addStrategy">
{{ s__('FeatureFlags|Add strategy') }}
</gl-button>
@@ -206,21 +206,21 @@ export default {
@delete="deleteStrategy(strategy)"
/>
</div>
- <div v-else class="flex justify-content-center border-top py-4 w-100">
+ <div v-else class="gl-display-flex gl-justify-content-center gl-border-t gl-py-6 w-100">
<span>{{ $options.translations.noStrategiesText }}</span>
</div>
</fieldset>
- <div class="form-actions">
+ <div class="gl-mr-6">
<gl-button
ref="submitButton"
type="button"
variant="confirm"
- class="js-ff-submit col-xs-12"
+ class="js-ff-submit gl-mr-2"
@click="handleSubmit"
>{{ submitText }}</gl-button
>
- <gl-button :href="cancelPath" class="js-ff-cancel col-xs-12 float-right">
+ <gl-button :href="cancelPath" class="js-ff-cancel">
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
index 5575c6567b5..98982920121 100644
--- a/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
+++ b/app/assets/javascripts/feature_flags/components/new_environments_dropdown.vue
@@ -72,7 +72,7 @@ export default {
<span class="d-md-none mr-1">
{{ $options.translations.addEnvironmentsLabel }}
</span>
- <gl-icon class="d-none d-md-inline-flex" name="plus" />
+ <gl-icon class="d-none d-md-inline-flex gl-mr-1" name="plus" />
</template>
<gl-search-box-by-type
ref="searchBox"
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
index 865c1e677cd..bc05e88e643 100644
--- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -24,7 +24,7 @@ export default {
</script>
<template>
<div>
- <h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3>
+ <h1 class="page-title gl-font-size-h-display">{{ s__('FeatureFlags|New feature flag') }}</h1>
<gl-alert v-if="error.length" variant="warning" class="gl-mb-5" :dismissible="false">
<p v-for="(message, index) in error" :key="index" class="gl-mb-0">{{ message }}</p>
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 3f515dcdf18..1d7a79f926a 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -176,7 +176,7 @@ export default {
}}</label>
<div class="gl-display-flex gl-flex-direction-column">
<div
- class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center"
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-md-align-items-center"
>
<new-environments-dropdown
:id="environmentsDropdownId"
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index b57db73a86e..3913e4e8d81 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -197,10 +197,10 @@ export default class AvailableDropdownMappings {
}
getGroupId() {
- return this.filteredSearchInput.getAttribute('data-group-id') || '';
+ return this.filteredSearchInput.dataset.groupId || '';
}
getProjectId() {
- return this.filteredSearchInput.getAttribute('data-project-id') || '';
+ return this.filteredSearchInput.dataset.projectId || '';
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 9d29782c9a7..10c3a6a36d5 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -25,9 +25,9 @@ export default class DropdownHint extends FilteredSearchDropdown {
const { selected } = e.detail;
if (selected.tagName === 'LI') {
- if (selected.hasAttribute('data-value')) {
+ if (Object.prototype.hasOwnProperty.call(selected.dataset, 'value')) {
this.dismissDropdown();
- } else if (selected.getAttribute('data-action') === 'submit') {
+ } else if (selected.dataset.action === 'submit') {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index fb9f25a8c45..f3f159ab988 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -23,7 +23,7 @@ export default class DropdownOperator extends FilteredSearchDropdown {
const { selected } = e.detail;
if (selected.tagName === 'LI') {
- if (selected.hasAttribute('data-value')) {
+ if (Object.prototype.hasOwnProperty.call(selected.dataset, 'value')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
const operator = selected.dataset.value;
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 9a23ff25eac..26507a85fa8 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -31,11 +31,11 @@ export default class DropdownUser extends DropdownAjaxFilter {
}
getGroupId() {
- return this.input.getAttribute('data-group-id');
+ return this.input.dataset.groupId;
}
getProjectId() {
- return this.input.getAttribute('data-project-id');
+ return this.input.dataset.projectId;
}
projectOrGroupId() {
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index c98d1f8e064..22e1604871a 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -87,6 +87,7 @@ export default class DropdownUtils {
}
static setDataValueIfSelected(filter, operator, selected) {
+ // eslint-disable-next-line unicorn/prefer-dom-node-dataset
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
@@ -96,6 +97,7 @@ export default class DropdownUtils {
tokenValue: dataValue,
clicked: true,
options: {
+ // eslint-disable-next-line unicorn/prefer-dom-node-dataset
capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
},
});
diff --git a/app/assets/javascripts/filtered_search/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js
index 05b741af191..398a7b26677 100644
--- a/app/assets/javascripts/filtered_search/droplab/drop_down.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js
@@ -165,8 +165,8 @@ class DropDown {
images.forEach((image) => {
const img = image;
- img.src = img.getAttribute('data-src');
- img.removeAttribute('data-src');
+ img.src = img.dataset.src;
+ delete img.dataset.src;
});
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 07f2c75f00a..ac2cf27e873 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -814,7 +814,7 @@ export default class FilteredSearchManager {
getUsernameParams() {
const usernamesById = {};
try {
- const attribute = this.filteredSearchInput.getAttribute('data-username-params');
+ const attribute = this.filteredSearchInput.dataset.usernameParams;
JSON.parse(attribute).forEach((user) => {
usernamesById[user.id] = user.username;
});
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 6216ab5401d..359a276aa74 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -1,5 +1,3 @@
-import { __ } from '~/locale';
-
export default class FilteredSearchTokenKeys {
constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) {
this.tokenKeys = tokenKeys;
@@ -76,24 +74,6 @@ export default class FilteredSearchTokenKeys {
);
}
- addExtraTokensForIssues() {
- const confidentialToken = {
- formattedKey: __('Confidential'),
- key: 'confidential',
- type: 'string',
- param: '',
- symbol: '',
- icon: 'eye-slash',
- tag: __('Yes or No'),
- lowercaseValueOnSubmit: true,
- uppercaseTokenName: false,
- capitalizeTokenValue: true,
- };
-
- this.tokenKeys.push(confidentialToken);
- this.tokenKeysWithAlternative.push(confidentialToken);
- }
-
removeTokensForKeys(...keys) {
const keysSet = new Set(keys);
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 1700437aa84..6b1676eca8a 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
+import { snakeCase } from 'lodash';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers';
@@ -56,6 +57,9 @@ export default {
highlightedItemName() {
return highlight(this.itemName, this.matcher);
},
+ itemTrackingLabel() {
+ return `${this.dropdownType}_dropdown_frequent_items_list_item_${snakeCase(this.itemName)}`;
+ },
},
};
</script>
@@ -66,7 +70,7 @@ export default {
category="tertiary"
:href="webUrl"
class="gl-text-left gl-justify-content-start!"
- @click="track('click_link', { label: `${dropdownType}_dropdown_frequent_items_list_item` })"
+ @click="track('click_link', { label: itemTrackingLabel })"
>
<project-avatar
class="gl-float-left gl-mr-3"
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 146255df31f..d4dafbdc94f 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -897,7 +897,7 @@ GfmAutoComplete.Emoji = {
return Emoji.searchEmoji(query);
},
sorter(items) {
- return Emoji.sortEmoji(items);
+ return items.sort(Emoji.sortEmoji);
},
};
// Team Members
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 3a04779e48d..2b157fac878 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -60,7 +60,10 @@ export default class GLForm {
this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM);
this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 });
- autosize(this.textarea);
+
+ if (this.form.is(':not(.js-no-autosize)')) {
+ autosize(this.textarea);
+ }
}
// form and textarea event listeners
this.addEventListeners();
diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
index 1cc5a85198a..5d403d5cd65 100644
--- a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
+++ b/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue
@@ -48,7 +48,7 @@ export default {
<gl-table :items="list" :fields="$options.tableFields" />
- <gl-button :href="createUrl" category="primary" variant="info">
+ <gl-button :href="createUrl" category="primary" variant="confirm">
{{ $options.i18n.configureRegions }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/google_cloud/components/revoke_oauth.vue b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
index 07d966894f6..c07702ff42b 100644
--- a/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
+++ b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue
@@ -30,7 +30,7 @@ export default {
<p>{{ $options.i18n.description }}</p>
<gl-form :action="url" method="post">
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
- <gl-button category="secondary" variant="warning" type="submit">
+ <gl-button category="secondary" variant="danger" type="submit">
{{ $options.i18n.title }}
</gl-button>
</gl-form>
diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
index 37b716d7be5..4b580c594f5 100644
--- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
+++ b/app/assets/javascripts/google_cloud/components/service_accounts_list.vue
@@ -67,7 +67,7 @@ export default {
</template>
</gl-table>
- <gl-button :href="createUrl" category="primary" variant="info">
+ <gl-button :href="createUrl" category="primary" variant="confirm">
{{ $options.i18n.createServiceAccount }}
</gl-button>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index a44a5b30e1e..2969121bf06 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -19,6 +19,7 @@ const PRODUCT_INFO = {
variant: 'SaaS',
},
};
+const EMPTY_NAMESPACE_ID_VALUE = 'not available';
const generateProductInfo = (sku, quantity) => {
const product = PRODUCT_INFO[sku];
@@ -200,6 +201,10 @@ export const trackCheckout = (selectedPlan, quantity) => {
pushEnhancedEcommerceEvent('EECCheckout', eventData);
};
+export const getNamespaceId = () => {
+ return window.gl.snowplowStandardContext?.data?.namespace_id || EMPTY_NAMESPACE_ID_VALUE;
+};
+
export const trackTransaction = (transactionDetails) => {
if (!isSupported()) {
return;
@@ -208,6 +213,7 @@ export const trackTransaction = (transactionDetails) => {
const transactionId = uuidv4();
const { paymentOption, revenue, tax, selectedPlan, quantity } = transactionDetails;
const product = generateProductInfo(selectedPlan, quantity);
+ const namespaceId = getNamespaceId();
if (Object.keys(product).length === 0) {
return;
@@ -224,7 +230,7 @@ export const trackTransaction = (transactionDetails) => {
revenue: revenue.toString(),
tax: tax.toString(),
},
- products: [product],
+ products: [{ ...product, dimension36: namespaceId }],
},
},
};
diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
index 824997f8e33..fb771d7ec8a 100644
--- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
@@ -11,4 +11,7 @@ fragment TimelogFragment on Timelog {
body
}
summary
+ userPermissions {
+ adminTimelog
+ }
}
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 7ca3f20ec1c..50b40526ee0 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -129,5 +129,9 @@
"VulnerabilityLocationGeneric",
"VulnerabilityLocationSast",
"VulnerabilityLocationSecretDetection"
+ ],
+ "WorkItemWidget": [
+ "WorkItemWidgetDescription",
+ "WorkItemWidgetHierarchy"
]
}
diff --git a/app/assets/javascripts/group_settings/components/shared_runners_form.vue b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
index 3365f4aa76c..06aea26830d 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -1,8 +1,7 @@
<script>
import { GlToggle, GlAlert } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import { ERROR_MESSAGE } from '../constants';
+import { I18N_UPDATE_ERROR_MESSAGE, I18N_REFRESH_MESSAGE } from '../constants';
export default {
components: {
@@ -62,8 +61,8 @@ export default {
})
.catch((error) => {
const message = [
- error.response?.data?.error || __('An error occurred while updating configuration.'),
- ERROR_MESSAGE,
+ error.response?.data?.error || I18N_UPDATE_ERROR_MESSAGE,
+ I18N_REFRESH_MESSAGE,
].join(' ');
this.error = message;
diff --git a/app/assets/javascripts/group_settings/constants.js b/app/assets/javascripts/group_settings/constants.js
index ab5c0db45ba..1b44161903d 100644
--- a/app/assets/javascripts/group_settings/constants.js
+++ b/app/assets/javascripts/group_settings/constants.js
@@ -1,3 +1,4 @@
import { __ } from '~/locale';
-export const ERROR_MESSAGE = __('Refresh the page and try again.');
+export const I18N_UPDATE_ERROR_MESSAGE = __('An error occurred while updating configuration.');
+export const I18N_REFRESH_MESSAGE = __('Refresh the page and try again.');
diff --git a/app/assets/javascripts/group_settings/stale_runner_cleanup.js b/app/assets/javascripts/group_settings/stale_runner_cleanup.js
new file mode 100644
index 00000000000..3a4c171915f
--- /dev/null
+++ b/app/assets/javascripts/group_settings/stale_runner_cleanup.js
@@ -0,0 +1,3 @@
+export default () => {
+ // Overridden in EE
+};
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index e3147065d5c..cd5521c599e 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -1,19 +1,26 @@
<script>
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import createFlash from '~/flash';
-import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility';
+import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { __, s__, sprintf } from '~/locale';
import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants';
import eventHub from '../event_hub';
-import groupsComponent from './groups.vue';
+import GroupsComponent from './groups.vue';
+import EmptyState from './empty_state.vue';
export default {
components: {
- groupsComponent,
+ GroupsComponent,
GlModal,
GlLoadingIcon,
+ EmptyState,
+ },
+ inject: {
+ renderEmptyState: {
+ default: false,
+ },
},
props: {
action: {
@@ -47,13 +54,14 @@ export default {
searchEmptyMessage: '',
targetGroup: null,
targetParentGroup: null,
+ showEmptyState: false,
};
},
computed: {
primaryProps() {
return {
text: __('Leave group'),
- attributes: [{ variant: 'warning' }, { category: 'primary' }],
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
};
},
cancelProps() {
@@ -75,6 +83,9 @@ export default {
pageInfo() {
return this.store.getPaginationInfo();
},
+ filterGroupsBy() {
+ return getParameterByName('filter') || null;
+ },
},
created() {
this.searchEmptyMessage = this.hideProjects
@@ -128,19 +139,18 @@ export default {
const page = getParameterByName('page') || null;
const sortBy = getParameterByName('sort') || null;
const archived = getParameterByName('archived') || null;
- const filterGroupsBy = getParameterByName('filter') || null;
this.isLoading = true;
return this.fetchGroups({
page,
- filterGroupsBy,
+ filterGroupsBy: this.filterGroupsBy,
sortBy,
archived,
updatePagination: true,
}).then((res) => {
this.isLoading = false;
- this.updateGroups(res, Boolean(filterGroupsBy));
+ this.updateGroups(res, Boolean(this.filterGroupsBy));
});
},
fetchPage({ page, filterGroupsBy, sortBy, archived }) {
@@ -212,7 +222,7 @@ export default {
this.targetGroup.isBeingRemoved = false;
});
},
- showEmptyState() {
+ showLegacyEmptyState() {
const { containerEl } = this;
const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS);
const emptyStateEl = containerEl.querySelector('.empty-state');
@@ -230,7 +240,12 @@ export default {
},
updateGroups(groups, fromSearch) {
const hasGroups = groups && groups.length > 0;
- this.isSearchEmpty = !hasGroups;
+
+ if (this.renderEmptyState) {
+ this.isSearchEmpty = this.filterGroupsBy !== null && !hasGroups;
+ } else {
+ this.isSearchEmpty = !hasGroups;
+ }
if (fromSearch) {
this.store.setSearchedGroups(groups);
@@ -239,7 +254,11 @@ export default {
}
if (this.action && !hasGroups && !fromSearch) {
- this.showEmptyState();
+ if (this.renderEmptyState) {
+ this.showEmptyState = true;
+ } else {
+ this.showLegacyEmptyState();
+ }
}
},
},
@@ -251,7 +270,7 @@ export default {
<gl-loading-icon
v-if="isLoading"
:label="s__('GroupsTree|Loading groups')"
- size="md"
+ size="lg"
class="loading-animation prepend-top-20"
/>
<groups-component
@@ -262,6 +281,7 @@ export default {
:page-info="pageInfo"
:action="action"
/>
+ <empty-state v-if="showEmptyState" />
<gl-modal
modal-id="leave-group-modal"
:visible="isModalVisible"
diff --git a/app/assets/javascripts/groups/components/empty_state.vue b/app/assets/javascripts/groups/components/empty_state.vue
new file mode 100644
index 00000000000..4219b52737d
--- /dev/null
+++ b/app/assets/javascripts/groups/components/empty_state.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlLink, GlEmptyState } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+
+export default {
+ components: { GlLink, GlEmptyState },
+ i18n: {
+ withLinks: {
+ subgroup: {
+ title: s__('GroupsEmptyState|Create new subgroup'),
+ description: s__(
+ 'GroupsEmptyState|Groups are the best way to manage multiple projects and members.',
+ ),
+ },
+ project: {
+ title: s__('GroupsEmptyState|Create new project'),
+ description: s__(
+ 'GroupsEmptyState|Projects are where you can store your code, access issues, wiki, and other features of Gitlab.',
+ ),
+ },
+ },
+ withoutLinks: {
+ title: s__('GroupsEmptyState|No subgroups or projects.'),
+ description: s__(
+ 'GroupsEmptyState|You do not have necessary permissions to create a subgroup or project in this group. Please contact an owner of this group to create a new subgroup or project.',
+ ),
+ },
+ },
+ linkClasses: [
+ 'gl-border',
+ 'gl-text-decoration-none!',
+ 'gl-rounded-base',
+ 'gl-p-7',
+ 'gl-display-flex',
+ 'gl-h-full',
+ 'gl-align-items-center',
+ 'gl-text-purple-600',
+ 'gl-hover-bg-gray-50',
+ ],
+ inject: [
+ 'newSubgroupPath',
+ 'newProjectPath',
+ 'newSubgroupIllustration',
+ 'newProjectIllustration',
+ 'emptySubgroupIllustration',
+ 'canCreateSubgroups',
+ 'canCreateProjects',
+ ],
+};
+</script>
+
+<template>
+ <div v-if="canCreateSubgroups || canCreateProjects" class="gl-mt-5">
+ <div class="gl-display-flex gl-mx-n3 gl-my-n3 gl-flex-wrap">
+ <div v-if="canCreateSubgroups" class="gl-p-3 gl-w-full gl-sm-w-half">
+ <gl-link :href="newSubgroupPath" :class="$options.linkClasses">
+ <div class="svg-content gl-w-15 gl-flex-shrink-0 gl-mr-5">
+ <img :src="newSubgroupIllustration" :alt="$options.i18n.withLinks.subgroup.title" />
+ </div>
+ <div>
+ <h4 class="gl-reset-color">{{ $options.i18n.withLinks.subgroup.title }}</h4>
+ <p class="gl-text-body">
+ {{ $options.i18n.withLinks.subgroup.description }}
+ </p>
+ </div>
+ </gl-link>
+ </div>
+ <div v-if="canCreateProjects" class="gl-p-3 gl-w-full gl-sm-w-half">
+ <gl-link :href="newProjectPath" :class="$options.linkClasses">
+ <div class="svg-content gl-w-13 gl-flex-shrink-0 gl-mr-5">
+ <img :src="newProjectIllustration" :alt="$options.i18n.withLinks.project.title" />
+ </div>
+ <div>
+ <h4 class="gl-reset-color">{{ $options.i18n.withLinks.project.title }}</h4>
+ <p class="gl-text-body">
+ {{ $options.i18n.withLinks.project.description }}
+ </p>
+ </div>
+ </gl-link>
+ </div>
+ </div>
+ </div>
+ <gl-empty-state
+ v-else
+ class="gl-mt-5"
+ :title="$options.i18n.withoutLinks.title"
+ :svg-path="emptySubgroupIllustration"
+ :description="$options.i18n.withoutLinks.description"
+ />
+</template>
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index 042d818338a..96162c32d52 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -39,7 +39,7 @@ export default {
</script>
<template>
- <ul class="groups-list group-list-tree">
+ <ul class="groups-list group-list-tree gl-display-flex gl-flex-direction-column gl-m-0">
<group-item
v-for="(group, index) in groups"
:key="index"
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 4f21f68fa65..2241d57f96f 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -157,7 +157,9 @@ export default {
</a>
<div class="group-text-container d-flex flex-fill align-items-center">
<div class="group-text flex-grow-1 flex-shrink-1">
- <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
+ <div
+ class="gl-display-flex gl-align-items-center gl-flex-wrap title namespace-title gl-font-weight-bold gl-mr-3"
+ >
<a
v-gl-tooltip.bottom
data-testid="group-name"
diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue
new file mode 100644
index 00000000000..f9bd8701199
--- /dev/null
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -0,0 +1,279 @@
+<script>
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlLink,
+ GlAlert,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+
+import { s__, __ } from '~/locale';
+import { getGroupPathAvailability } from '~/rest_api';
+import { createAlert } from '~/flash';
+import { slugify } from '~/lib/utils/text_utility';
+import axios from '~/lib/utils/axios_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+const DEBOUNCE_DURATION = 1000;
+
+export default {
+ i18n: {
+ inputs: {
+ name: {
+ label: s__('Groups|Group name'),
+ placeholder: __('My awesome group'),
+ description: s__(
+ 'Groups|Must start with letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.',
+ ),
+ invalidFeedback: s__('Groups|Enter a descriptive name for your group.'),
+ },
+ path: {
+ label: s__('Groups|Group URL'),
+ placeholder: __('my-awesome-group'),
+ invalidFeedbackInvalidPattern: s__(
+ 'GroupSettings|Choose a group path that does not start with a dash or end with a period. It can also contain alphanumeric characters and underscores.',
+ ),
+ invalidFeedbackPathUnavailable: s__(
+ 'Groups|Group path is unavailable. Path has been replaced with a suggested available path.',
+ ),
+ validFeedback: s__('Groups|Group path is available.'),
+ },
+ groupId: {
+ label: s__('Groups|Group ID'),
+ },
+ },
+ apiLoadingMessage: s__('Groups|Checking group URL availability...'),
+ apiErrorMessage: __(
+ 'An error occurred while checking group path. Please refresh and try again.',
+ ),
+ changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'),
+ learnMore: s__('Groups|Learn more'),
+ },
+ nameInputSize: { md: 'lg' },
+ changingGroupPathHelpPagePath: helpPagePath('user/group/index', {
+ anchor: 'change-a-groups-path',
+ }),
+ mattermostDataBindName: 'create_chat_team',
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlLink,
+ GlAlert,
+ },
+ inject: ['fields', 'basePath', 'mattermostEnabled'],
+ data() {
+ return {
+ name: this.fields.name.value,
+ path: this.fields.path.value,
+ hasPathBeenManuallySet: false,
+ apiSuggestedPath: '',
+ apiLoading: false,
+ nameFeedbackState: null,
+ pathFeedbackState: null,
+ pathInvalidFeedback: null,
+ activeApiRequestAbortController: null,
+ };
+ },
+ computed: {
+ computedPath() {
+ return this.apiSuggestedPath || this.path;
+ },
+ pathDescription() {
+ return this.apiLoading ? this.$options.i18n.apiLoadingMessage : '';
+ },
+ isEditingGroup() {
+ return this.fields.groupId.value !== '';
+ },
+ },
+ watch: {
+ name: [
+ function updatePath(newName) {
+ if (this.isEditingGroup || this.hasPathBeenManuallySet) return;
+
+ this.nameFeedbackState = null;
+ this.pathFeedbackState = null;
+ this.apiSuggestedPath = '';
+ this.path = slugify(newName);
+ },
+ debounce(async function updatePathWithSuggestions() {
+ if (this.isEditingGroup || this.hasPathBeenManuallySet) return;
+
+ try {
+ const { suggests } = await this.checkPathAvailability();
+
+ const [suggestedPath] = suggests;
+
+ this.apiSuggestedPath = suggestedPath;
+ } catch (error) {
+ // Do nothing, error handled in `checkPathAvailability`
+ }
+ }, DEBOUNCE_DURATION),
+ ],
+ },
+ methods: {
+ async checkPathAvailability() {
+ if (!this.path) return Promise.reject();
+
+ this.apiLoading = true;
+
+ if (this.activeApiRequestAbortController !== null) {
+ this.activeApiRequestAbortController.abort();
+ }
+
+ this.activeApiRequestAbortController = new AbortController();
+
+ try {
+ const {
+ data: { exists, suggests },
+ } = await getGroupPathAvailability(this.path, this.fields.parentId?.value, {
+ signal: this.activeApiRequestAbortController.signal,
+ });
+
+ if (exists) {
+ if (suggests.length) {
+ return Promise.resolve({ exists, suggests });
+ }
+
+ createAlert({
+ message: this.$options.i18n.apiErrorMessage,
+ });
+
+ return Promise.reject();
+ }
+
+ return Promise.resolve({ exists, suggests });
+ } catch (error) {
+ if (!axios.isCancel(error)) {
+ createAlert({
+ message: this.$options.i18n.apiErrorMessage,
+ });
+ }
+
+ return Promise.reject();
+ } finally {
+ this.apiLoading = false;
+ }
+ },
+ handlePathInput(value) {
+ this.pathFeedbackState = null;
+ this.apiSuggestedPath = '';
+ this.hasPathBeenManuallySet = true;
+ this.path = value;
+ this.debouncedValidatePath();
+ },
+ debouncedValidatePath: debounce(async function validatePath() {
+ if (this.isEditingGroup && this.path === this.fields.path.value) return;
+
+ try {
+ const {
+ exists,
+ suggests: [suggestedPath],
+ } = await this.checkPathAvailability();
+
+ if (exists) {
+ this.apiSuggestedPath = suggestedPath;
+ this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackPathUnavailable;
+ this.pathFeedbackState = false;
+ } else {
+ this.pathFeedbackState = true;
+ }
+ } catch (error) {
+ // Do nothing, error handled in `checkPathAvailability`
+ }
+ }, DEBOUNCE_DURATION),
+ handleInvalidName(event) {
+ event.preventDefault();
+
+ this.nameFeedbackState = false;
+ },
+ handleInvalidPath(event) {
+ event.preventDefault();
+
+ this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackInvalidPattern;
+ this.pathFeedbackState = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input
+ :id="fields.parentId.id"
+ type="hidden"
+ :name="fields.parentId.name"
+ :value="fields.parentId.value"
+ />
+ <gl-form-group
+ :label="$options.i18n.inputs.name.label"
+ :description="$options.i18n.inputs.name.description"
+ :label-for="fields.name.id"
+ :invalid-feedback="$options.i18n.inputs.name.invalidFeedback"
+ :state="nameFeedbackState"
+ >
+ <gl-form-input
+ :id="fields.name.id"
+ v-model="name"
+ class="gl-field-error-ignore"
+ required
+ :name="fields.name.name"
+ :placeholder="$options.i18n.inputs.name.placeholder"
+ data-qa-selector="group_name_field"
+ :size="$options.nameInputSize"
+ :state="nameFeedbackState"
+ @invalid="handleInvalidName"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.inputs.path.label"
+ :label-for="fields.path.id"
+ :description="pathDescription"
+ :state="pathFeedbackState"
+ :valid-feedback="$options.i18n.inputs.path.validFeedback"
+ :invalid-feedback="pathInvalidFeedback"
+ >
+ <gl-form-input-group>
+ <template #prepend>
+ <gl-input-group-text class="group-root-path">{{ basePath }}</gl-input-group-text>
+ </template>
+ <gl-form-input
+ :id="fields.path.id"
+ class="gl-field-error-ignore"
+ :name="fields.path.name"
+ :value="computedPath"
+ :placeholder="$options.i18n.inputs.path.placeholder"
+ :maxlength="fields.path.maxLength"
+ :pattern="fields.path.pattern"
+ :state="pathFeedbackState"
+ :size="$options.nameInputSize"
+ required
+ data-qa-selector="group_path_field"
+ :data-bind-in="mattermostEnabled ? $options.mattermostDataBindName : null"
+ @input="handlePathInput"
+ @invalid="handleInvalidPath"
+ />
+ </gl-form-input-group>
+ </gl-form-group>
+ <template v-if="isEditingGroup">
+ <gl-alert class="gl-mb-5" :dismissible="false" variant="warning">
+ {{ $options.i18n.changingUrlWarningMessage }}
+ <gl-link :href="$options.changingGroupPathHelpPagePath"
+ >{{ $options.i18n.learnMore }}
+ </gl-link>
+ </gl-alert>
+ <gl-form-group :label="$options.i18n.inputs.groupId.label" :label-for="fields.groupId.id">
+ <gl-form-input
+ :id="fields.groupId.id"
+ :value="fields.groupId.value"
+ :name="fields.groupId.name"
+ size="sm"
+ readonly
+ />
+ </gl-form-group>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 313c8dadd1f..5706df0dd1b 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -43,7 +43,12 @@ export default {
<template>
<div class="groups-list-tree-container qa-groups-list-tree-container">
- <div v-if="searchEmpty" class="has-no-search-results">{{ searchEmptyMessage }}</div>
+ <div
+ v-if="searchEmpty"
+ class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5"
+ >
+ {{ searchEmptyMessage }}
+ </div>
<template v-else>
<group-folder :groups="groups" :action="action" />
<pagination-links
diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue
index a51edd385dd..ef82e6d693a 100644
--- a/app/assets/javascripts/groups/components/item_caret.vue
+++ b/app/assets/javascripts/groups/components/item_caret.vue
@@ -14,14 +14,14 @@ export default {
},
computed: {
iconClass() {
- return this.isGroupOpen ? 'angle-down' : 'angle-right';
+ return this.isGroupOpen ? 'chevron-down' : 'chevron-right';
},
},
};
</script>
<template>
- <span class="folder-caret gl-mr-2">
+ <span class="folder-caret gl-display-inline-block gl-text-secondary gl-w-5 gl-mr-2">
<gl-icon :size="12" :name="iconClass" />
</span>
</template>
diff --git a/app/assets/javascripts/groups/components/item_type_icon.vue b/app/assets/javascripts/groups/components/item_type_icon.vue
index 7821e604700..da4173993c5 100644
--- a/app/assets/javascripts/groups/components/item_type_icon.vue
+++ b/app/assets/javascripts/groups/components/item_type_icon.vue
@@ -24,5 +24,7 @@ export default {
</script>
<template>
- <span class="item-type-icon"> <gl-icon :name="iconClass" /> </span>
+ <span class="item-type-icon gl-display-inline-block gl-text-secondary">
+ <gl-icon :name="iconClass" />
+ </span>
</template>
diff --git a/app/assets/javascripts/groups/create_edit_form.js b/app/assets/javascripts/groups/create_edit_form.js
new file mode 100644
index 00000000000..8ca0e6077e9
--- /dev/null
+++ b/app/assets/javascripts/groups/create_edit_form.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import { parseRailsFormFields } from '~/lib/utils/forms';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import GroupNameAndPath from './components/group_name_and_path.vue';
+
+export const initGroupNameAndPath = () => {
+ const elements = document.querySelectorAll('.js-group-name-and-path');
+
+ if (!elements.length) {
+ return;
+ }
+
+ elements.forEach((element) => {
+ const fields = parseRailsFormFields(element);
+ const { basePath, mattermostEnabled } = element.dataset;
+
+ return new Vue({
+ el: element,
+ provide: {
+ fields,
+ basePath,
+ mattermostEnabled: parseBoolean(mattermostEnabled),
+ },
+ render(h) {
+ return h(GroupNameAndPath);
+ },
+ });
+ });
+};
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index c34810954a3..dfcee80aec7 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -44,6 +44,31 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
components: {
groupsApp,
},
+ provide() {
+ const {
+ dataset: {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ renderEmptyState,
+ canCreateSubgroups,
+ canCreateProjects,
+ },
+ } = this.$options.el;
+
+ return {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ renderEmptyState: parseBoolean(renderEmptyState),
+ canCreateSubgroups: parseBoolean(canCreateSubgroups),
+ canCreateProjects: parseBoolean(canCreateProjects),
+ };
+ },
data() {
const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects);
diff --git a/app/assets/javascripts/groups/settings/api/access_dropdown_api.js b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
new file mode 100644
index 00000000000..5560d10d179
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/api/access_dropdown_api.js
@@ -0,0 +1,16 @@
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+
+const GROUP_SUBGROUPS_PATH = '/-/autocomplete/group_subgroups.json';
+
+const buildUrl = (urlRoot, url) => {
+ return joinPaths(urlRoot, url);
+};
+
+export const getSubGroups = () => {
+ return axios.get(buildUrl(gon.relative_url_root || '', GROUP_SUBGROUPS_PATH), {
+ params: {
+ group_id: gon.current_group_id,
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
new file mode 100644
index 00000000000..b8a269de98a
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue
@@ -0,0 +1,194 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui';
+import { debounce, intersectionWith, groupBy, differenceBy, intersectionBy } from 'lodash';
+import createFlash from '~/flash';
+import { __, s__, n__ } from '~/locale';
+import { getSubGroups } from '../api/access_dropdown_api';
+import { LEVEL_TYPES } from '../constants';
+
+export const i18n = {
+ selectUsers: s__('ProtectedEnvironment|Select groups'),
+ groupsSectionHeader: s__('AccessDropdown|Groups'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ },
+ props: {
+ hasLicense: {
+ required: false,
+ type: Boolean,
+ default: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: i18n.selectUsers,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ preselectedItems: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ initialLoading: false,
+ query: '',
+ groups: [],
+ selected: {
+ [LEVEL_TYPES.GROUP]: [],
+ },
+ };
+ },
+ computed: {
+ preselected() {
+ return groupBy(this.preselectedItems, 'type');
+ },
+ toggleLabel() {
+ const counts = Object.fromEntries(
+ Object.entries(this.selected).map(([key, value]) => [key, value.length]),
+ );
+
+ const labelPieces = [];
+
+ if (counts[LEVEL_TYPES.GROUP] > 0) {
+ labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP]));
+ }
+
+ return labelPieces.join(', ') || this.label;
+ },
+ toggleClass() {
+ return this.toggleLabel === this.label ? 'gl-text-gray-500!' : '';
+ },
+ selection() {
+ return [...this.getDataForSave(LEVEL_TYPES.GROUP, 'group_id')];
+ },
+ },
+ watch: {
+ query: debounce(function debouncedSearch() {
+ return this.getData();
+ }, 500),
+ },
+ created() {
+ this.getData({ initial: true });
+ },
+ methods: {
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ getData({ initial = false } = {}) {
+ this.initialLoading = initial;
+ this.loading = true;
+
+ if (this.hasLicense) {
+ Promise.all([this.groups.length ? Promise.resolve({ data: this.groups }) : getSubGroups()])
+ .then(([groupsResponse]) => {
+ this.consolidateData(groupsResponse.data);
+ this.setSelected({ initial });
+ })
+ .catch(() => createFlash({ message: __('Failed to load groups.') }))
+ .finally(() => {
+ this.initialLoading = false;
+ this.loading = false;
+ });
+ }
+ },
+ consolidateData(groupsResponse = []) {
+ if (this.hasLicense) {
+ this.groups = groupsResponse.map((group) => ({ ...group, type: LEVEL_TYPES.GROUP }));
+ }
+ },
+ setSelected({ initial } = {}) {
+ if (initial) {
+ const selectedGroups = intersectionWith(
+ this.groups,
+ this.preselectedItems,
+ (group, selected) => {
+ return selected.type === LEVEL_TYPES.GROUP && group.id === selected.group_id;
+ },
+ );
+ this.selected[LEVEL_TYPES.GROUP] = selectedGroups;
+ }
+ },
+ getDataForSave(accessType, key) {
+ const selected = this.selected[accessType].map(({ id }) => ({ [key]: id }));
+ const preselected = this.preselected[accessType];
+ const added = differenceBy(selected, preselected, key);
+ const preserved = intersectionBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ }));
+ const removed = differenceBy(preselected, selected, key).map(({ id, [key]: keyId }) => ({
+ id,
+ [key]: keyId,
+ _destroy: true,
+ }));
+ return [...added, ...removed, ...preserved];
+ },
+ onItemClick(item) {
+ this.toggleSelection(this.selected[item.type], item);
+ this.emitUpdate();
+ },
+ toggleSelection(arr, item) {
+ const itemIndex = arr.findIndex(({ id }) => id === item.id);
+ if (itemIndex > -1) {
+ arr.splice(itemIndex, 1);
+ } else arr.push(item);
+ },
+ isSelected(item) {
+ return this.selected[item.type].some((selected) => selected.id === item.id);
+ },
+ emitUpdate() {
+ this.$emit('select', this.selection);
+ },
+ onHide() {
+ this.$emit('hidden', this.selection);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :disabled="disabled || initialLoading"
+ :text="toggleLabel"
+ class="gl-min-w-20"
+ :toggle-class="toggleClass"
+ aria-labelledby="allowed-users-label"
+ @shown="focusInput"
+ @hidden="onHide"
+ >
+ <template #header>
+ <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" />
+ </template>
+ <template v-if="groups.length">
+ <gl-dropdown-section-header>{{
+ $options.i18n.groupsSectionHeader
+ }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="group in groups"
+ :key="`${group.id}${group.name}`"
+ fingerprint
+ data-testid="group-dropdown-item"
+ :avatar-url="group.avatar_url"
+ is-check-item
+ :is-checked="isSelected(group)"
+ @click.native.capture.stop="onItemClick(group)"
+ >
+ {{ group.name }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/groups/settings/constants.js b/app/assets/javascripts/groups/settings/constants.js
new file mode 100644
index 00000000000..c91c2a20529
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/constants.js
@@ -0,0 +1,3 @@
+export const LEVEL_TYPES = {
+ GROUP: 'group',
+};
diff --git a/app/assets/javascripts/groups/settings/init_access_dropdown.js b/app/assets/javascripts/groups/settings/init_access_dropdown.js
new file mode 100644
index 00000000000..24419280fc0
--- /dev/null
+++ b/app/assets/javascripts/groups/settings/init_access_dropdown.js
@@ -0,0 +1,36 @@
+import * as Sentry from '@sentry/browser';
+import Vue from 'vue';
+import AccessDropdown from './components/access_dropdown.vue';
+
+export const initAccessDropdown = (el) => {
+ if (!el) {
+ return false;
+ }
+
+ const { label, disabled, preselectedItems } = el.dataset;
+ let preselected = [];
+ try {
+ preselected = JSON.parse(preselectedItems);
+ } catch (e) {
+ Sentry.captureException(e);
+ }
+
+ return new Vue({
+ el,
+ render(createElement) {
+ const vm = this;
+ return createElement(AccessDropdown, {
+ props: {
+ preselectedItems: preselected,
+ label,
+ disabled,
+ },
+ on: {
+ select(selected) {
+ vm.$emit('select', selected);
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 75f02af28c4..7112c43bab8 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -18,6 +18,20 @@ export default {
required: true,
},
},
+ modal: {
+ actionPrimary: {
+ text: __('Discard changes'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
computed: {
discardModalId() {
return `discard-file-${this.activeFile.path}`;
@@ -43,9 +57,9 @@ export default {
</script>
<template>
- <div class="d-flex ide-commit-editor-header align-items-center">
- <file-icon :file-name="activeFile.name" :size="16" class="mr-2" />
- <strong class="mr-2">
+ <div class="gl-display-flex ide-commit-editor-header gl-align-items-center">
+ <file-icon :file-name="activeFile.name" :size="16" class="gl-mr-3" />
+ <strong class="gl-mr-3">
<template v-if="activeFile.prevPath && activeFile.prevPath !== activeFile.path">
{{ activeFile.prevPath }} &#x2192;
</template>
@@ -66,12 +80,11 @@ export default {
</div>
<gl-modal
ref="discardModal"
- ok-variant="danger"
- cancel-variant="light"
- :ok-title="__('Discard changes')"
:modal-id="discardModalId"
:title="discardModalTitle"
- @ok="discardChanges(activeFile.path)"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="discardChanges(activeFile.path)"
>
{{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
</gl-modal>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index cb906374fe1..05a254d3fbf 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -154,7 +154,7 @@ export default {
<gl-button
:disabled="commitButtonDisabled"
category="primary"
- variant="info"
+ variant="confirm"
block
class="qa-begin-commit-button"
data-testid="begin-commit-button"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index 829686ef051..91d78a7c28c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -38,6 +38,20 @@ export default {
default: __('No changes'),
},
},
+ modal: {
+ actionPrimary: {
+ text: __('Discard all changes'),
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
computed: {
titleText() {
if (!this.title) return __('Changes');
@@ -66,10 +80,10 @@ export default {
<template>
<div class="ide-commit-list-container">
- <header class="multi-file-commit-panel-header d-flex mb-0">
- <div class="d-flex align-items-center flex-fill">
+ <header class="multi-file-commit-panel-header gl-display-flex gl-mb-0">
+ <div class="gl-display-flex gl-align-items-center flex-fill">
<strong> {{ titleText }} </strong>
- <div class="d-flex ml-auto">
+ <div class="gl-display-flex gl-ml-auto">
<gl-button
v-if="!stagedList"
v-gl-tooltip
@@ -100,17 +114,17 @@ export default {
/>
</li>
</ul>
- <p v-else class="multi-file-commit-list form-text text-muted text-center">
+ <p v-else class="multi-file-commit-list form-text gl-text-gray-600 gl-text-center">
{{ emptyStateText }}
</p>
<gl-modal
v-if="!stagedList"
ref="discardAllModal"
- ok-variant="danger"
modal-id="discard-all-changes"
- :ok-title="__('Discard all changes')"
:title="__('Discard all changes?')"
- @ok="unstageAndDiscardAllChanges"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="unstageAndDiscardAllChanges"
>
{{ $options.discardModalText }}
</gl-modal>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
index 43bf2e1a90c..0a8fec49aac 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import { createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
@@ -8,19 +8,20 @@ const { mapActions: mapCommitActions, mapGetters: mapCommitGetters } = createNam
);
export default {
+ components: { GlFormCheckbox },
directives: {
GlTooltip: GlTooltipDirective,
},
+ i18n: {
+ newMrText: s__('IDE|Start a new merge request'),
+ tooltipText: s__(
+ 'IDE|This option is disabled because you are not allowed to create merge requests in this project.',
+ ),
+ },
computed: {
...mapCommitGetters(['shouldHideNewMrOption', 'shouldDisableNewMrOption', 'shouldCreateMR']),
tooltipText() {
- if (this.shouldDisableNewMrOption) {
- return s__(
- 'IDE|This option is disabled because you are not allowed to create merge requests in this project.',
- );
- }
-
- return '';
+ return this.shouldDisableNewMrOption ? this.$options.i18n.tooltipText : null;
},
},
methods: {
@@ -30,22 +31,23 @@ export default {
</script>
<template>
- <fieldset v-if="!shouldHideNewMrOption">
- <hr class="my-2" />
- <label
- v-gl-tooltip="tooltipText"
- class="mb-0 js-ide-commit-new-mr"
- :class="{ 'is-disabled': shouldDisableNewMrOption }"
+ <fieldset
+ v-if="!shouldHideNewMrOption"
+ v-gl-tooltip="tooltipText"
+ data-testid="new-merge-request-fieldset"
+ class="js-ide-commit-new-mr"
+ :class="{ 'is-disabled': shouldDisableNewMrOption }"
+ >
+ <hr class="gl-mt-3 gl-mb-4" />
+
+ <gl-form-checkbox
+ :disabled="shouldDisableNewMrOption"
+ :checked="shouldCreateMR"
+ @change="toggleShouldCreateMR"
>
- <input
- :disabled="shouldDisableNewMrOption"
- :checked="shouldCreateMR"
- type="checkbox"
- @change="toggleShouldCreateMR"
- />
- <span class="gl-ml-3 ide-option-label">
- {{ __('Start a new merge request') }}
+ <span class="ide-option-label">
+ {{ $options.i18n.newMrText }}
</span>
- </label>
+ </gl-form-checkbox>
</fieldset>
</template>
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 870355e884e..bd5d28dbb56 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -1,8 +1,20 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormGroup,
+ GlFormInput,
+} from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
export default {
+ components: {
+ GlFormRadio,
+ GlFormRadioGroup,
+ GlFormGroup,
+ GlFormInput,
+ },
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -51,35 +63,42 @@ export default {
</script>
<template>
- <fieldset>
- <label
+ <fieldset class="gl-mb-2">
+ <gl-form-radio-group
v-gl-tooltip="tooltipTitle"
+ :checked="commitAction"
:class="{
'is-disabled': disabled,
}"
>
- <input
+ <gl-form-radio
:value="value"
- :checked="commitAction === value"
:disabled="disabled"
- type="radio"
name="commit-action"
data-qa-selector="commit_type_radio"
- @change="updateCommitAction($event.target.value)"
- />
- <span class="gl-ml-3">
- <span v-if="label" class="ide-option-label"> {{ label }} </span> <slot v-else></slot>
- </span>
- </label>
- <div v-if="commitAction === value && showInput" class="ide-commit-new-branch">
- <input
+ @change="updateCommitAction(value)"
+ >
+ <span v-if="label" class="ide-option-label">
+ {{ label }}
+ </span>
+ <slot v-else></slot>
+ </gl-form-radio>
+ </gl-form-radio-group>
+
+ <gl-form-group
+ v-if="commitAction === value && showInput"
+ :label="placeholderBranchName"
+ :label-sr-only="true"
+ class="gl-ml-6 gl-mb-0"
+ >
+ <gl-form-input
:placeholder="placeholderBranchName"
:value="newBranchName"
+ :disabled="disabled"
data-testid="ide-new-branch-name"
- type="text"
- class="form-control monospace"
- @input="updateBranchName($event.target.value)"
+ class="gl-font-monospace"
+ @input="updateBranchName($event)"
/>
- </div>
+ </gl-form-group>
</fieldset>
</template>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 45bbf93ebc9..d589f56dd7c 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -176,7 +176,7 @@ export default {
{{ __('New file') }}
</gl-button>
</template>
- <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" />
+ <gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="lg" />
<p v-else>
{{
__(
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index c3d6494692a..f32d35bf774 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants';
import ActivityBar from './activity_bar.vue';
@@ -10,7 +10,7 @@ import ResizablePanel from './resizable_panel.vue';
export default {
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
ResizablePanel,
ActivityBar,
IdeTree,
@@ -38,7 +38,7 @@ export default {
<template v-if="loading">
<div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
- <gl-skeleton-loading />
+ <gl-skeleton-loader />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 93ff7e8566f..e0b7ac9b1e1 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -32,8 +32,11 @@ export default {
...mapState('pipelines', ['latestPipeline']),
},
watch: {
- lastCommit() {
- this.initPipelinePolling();
+ lastCommit: {
+ handler() {
+ this.initPipelinePolling();
+ },
+ immediate: true,
},
},
mounted() {
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 0fc7337ad26..c9bf84be6ac 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
@@ -10,7 +10,7 @@ import NavDropdown from './nav_dropdown.vue';
export default {
name: 'IdeTreeList',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
NavDropdown,
FileTree,
},
@@ -55,7 +55,7 @@ export default {
<div class="ide-file-list qa-file-list">
<template v-if="showLoading">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
- <gl-skeleton-loading />
+ <gl-skeleton-loader />
</div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue
index 8fd1973267c..c184e25f67f 100644
--- a/app/assets/javascripts/ide/components/jobs/detail/description.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue
@@ -23,7 +23,7 @@ export default {
<template>
<div class="d-flex align-items-center">
- <ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" />
+ <ci-icon is-borderless :status="job.status" :size="24" class="d-flex" />
<span class="gl-ml-3">
{{ job.name }}
<a
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 7797850f097..2284ffb8480 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -27,7 +27,7 @@ export default {
},
computed: {
collapseIcon() {
- return this.stage.isCollapsed ? 'angle-left' : 'angle-down';
+ return this.stage.isCollapsed ? 'chevron-lg-left' : 'chevron-lg-down';
},
showLoadingIcon() {
return this.stage.isLoading && !this.stage.jobs.length;
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index f14d86114b8..d71ac766933 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -65,6 +65,8 @@ export default {
modelManager: new ModelManager(),
isEditorLoading: true,
unwatchCiYaml: null,
+ SELivepreviewExtension: null,
+ MarkdownLivePreview: null,
};
},
computed: {
@@ -192,23 +194,6 @@ export default {
this.createEditorInstance();
}
},
- panelResizing() {
- if (!this.panelResizing) {
- this.refreshEditorDimensions();
- }
- },
- showTabs() {
- this.$nextTick(() => this.refreshEditorDimensions());
- },
- rightPaneIsOpen() {
- this.refreshEditorDimensions();
- },
- showEditor(val) {
- if (val) {
- // We need to wait for the editor to actually be rendered.
- this.$nextTick(() => this.refreshEditorDimensions());
- }
- },
showContentViewer(val) {
if (!val) return;
@@ -324,17 +309,33 @@ export default {
},
]);
- if (
- this.fileType === MARKDOWN_FILE_TYPE &&
- this.editor?.getEditorType() === EDITOR_TYPE_CODE &&
- this.previewMarkdownPath
- ) {
+ this.$nextTick(() => {
+ this.setupEditor();
+ });
+ }
+ },
+
+ setupEditor() {
+ if (!this.file || !this.editor || this.file.loading) return;
+
+ const useLivePreviewExtension = () => {
+ this.SELivepreviewExtension = this.editor.use({
+ definition: this.MarkdownLivePreview,
+ setupOptions: { previewMarkdownPath: this.previewMarkdownPath },
+ });
+ };
+ if (
+ this.fileType === MARKDOWN_FILE_TYPE &&
+ this.editor?.getEditorType() === EDITOR_TYPE_CODE &&
+ this.previewMarkdownPath
+ ) {
+ if (this.MarkdownLivePreview) {
+ useLivePreviewExtension();
+ } else {
import('~/editor/extensions/source_editor_markdown_livepreview_ext')
- .then(({ EditorMarkdownPreviewExtension: MarkdownLivePreview }) => {
- this.editor.use({
- definition: MarkdownLivePreview,
- setupOptions: { previewMarkdownPath: this.previewMarkdownPath },
- });
+ .then(({ EditorMarkdownPreviewExtension }) => {
+ this.MarkdownLivePreview = EditorMarkdownPreviewExtension;
+ useLivePreviewExtension();
})
.catch((e) =>
createFlash({
@@ -342,15 +343,9 @@ export default {
}),
);
}
-
- this.$nextTick(() => {
- this.setupEditor();
- });
+ } else if (this.SELivepreviewExtension) {
+ this.editor.unuse(this.SELivepreviewExtension);
}
- },
-
- setupEditor() {
- if (!this.file || !this.editor || this.file.loading) return;
const head = this.getStagedFile(this.file.path);
@@ -396,10 +391,6 @@ export default {
fileLanguage: this.model.language,
});
- this.$nextTick(() => {
- this.editor.updateDimensions();
- });
-
this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
@@ -415,11 +406,6 @@ export default {
});
}
},
- refreshEditorDimensions() {
- if (this.showEditor && this.editor) {
- this.editor.updateDimensions();
- }
- },
fetchEditorconfigRules() {
return getRulesWithTraversal(this.file.path, (path) => {
const entry = this.entries[path];
diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue
index f4dd83b16c7..623ba719b28 100644
--- a/app/assets/javascripts/ide/components/terminal/empty_state.vue
+++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue
@@ -55,7 +55,7 @@ export default {
<gl-button
:disabled="!isValid"
category="primary"
- variant="info"
+ variant="confirm"
data-qa-selector="start_web_terminal_button"
@click="onStart"
>
diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue
index 3a4128b6207..384e27844c6 100644
--- a/app/assets/javascripts/ide/components/terminal/session.vue
+++ b/app/assets/javascripts/ide/components/terminal/session.vue
@@ -16,7 +16,7 @@ export default {
if (isEndingStatus(this.session.status)) {
return {
action: () => this.restartSession(),
- variant: 'info',
+ variant: 'confirm',
category: 'primary',
text: __('Restart Terminal'),
};
diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js
index 52da9942efe..525afcb2083 100644
--- a/app/assets/javascripts/ide/lib/editor_options.js
+++ b/app/assets/javascripts/ide/lib/editor_options.js
@@ -8,6 +8,7 @@ export const defaultEditorOptions = {
},
wordWrap: 'on',
glyphMargin: true,
+ automaticLayout: true,
};
export const defaultDiffOptions = {
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index ec3630cc5eb..a7e6506b045 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -81,7 +81,7 @@ export function registerLanguages(def, ...defs) {
languages.setLanguageConfiguration(languageId, def.conf);
}
-export function registerSchema(schema) {
+export function registerSchema(schema, options = {}) {
const defaults = [languages.json.jsonDefaults, languages.yaml.yamlDefaults];
defaults.forEach((d) =>
d.setDiagnosticsOptions({
@@ -90,6 +90,7 @@ export function registerSchema(schema) {
hover: true,
completion: true,
schemas: [schema],
+ ...options,
}),
);
}
diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js
index 3468a629f5a..180e927a3e7 100644
--- a/app/assets/javascripts/image_diff/helpers/dom_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js
@@ -6,7 +6,7 @@ export function setPositionDataAttribute(el, options) {
const positionObject = { ...JSON.parse(position), x, y, width, height };
- el.setAttribute('data-position', JSON.stringify(positionObject));
+ el.dataset.position = JSON.stringify(positionObject);
}
export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
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 ce401862cc1..6b96fa7c45c 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
@@ -15,9 +15,11 @@ import { debounce } from 'lodash';
import createFlash from '~/flash';
import { s__, __, n__, sprintf } from '~/locale';
import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import { getGroupPathAvailability } from '~/rest_api';
import axios from '~/lib/utils/axios_utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { STATUSES } from '../../constants';
import ImportStatusCell from '../../components/import_status.vue';
@@ -56,6 +58,7 @@ export default {
ImportStatusCell,
ImportActionsCell,
PaginationBar,
+ HelpPopover,
},
props: {
@@ -190,9 +193,9 @@ export default {
statusMessage() {
return this.filter.length === 0
- ? s__('BulkImport|Showing %{start}-%{end} of %{total} from %{link}')
+ ? s__('BulkImport|Showing %{start}-%{end} of %{total} that you own from %{link}')
: s__(
- 'BulkImport|Showing %{start}-%{end} of %{total} matching filter "%{filter}" from %{link}',
+ 'BulkImport|Showing %{start}-%{end} of %{total} that you own matching filter "%{filter}" from %{link}',
);
},
@@ -484,6 +487,9 @@ export default {
gitlabLogo: window.gon.gitlab_logo,
PAGE_SIZES,
+ permissionsHelpPath: helpPagePath('user/permissions', { anchor: 'group-members-permissions' }),
+ popoverOptions: { title: __('What is listed here?') },
+ i18n,
};
</script>
@@ -533,8 +539,8 @@ export default {
<div
class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex"
>
- <span>
- <gl-sprintf v-if="!$apollo.loading && hasGroups" :message="statusMessage">
+ <span v-if="!$apollo.loading && hasGroups">
+ <gl-sprintf :message="statusMessage">
<template #start>
<strong>{{ paginationInfo.start }}</strong>
</template>
@@ -548,12 +554,26 @@ export default {
<strong>{{ filter }}</strong>
</template>
<template #link>
- <gl-link :href="sourceUrl" target="_blank">
- {{ sourceUrl }}<gl-icon name="external-link" class="vertical-align-middle" />
- </gl-link>
+ {{ sourceUrl }}
</template>
</gl-sprintf>
+ <help-popover :options="$options.popoverOptions">
+ <gl-sprintf
+ :message="
+ s__(
+ 'BulkImport|Only groups that you have the %{role} role for are listed as groups you can import.',
+ )
+ "
+ >
+ <template #role>
+ <gl-link class="gl-font-sm" :href="$options.permissionsHelpPath" target="_blank">{{
+ $options.i18n.OWNER
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </help-popover>
</span>
+
<gl-search-box-by-click
class="gl-ml-auto"
:placeholder="s__('BulkImport|Filter by source group')"
@@ -561,18 +581,26 @@ export default {
@clear="filter = ''"
/>
</div>
- <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
+ <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
<template v-else>
<gl-empty-state
v-if="hasEmptyFilter"
:title="__('Sorry, your filter produced no results')"
:description="__('To widen your search, change or remove filters above.')"
/>
- <gl-empty-state
- v-else-if="!hasGroups"
- :title="s__('BulkImport|You have no groups to import')"
- :description="__('Check your source instance permissions.')"
- />
+ <gl-empty-state v-else-if="!hasGroups" :title="$options.i18n.NO_GROUPS_FOUND">
+ <template #description>
+ <gl-sprintf
+ :message="__('You don\'t have the %{role} role for any groups in this instance.')"
+ >
+ <template #role>
+ <gl-link :href="$options.permissionsHelpPath" target="_blank">{{
+ $options.i18n.OWNER
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
<template v-else>
<div
class="gl-bg-gray-10 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-px-4 gl-display-flex gl-align-items-center import-table-bar"
diff --git a/app/assets/javascripts/import_entities/import_groups/constants.js b/app/assets/javascripts/import_entities/import_groups/constants.js
index 32137308684..7e532dfec05 100644
--- a/app/assets/javascripts/import_entities/import_groups/constants.js
+++ b/app/assets/javascripts/import_entities/import_groups/constants.js
@@ -12,6 +12,9 @@ export const i18n = {
ERROR_IMPORT: s__('BulkImport|Importing the group failed.'),
ERROR_IMPORT_COMPLETED: s__('BulkImport|Import is finished. Pick another name for re-import'),
+ NO_GROUPS_FOUND: s__('BulkImport|No groups found'),
+ OWNER: __('Owner'),
+
features: {
projectMigration: __('projects'),
},
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index 0307607321e..848c7361601 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -181,7 +181,7 @@ export default {
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
- <gl-loading-icon v-if="isLoading" class="gl-mt-7" size="md" />
+ <gl-loading-icon v-if="isLoading" class="gl-mt-7" size="lg" />
<div v-if="!isLoading && repositories.length === 0" class="gl-text-center">
<strong>{{ emptyStateText }}</strong>
diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue
index 922e870caa7..dbd2225167a 100644
--- a/app/assets/javascripts/incidents/components/incidents_list.vue
+++ b/app/assets/javascripts/incidents/components/incidents_list.vue
@@ -144,7 +144,6 @@ export default {
'assigneeUsernameQuery',
'slaFeatureAvailable',
'canCreateIncident',
- 'incidentEscalationsAvailable',
],
apollo: {
incidents: {
@@ -238,7 +237,6 @@ export default {
const isHidden = {
published: !this.publishedAvailable,
incidentSla: !this.slaFeatureAvailable,
- escalationStatus: !this.incidentEscalationsAvailable,
};
return this.$options.fields.filter(({ key }) => !isHidden[key]);
@@ -421,7 +419,7 @@ export default {
</div>
</template>
- <template v-if="incidentEscalationsAvailable" #cell(escalationStatus)="{ item }">
+ <template #cell(escalationStatus)="{ item }">
<tooltip-on-truncate
:title="getEscalationStatus(item.escalationStatus)"
data-testid="incident-escalation-status"
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index c0f16a43d5c..1d40f1093a4 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -46,7 +46,6 @@ export default () => {
assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
canCreateIncident: parseBoolean(canCreateIncident),
- incidentEscalationsAvailable: parseBoolean(gon?.features?.incidentEscalations),
},
apolloProvider,
render(createElement) {
diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
index 866d2ff399e..e8c9aa53a7c 100644
--- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
@@ -11,6 +11,7 @@ import {
GlModal,
GlModalDirective,
} from '@gitlab/ui';
+import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants';
@@ -42,6 +43,21 @@ export default {
};
},
i18n: I18N_PAGERDUTY_SETTINGS_FORM,
+ modal: {
+ id: 'resetWebhookModal',
+ actionPrimary: {
+ text: I18N_PAGERDUTY_SETTINGS_FORM.webhookUrl.resetWebhookUrl,
+ attributes: {
+ variant: 'danger',
+ },
+ },
+ actionCancel: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK,
computed: {
formData() {
@@ -152,11 +168,11 @@ export default {
{{ $options.i18n.webhookUrl.resetWebhookUrl }}
</gl-button>
<gl-modal
- modal-id="resetWebhookModal"
+ :modal-id="$options.modal.id"
:title="$options.i18n.webhookUrl.resetWebhookUrl"
- :ok-title="$options.i18n.webhookUrl.resetWebhookUrl"
- ok-variant="danger"
- @ok="resetWebhookUrl"
+ :action-primary="$options.modal.actionPrimary"
+ :action-cancel="$options.modal.actionCancel"
+ @primary="resetWebhookUrl"
>
{{ $options.i18n.webhookUrl.restKeyInfo }}
</gl-modal>
diff --git a/app/assets/javascripts/integrations/constants.js b/app/assets/javascripts/integrations/constants.js
index b9975eed716..e4f6e931ec0 100644
--- a/app/assets/javascripts/integrations/constants.js
+++ b/app/assets/javascripts/integrations/constants.js
@@ -27,15 +27,51 @@ export const settingsTabTitle = __('Settings');
export const overridesTabTitle = s__('Integrations|Projects using custom settings');
export const integrationFormSections = {
+ CONFIGURATION: 'configuration',
CONNECTION: 'connection',
JIRA_TRIGGER: 'jira_trigger',
JIRA_ISSUES: 'jira_issues',
+ TRIGGER: 'trigger',
};
export const integrationFormSectionComponents = {
+ [integrationFormSections.CONFIGURATION]: 'IntegrationSectionConfiguration',
[integrationFormSections.CONNECTION]: 'IntegrationSectionConnection',
[integrationFormSections.JIRA_TRIGGER]: 'IntegrationSectionJiraTrigger',
[integrationFormSections.JIRA_ISSUES]: 'IntegrationSectionJiraIssues',
+ [integrationFormSections.TRIGGER]: 'IntegrationSectionTrigger',
+};
+
+export const integrationTriggerEvents = {
+ PUSH: 'push_events',
+ ISSUE: 'issues_events',
+ CONFIDENTIAL_ISSUE: 'confidential_issues_events',
+ MERGE_REQUEST: 'merge_requests_events',
+ NOTE: 'note_events',
+ CONFIDENTIAL_NOTE: 'confidential_note_events',
+ TAG_PUSH: 'tag_push_events',
+ PIPELINE: 'pipeline_events',
+ WIKI_PAGE: 'wiki_page_events',
+};
+
+export const integrationTriggerEventTitles = {
+ [integrationTriggerEvents.PUSH]: s__('IntegrationEvents|A push is made to the repository'),
+ [integrationTriggerEvents.ISSUE]: s__(
+ 'IntegrationEvents|An issue is created, updated, or closed',
+ ),
+ [integrationTriggerEvents.CONFIDENTIAL_ISSUE]: s__(
+ 'IntegrationEvents|A confidential issue is created, updated, or closed',
+ ),
+ [integrationTriggerEvents.MERGE_REQUEST]: s__(
+ 'IntegrationEvents|A merge request is created, updated, or merged',
+ ),
+ [integrationTriggerEvents.NOTE]: s__('IntegrationEvents|A comment is added on an issue'),
+ [integrationTriggerEvents.CONFIDENTIAL_NOTE]: s__(
+ 'IntegrationEvents|A comment is added on a confidential issue',
+ ),
+ [integrationTriggerEvents.TAG_PUSH]: s__('IntegrationEvents|A tag is pushed to the repository'),
+ [integrationTriggerEvents.PIPELINE]: s__('IntegrationEvents|A pipeline status changes'),
+ [integrationTriggerEvents.WIKI_PAGE]: s__('IntegrationEvents|A wiki page is created or updated'),
};
export const billingPlans = {
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index 9f43360fb73..9307d7c2d3d 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -37,6 +37,10 @@ export default {
DynamicField,
ConfirmationModal,
ResetConfirmationModal,
+ IntegrationSectionConfiguration: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue'
+ ),
IntegrationSectionConnection: () =>
import(
/* webpackChunkName: 'integrationSectionConnection' */ '~/integrations/edit/components/sections/connection.vue'
@@ -49,6 +53,10 @@ export default {
import(
/* webpackChunkName: 'integrationSectionJiraTrigger' */ '~/integrations/edit/components/sections/jira_trigger.vue'
),
+ IntegrationSectionTrigger: () =>
+ import(
+ /* webpackChunkName: 'integrationSectionTrigger' */ '~/integrations/edit/components/sections/trigger.vue'
+ ),
GlBadge,
GlButton,
GlForm,
@@ -193,7 +201,7 @@ export default {
<gl-form
ref="integrationForm"
method="post"
- class="gl-mb-3 gl-show-field-errors integration-settings-form"
+ class="gl-mt-6 gl-mb-3 gl-show-field-errors integration-settings-form"
:action="propsSource.formPath"
:novalidate="!integrationActive"
>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue
deleted file mode 100644
index 9164e484440..00000000000
--- a/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue
+++ /dev/null
@@ -1,51 +0,0 @@
-<script>
-import { GlButton, GlCard } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- GlCard,
- },
- props: {
- upgradePlanPath: {
- type: String,
- required: false,
- default: '',
- },
- showPremiumMessage: {
- type: Boolean,
- required: false,
- default: false,
- },
- showUltimateMessage: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- title() {
- return this.showUltimateMessage
- ? this.$options.i18n.titleUltimate
- : this.$options.i18n.titlePremium;
- },
- },
- i18n: {
- titleUltimate: s__('JiraService|This is an Ultimate feature'),
- titlePremium: s__('JiraService|This is a Premium feature'),
- content: s__('JiraService|Upgrade your plan to enable this feature of the Jira Integration.'),
- upgrade: __('Upgrade your plan'),
- },
-};
-</script>
-
-<template>
- <gl-card>
- <strong>{{ title }}</strong>
- <p>{{ $options.i18n.content }}</p>
- <gl-button v-if="upgradePlanPath" category="primary" variant="info" :href="upgradePlanPath">
- {{ $options.i18n.upgrade }}
- </gl-button>
- </gl-card>
-</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/configuration.vue b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
new file mode 100644
index 00000000000..9e1ad24ae9f
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/configuration.vue
@@ -0,0 +1,38 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import DynamicField from '../dynamic_field.vue';
+
+export default {
+ name: 'IntegrationSectionConfiguration',
+ components: {
+ DynamicField,
+ },
+ props: {
+ fields: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isValidated: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapGetters(['currentKey']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <dynamic-field
+ v-for="field in fields"
+ :key="`${currentKey}-${field.name}`"
+ v-bind="field"
+ :is-validated="isValidated"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/sections/trigger.vue b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
new file mode 100644
index 00000000000..9af5070d4cf
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/sections/trigger.vue
@@ -0,0 +1,26 @@
+<script>
+import { mapGetters } from 'vuex';
+
+import TriggerField from '../trigger_field.vue';
+
+export default {
+ name: 'IntegrationSectionTrigger',
+ components: {
+ TriggerField,
+ },
+ computed: {
+ ...mapGetters(['currentKey', 'propsSource']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <trigger-field
+ v-for="event in propsSource.triggerEvents"
+ :key="`${currentKey}-trigger-fields-${event.name}`"
+ :event="event"
+ class="gl-mb-3"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_field.vue b/app/assets/javascripts/integrations/edit/components/trigger_field.vue
new file mode 100644
index 00000000000..dc5ae2f3a3d
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/trigger_field.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
+
+import { integrationTriggerEventTitles } from '~/integrations/constants';
+
+export default {
+ name: 'TriggerField',
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ event: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ value: false,
+ };
+ },
+ computed: {
+ ...mapGetters(['isInheriting']),
+ name() {
+ return `service[${this.event.name}]`;
+ },
+ title() {
+ return integrationTriggerEventTitles[this.event.name];
+ },
+ },
+ mounted() {
+ this.value = this.event.value || false;
+ },
+};
+</script>
+
+<template>
+ <div>
+ <input :name="name" type="hidden" :value="value" />
+ <gl-form-checkbox v-model="value" :disabled="isInheriting">
+ {{ title }}
+ </gl-form-checkbox>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index 92e6ca509c3..2360588ab39 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -21,7 +21,6 @@ function parseDatasetToProps(data) {
type,
commentDetail,
projectKey,
- upgradePlanPath,
learnMorePath,
aboutPricingUrl,
triggerEvents,
@@ -80,12 +79,11 @@ function parseDatasetToProps(data) {
initialEnableJiraVulnerabilities: enableJiraVulnerabilities,
initialVulnerabilitiesIssuetype: vulnerabilitiesIssuetype,
initialProjectKey: projectKey,
- upgradePlanPath,
},
learnMorePath,
aboutPricingUrl,
triggerEvents: JSON.parse(triggerEvents),
- sections: JSON.parse(sections, { deep: true }),
+ sections: JSON.parse(sections),
fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }),
inheritFromId: parseInt(inheritFromId, 10),
integrationLevel,
diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
index f2d3e6489ee..1255ed01f6d 100644
--- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
+++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
@@ -136,7 +136,7 @@ export default {
</template>
<template #table-busy>
- <gl-loading-icon size="md" class="gl-my-2" />
+ <gl-loading-icon size="lg" class="gl-my-2" />
</template>
</gl-table>
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 7857b9d86d2..d597c7e53bb 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -14,6 +14,7 @@ import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { getParameterValues } from '~/lib/utils/url_utility';
import {
+ CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
INVITE_MEMBERS_FOR_TASK,
MEMBER_MODAL_LABELS,
@@ -151,6 +152,16 @@ export default {
isOnLearnGitlab() {
return this.source === LEARN_GITLAB;
},
+ closeToLimit() {
+ if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) {
+ return (
+ this.usersLimitDataset.membersCount >=
+ this.usersLimitDataset.freeUsersLimit - CLOSE_TO_LIMIT_COUNT
+ );
+ }
+
+ return false;
+ },
reachedLimit() {
if (this.usersLimitDataset.freeUsersLimit && this.usersLimitDataset.membersCount) {
return this.usersLimitDataset.membersCount >= this.usersLimitDataset.freeUsersLimit;
@@ -297,6 +308,7 @@ export default {
:is-loading="isLoading"
:new-users-to-invite="newUsersToInvite"
:root-group-id="rootId"
+ :close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
@reset="resetFields"
@@ -314,6 +326,7 @@ export default {
<template #user-limit-notification>
<user-limit-notification
+ :close-to-limit="closeToLimit"
:reached-limit="reachedLimit"
:users-limit-dataset="usersLimitDataset"
/>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 79b192e2495..42645110e48 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -2,7 +2,11 @@
import { GlButton, GlLink, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
-import { TRIGGER_ELEMENT_BUTTON, TRIGGER_ELEMENT_SIDE_NAV } from '../constants';
+import {
+ TRIGGER_ELEMENT_BUTTON,
+ TRIGGER_ELEMENT_SIDE_NAV,
+ TRIGGER_DEFAULT_QA_SELECTOR,
+} from '../constants';
export default {
components: { GlButton, GlLink, GlIcon },
@@ -46,12 +50,17 @@ export default {
required: false,
default: '',
},
+ qaSelector: {
+ type: String,
+ required: false,
+ default: TRIGGER_DEFAULT_QA_SELECTOR,
+ },
},
computed: {
componentAttributes() {
const baseAttributes = {
class: this.classes,
- 'data-qa-selector': 'invite_members_button',
+ 'data-qa-selector': this.qaSelector,
'data-test-id': 'invite-members-button',
};
diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
index 33d37b809c2..90d266c3155 100644
--- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue
+++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue
@@ -131,6 +131,11 @@ export default {
required: false,
default: false,
},
+ closeToLimit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
reachedLimit: {
type: Boolean,
required: false,
@@ -183,6 +188,17 @@ export default {
actionCancel() {
if (this.reachedLimit && this.usersLimitDataset.userNamespace) return undefined;
+ if (this.closeToLimit && this.usersLimitDataset.userNamespace) {
+ return {
+ text: INVITE_BUTTON_TEXT_DISABLED,
+ attributes: {
+ href: this.usersLimitDataset.membersPath,
+ category: 'secondary',
+ variant: 'confirm',
+ },
+ };
+ }
+
return {
text: this.reachedLimit ? CANCEL_BUTTON_TEXT_DISABLED : this.cancelButtonText,
...(this.reachedLimit && { attributes: { href: this.usersLimitDataset.purchasePath } }),
diff --git a/app/assets/javascripts/invite_members/components/user_limit_notification.vue b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
index ea5f4317d86..ae5c3c11386 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -8,15 +8,20 @@ import {
REACHED_LIMIT_MESSAGE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
CLOSE_TO_LIMIT_MESSAGE,
+ CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
+ DANGER_ALERT_TITLE_PERSONAL_NAMESPACE,
+ WARNING_ALERT_TITLE_PERSONAL_NAMESPACE,
} from '../constants';
-const CLOSE_TO_LIMIT_COUNT = 2;
-
export default {
name: 'UserLimitNotification',
components: { GlAlert, GlSprintf, GlLink },
inject: ['name'],
props: {
+ closeToLimit: {
+ type: Boolean,
+ required: true,
+ },
reachedLimit: {
type: Boolean,
required: true,
@@ -40,14 +45,14 @@ export default {
purchasePath() {
return this.usersLimitDataset.purchasePath;
},
- closeToLimit() {
- if (this.freeUsersLimit && this.membersCount) {
- return this.membersCount >= this.freeUsersLimit - CLOSE_TO_LIMIT_COUNT;
+ warningAlertTitle() {
+ if (this.usersLimitDataset.userNamespace) {
+ return sprintf(WARNING_ALERT_TITLE_PERSONAL_NAMESPACE, {
+ count: this.freeUsersLimit - this.membersCount,
+ members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
+ });
}
- return false;
- },
- warningAlertTitle() {
return sprintf(WARNING_ALERT_TITLE, {
count: this.freeUsersLimit - this.membersCount,
members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
@@ -55,6 +60,13 @@ export default {
});
},
dangerAlertTitle() {
+ if (this.usersLimitDataset.userNamespace) {
+ return sprintf(DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, {
+ count: this.freeUsersLimit,
+ members: this.pluralMembers(this.freeUsersLimit),
+ });
+ }
+
return sprintf(DANGER_ALERT_TITLE, {
count: this.freeUsersLimit,
members: this.pluralMembers(this.freeUsersLimit),
@@ -79,6 +91,10 @@ export default {
return this.reachedLimitMessage;
}
+ if (this.usersLimitDataset.userNamespace) {
+ return this.$options.i18n.closeToLimitMessagePersonalNamespace;
+ }
+
return this.$options.i18n.closeToLimitMessage;
},
},
@@ -91,6 +107,7 @@ export default {
reachedLimitMessage: REACHED_LIMIT_MESSAGE,
reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
+ closeToLimitMessagePersonalNamespace: CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
},
};
</script>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 928f79f1c8d..beb8f5b5aab 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1,7 +1,7 @@
import { s__ } from '~/locale';
+export const CLOSE_TO_LIMIT_COUNT = 2;
export const SEARCH_DELAY = 200;
-
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
@@ -18,6 +18,7 @@ export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
export const TRIGGER_ELEMENT_BUTTON = 'button';
export const TRIGGER_ELEMENT_SIDE_NAV = 'side-nav';
+export const TRIGGER_DEFAULT_QA_SELECTOR = 'invite_members_button';
export const MEMBERS_MODAL_DEFAULT_TITLE = s__('InviteMembersModal|Invite members');
export const MEMBERS_MODAL_CELEBRATE_TITLE = s__(
'InviteMembersModal|GitLab is better with colleagues!',
@@ -131,10 +132,17 @@ export const ON_SUBMIT_TRACK_LABEL = 'manage_members_clicked';
export const WARNING_ALERT_TITLE = s__(
'InviteMembersModal|You only have space for %{count} more %{members} in %{name}',
);
+export const WARNING_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
+ 'InviteMembersModal|You only have space for %{count} more %{members} in your personal projects',
+);
export const DANGER_ALERT_TITLE = s__(
"InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
);
+export const DANGER_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
+ "InviteMembersModal|You've reached your %{count} %{members} limit for your personal projects",
+);
+
export const REACHED_LIMIT_MESSAGE = s__(
'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.',
);
@@ -148,3 +156,6 @@ export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.co
export const CLOSE_TO_LIMIT_MESSAGE = s__(
'InviteMembersModal|To get more members an owner of this namespace can %{trialLinkStart}start a trial%{trialLinkEnd} or %{upgradeLinkStart}upgrade%{upgradeLinkEnd} to a paid tier.',
);
+export const CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE = s__(
+ 'InviteMembersModal|To make more space, you can remove members who no longer need access.',
+);
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index b0af3612e05..736da92fa9f 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -1,16 +1,18 @@
<script>
-import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
+import { GlModal, GlSprintf, GlIcon } from '@gitlab/ui';
import { __, n__ } from '~/locale';
import { ISSUABLE_TYPE } from '../constants';
export default {
+ actionCancel: {
+ text: __('Cancel'),
+ },
i18n: {
exportText: __(
'The CSV export will be created in the background. Once finished, it will be sent to %{email} in an attachment.',
),
},
components: {
- GlButton,
GlModal,
GlSprintf,
GlIcon,
@@ -38,6 +40,19 @@ export default {
},
},
computed: {
+ actionPrimary() {
+ return {
+ text: this.exportText,
+ attributes: {
+ href: this.exportCsvPath,
+ variant: 'confirm',
+ 'data-method': 'post',
+ 'data-qa-selector': `export_${this.issuableType}_button`,
+ 'data-track-action': 'click_button',
+ 'data-track-label': `export_${this.issuableType}_csv`,
+ },
+ };
+ },
isIssue() {
return this.issuableType === ISSUABLE_TYPE.issues;
},
@@ -56,6 +71,8 @@ export default {
<template>
<gl-modal
:modal-id="modalId"
+ :action-primary="actionPrimary"
+ :action-cancel="$options.actionCancel"
body-class="gl-p-0!"
:title="exportText"
data-qa-selector="export_issuable_modal"
@@ -73,18 +90,5 @@ export default {
</template>
</gl-sprintf>
</div>
- <template #modal-footer>
- <gl-button
- category="primary"
- variant="confirm"
- :href="exportCsvPath"
- data-method="post"
- :data-qa-selector="`export_${issuableType}_button`"
- data-track-action="click_button"
- :data-track-label="`export_${issuableType}_csv`"
- >
- {{ exportText }}
- </gl-button>
- </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue
index 7e2cbf03801..72293343c48 100644
--- a/app/assets/javascripts/issuable/components/csv_import_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue
@@ -18,6 +18,9 @@ export default {
actionPrimary: {
text: __('Import issues'),
},
+ actionCancel: {
+ text: __('Cancel'),
+ },
components: {
GlModal,
GlFormGroup,
@@ -55,6 +58,7 @@ export default {
:modal-id="modalId"
:title="$options.i18n.importIssuesText"
:action-primary="$options.actionPrimary"
+ :action-cancel="$options.actionCancel"
@primary="submitForm"
>
<form ref="form" :action="importCsvIssuesPath" enctype="multipart/form-data" method="post">
diff --git a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
index 06d1a2ee233..543dca0afe1 100644
--- a/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
+++ b/app/assets/javascripts/issuable/components/issuable_header_warnings.vue
@@ -27,7 +27,7 @@ export default {
return this.getNoteableData.confidential;
},
isMergeRequest() {
- return this.getNoteableData.targetType === 'merge_request' && this.glFeatures.updatedMrHeader;
+ return this.getNoteableData.targetType === 'merge_request';
},
warningIconsMeta() {
return [
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index dfe18567608..e6379b35f7a 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -71,8 +71,9 @@ export default {
:class="{
'issuable-info-container': !canReorder,
'card-body': canReorder,
+ 'gl-pr-2': canRemove,
}"
- class="item-body d-flex align-items-center py-2 px-3"
+ class="item-body d-flex align-items-center gl-py-3 gl-px-5"
>
<div
class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7"
@@ -170,7 +171,7 @@ export default {
<issue-assignees
v-if="assignees.length !== 0"
:assignees="assignees"
- class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none ml-2"
+ class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none gl-ml-3"
/>
</div>
</div>
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index 498dc859186..d72ee5c6757 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -63,6 +63,8 @@ export default {
},
},
data() {
+ if (!this.iid) return { state: this.initialState };
+
if (this.initialState) {
badgeState.state = this.initialState;
}
@@ -74,8 +76,7 @@ export default {
return [
CLASSES[this.state],
{
- 'gl-vertical-align-bottom':
- this.issuableType === IssuableType.MergeRequest && this.glFeatures.updatedMrHeader,
+ 'gl-vertical-align-bottom': this.issuableType === IssuableType.MergeRequest,
},
];
},
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index 8e76a33c7dd..38453072af8 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -46,6 +46,9 @@ function getFallbackKey() {
export default class IssuableForm {
constructor(form) {
+ if (form.length === 0) {
+ return;
+ }
this.form = form;
this.toggleWip = this.toggleWip.bind(this);
this.renderWipExplanation = this.renderWipExplanation.bind(this);
diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
new file mode 100644
index 00000000000..0cafaa1e500
--- /dev/null
+++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlPopover, GlSkeletonLoader } from '@gitlab/ui';
+import StatusBox from '~/issuable/components/status_box.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import query from '../queries/issue.query.graphql';
+
+export default {
+ components: {
+ GlPopover,
+ GlSkeletonLoader,
+ StatusBox,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ target: {
+ type: HTMLAnchorElement,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ cachedTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ issue: {},
+ };
+ },
+ computed: {
+ formattedTime() {
+ return this.timeFormatted(this.issue.createdAt);
+ },
+ title() {
+ return this.issue?.title || this.cachedTitle;
+ },
+ showDetails() {
+ return Object.keys(this.issue).length > 0;
+ },
+ },
+ apollo: {
+ issue: {
+ query,
+ update: (data) => data.project.issue,
+ variables() {
+ const { projectPath, iid } = this;
+
+ return {
+ projectPath,
+ iid,
+ };
+ },
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover :target="target" boundary="viewport" placement="top" show>
+ <gl-skeleton-loader v-if="$apollo.queries.issue.loading" :height="15">
+ <rect width="250" height="15" rx="4" />
+ </gl-skeleton-loader>
+ <div v-else-if="showDetails" class="gl-display-flex gl-align-items-center">
+ <status-box issuable-type="issue" :initial-state="issue.state" />
+ <span class="gl-text-secondary">
+ {{ __('Opened') }} <time :datetime="issue.createdAt">{{ formattedTime }}</time>
+ </span>
+ </div>
+ <h5 v-if="!$apollo.queries.issue.loading" class="gl-my-3">{{ title }}</h5>
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ <div class="gl-text-secondary">
+ {{ `${projectPath}#${iid}` }}
+ </div>
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
index fef75b6d5d0..92994809362 100644
--- a/app/assets/javascripts/mr_popover/components/mr_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue
@@ -1,6 +1,6 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlPopover, GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlBadge, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { mrStates, humanMRStates } from '../constants';
@@ -10,8 +10,9 @@ export default {
// name: 'MRPopover' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
name: 'MRPopover', // eslint-disable-line @gitlab/require-i18n-strings
components: {
+ GlBadge,
GlPopover,
- GlSkeletonLoading,
+ GlSkeletonLoader,
CiIcon,
},
mixins: [timeagoMixin],
@@ -24,11 +25,11 @@ export default {
type: String,
required: true,
},
- mergeRequestIID: {
+ iid: {
type: String,
required: true,
},
- mergeRequestTitle: {
+ cachedTitle: {
type: String,
required: true,
},
@@ -45,14 +46,14 @@ export default {
formattedTime() {
return this.timeFormatted(this.mergeRequest.createdAt);
},
- statusBoxClass() {
+ badgeVariant() {
switch (this.mergeRequest.state) {
case mrStates.merged:
- return 'status-box-mr-merged';
+ return 'info';
case mrStates.closed:
- return 'status-box-closed';
+ return 'danger';
default:
- return 'status-box-open';
+ return 'success';
}
},
stateHumanName() {
@@ -66,7 +67,7 @@ export default {
}
},
title() {
- return this.mergeRequest?.title || this.mergeRequestTitle;
+ return this.mergeRequest?.title || this.cachedTitle;
},
showDetails() {
return Object.keys(this.mergeRequest).length > 0;
@@ -77,11 +78,11 @@ export default {
query,
update: (data) => data.project.mergeRequest,
variables() {
- const { projectPath, mergeRequestIID } = this;
+ const { projectPath, iid } = this;
return {
projectPath,
- mergeRequestIID,
+ iid,
};
},
},
@@ -92,14 +93,14 @@ export default {
<template>
<gl-popover :target="target" boundary="viewport" placement="top" show>
<div class="mr-popover">
- <div v-if="$apollo.queries.mergeRequest.loading">
- <gl-skeleton-loading :lines="1" class="animation-container-small mt-1" />
- </div>
+ <gl-skeleton-loader v-if="$apollo.queries.mergeRequest.loading" :height="15">
+ <rect width="250" height="15" rx="4" />
+ </gl-skeleton-loader>
<div v-else-if="showDetails" class="d-flex align-items-center justify-content-between">
<div class="d-inline-flex align-items-center">
- <div :class="`issuable-status-box status-box ${statusBoxClass}`">
+ <gl-badge class="gl-mr-3" :variant="badgeVariant">
{{ stateHumanName }}
- </div>
+ </gl-badge>
<span class="gl-text-secondary">Opened <time v-text="formattedTime"></time></span>
</div>
<ci-icon v-if="detailedStatus" :status="detailedStatus" />
@@ -107,7 +108,7 @@ export default {
<h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<div class="gl-text-secondary">
- {{ `${projectPath}!${mergeRequestIID}` }}
+ {{ `${projectPath}!${iid}` }}
</div>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</div>
diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/issuable/popover/constants.js
index 352bc635293..352bc635293 100644
--- a/app/assets/javascripts/mr_popover/constants.js
+++ b/app/assets/javascripts/issuable/popover/constants.js
diff --git a/app/assets/javascripts/issuable/popover/index.js b/app/assets/javascripts/issuable/popover/index.js
new file mode 100644
index 00000000000..de3c8160b7a
--- /dev/null
+++ b/app/assets/javascripts/issuable/popover/index.js
@@ -0,0 +1,85 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import IssuePopover from './components/issue_popover.vue';
+import MRPopover from './components/mr_popover.vue';
+
+const componentsByReferenceType = {
+ issue: IssuePopover,
+ merge_request: MRPopover,
+};
+
+let renderFn;
+
+const handleIssuablePopoverMouseOut = ({ target }) => {
+ target.removeEventListener('mouseleave', handleIssuablePopoverMouseOut);
+
+ if (renderFn) {
+ clearTimeout(renderFn);
+ }
+};
+
+const popoverMountedAttr = 'data-popover-mounted';
+
+/**
+ * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
+ * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
+ */
+const handleIssuablePopoverMount = ({
+ apolloProvider,
+ projectPath,
+ title,
+ iid,
+ referenceType,
+ target,
+}) => {
+ // Add listener to actually remove it again
+ target.addEventListener('mouseleave', handleIssuablePopoverMouseOut);
+
+ renderFn = setTimeout(() => {
+ const PopoverComponent = Vue.extend(componentsByReferenceType[referenceType]);
+ new PopoverComponent({
+ propsData: {
+ target,
+ projectPath,
+ iid,
+ cachedTitle: title,
+ },
+ apolloProvider,
+ }).$mount();
+
+ target.setAttribute(popoverMountedAttr, true);
+ }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
+};
+
+export default (elements) => {
+ if (elements.length > 0) {
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+ const listenerAddedAttr = 'data-popover-listener-added';
+
+ elements.forEach((el) => {
+ const { projectPath, iid, referenceType } = el.dataset;
+ const title = el.dataset.mrTitle || el.title;
+
+ if (!el.getAttribute(listenerAddedAttr) && projectPath && title && iid && referenceType) {
+ el.addEventListener('mouseenter', ({ target }) => {
+ if (!el.getAttribute(popoverMountedAttr)) {
+ handleIssuablePopoverMount({
+ apolloProvider,
+ projectPath,
+ title,
+ iid,
+ referenceType,
+ target,
+ });
+ }
+ });
+ el.setAttribute(listenerAddedAttr, true);
+ }
+ });
+ }
+};
diff --git a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
new file mode 100644
index 00000000000..47a62e2b6ea
--- /dev/null
+++ b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
@@ -0,0 +1,11 @@
+query issue($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ id
+ issue(iid: $iid) {
+ id
+ title
+ createdAt
+ state
+ }
+ }
+}
diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql b/app/assets/javascripts/issuable/popover/queries/merge_request.query.graphql
index b3e5d89d495..7cd344c1d5e 100644
--- a/app/assets/javascripts/mr_popover/queries/merge_request.query.graphql
+++ b/app/assets/javascripts/issuable/popover/queries/merge_request.query.graphql
@@ -1,7 +1,7 @@
-query mergeRequest($projectPath: ID!, $mergeRequestIID: String!) {
+query mergeRequest($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
id
- mergeRequest(iid: $mergeRequestIID) {
+ mergeRequest(iid: $iid) {
id
title
createdAt
diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js
index 8294c018117..edf3789e6dc 100644
--- a/app/assets/javascripts/issues/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js
@@ -11,6 +11,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
import { mergeUrlParams } from '~/lib/utils/url_utility';
+import api from '~/api';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = { ...ISetter };
@@ -81,10 +82,7 @@ export default class CreateMergeRequestDropdown {
this.init();
if (isConfidentialIssue()) {
- this.createMergeRequestButton.setAttribute(
- 'data-dropdown-trigger',
- '#create-merge-request-dropdown',
- );
+ this.createMergeRequestButton.dataset.dropdownTrigger = '#create-merge-request-dropdown';
initConfidentialMergeRequest();
}
}
@@ -149,7 +147,7 @@ export default class CreateMergeRequestDropdown {
});
}
- createBranch() {
+ createBranch(navigateToBranch = true) {
this.isCreatingBranch = true;
return axios
@@ -158,7 +156,10 @@ export default class CreateMergeRequestDropdown {
})
.then(({ data }) => {
this.branchCreated = true;
- window.location.href = data.url;
+
+ if (navigateToBranch) {
+ window.location.href = data.url;
+ }
})
.catch(() =>
createFlash({
@@ -170,23 +171,25 @@ export default class CreateMergeRequestDropdown {
createMergeRequest() {
return new Promise(() => {
this.isCreatingMergeRequest = true;
- return this.createBranch().then(() => {
- let path = canCreateConfidentialMergeRequest()
- ? this.createMrPath.replace(
- this.projectPath,
- confidentialMergeRequestState.selectedProject.pathWithNamespace,
- )
- : this.createMrPath;
- path = mergeUrlParams(
- {
- 'merge_request[target_branch]': this.refInput.value,
- 'merge_request[source_branch]': this.branchInput.value,
- },
- path,
- );
-
- window.location.href = path;
- });
+ return this.createBranch(false)
+ .then(() => api.trackRedisHllUserEvent('i_code_review_user_create_mr_from_issue'))
+ .then(() => {
+ let path = canCreateConfidentialMergeRequest()
+ ? this.createMrPath.replace(
+ this.projectPath,
+ confidentialMergeRequestState.selectedProject.pathWithNamespace,
+ )
+ : this.createMrPath;
+ path = mergeUrlParams(
+ {
+ 'merge_request[target_branch]': this.refInput.value,
+ 'merge_request[source_branch]': this.branchInput.value,
+ },
+ path,
+ );
+
+ window.location.href = path;
+ });
});
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index bcd729785b3..67c6c723dcc 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -9,6 +9,7 @@ import { IssueType } from '~/issues/constants';
import Issue from '~/issues/issue';
import { initTitleSuggestions, initTypePopover } from '~/issues/new';
import { initRelatedMergeRequests } from '~/issues/related_merge_requests';
+import initRelatedIssues from '~/related_issues';
import {
initHeaderActions,
initIncidentApp,
@@ -56,8 +57,9 @@ export function initShow() {
const { issueType, ...issuableData } = parseIssuableData(el);
if (issueType === IssueType.Incident) {
- initIncidentApp(issuableData);
+ initIncidentApp({ ...issuableData, issuableId: el.dataset.issuableId });
initHeaderActions(store, IssueType.Incident);
+ initRelatedIssues(IssueType.Incident);
} else {
initIssueApp(issuableData, store);
initHeaderActions(store);
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 b81ab103271..fa56c0183b2 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -13,8 +13,6 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql';
-import getIssuesWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_without_crm.query.graphql';
-import getIssuesCountsWithoutCrmQuery from 'ee_else_ce/issues/list/queries/get_issues_counts_without_crm.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -23,6 +21,7 @@ import CsvImportExportButtons from '~/issuable/components/csv_import_export_butt
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import { IssuableStatus } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
+import { isPositiveInteger } from '~/lib/utils/number_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
import {
@@ -31,20 +30,28 @@ import {
TOKEN_TITLE_ASSIGNEE,
TOKEN_TITLE_AUTHOR,
TOKEN_TITLE_CONFIDENTIAL,
+ TOKEN_TITLE_CONTACT,
TOKEN_TITLE_LABEL,
TOKEN_TITLE_MILESTONE,
TOKEN_TITLE_MY_REACTION,
+ TOKEN_TITLE_ORGANIZATION,
TOKEN_TITLE_RELEASE,
TOKEN_TITLE_TYPE,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
-import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import {
+ IssuableListTabs,
+ IssuableStates,
+ IssuableTypes,
+} from '~/vue_shared/issuable/list/constants';
import {
CREATED_DESC,
i18n,
ISSUE_REFERENCE,
MAX_LIST_SIZE,
PAGE_SIZE,
+ PARAM_FIRST_PAGE_SIZE,
+ PARAM_LAST_PAGE_SIZE,
PARAM_PAGE_AFTER,
PARAM_PAGE_BEFORE,
PARAM_SORT,
@@ -53,9 +60,11 @@ import {
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
+ TOKEN_TYPE_CONTACT,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
+ TOKEN_TYPE_ORGANIZATION,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
UPDATED_DESC,
@@ -93,6 +102,7 @@ const ReleaseToken = () =>
export default {
i18n,
IssuableListTabs,
+ IssuableTypes: [IssuableTypes.Issue, IssuableTypes.Incident, IssuableTypes.TestCase],
components: {
CsvImportExportButtons,
GlButton,
@@ -112,6 +122,9 @@ export default {
'autocompleteAwardEmojisPath',
'calendarPath',
'canBulkUpdate',
+ 'canCreateProjects',
+ 'canReadCrmContact',
+ 'canReadCrmOrganization',
'emptyStateSvgPath',
'exportCsvPath',
'fullPath',
@@ -120,6 +133,7 @@ export default {
'hasBlockedIssuesFeature',
'hasIssueWeightsFeature',
'hasMultipleIssueAssigneesFeature',
+ 'hasScopedLabelsFeature',
'initialEmail',
'initialSort',
'isAnonymousSearchDisabled',
@@ -129,6 +143,7 @@ export default {
'isSignedIn',
'jiraIntegrationPath',
'newIssuePath',
+ 'newProjectPath',
'releasesPath',
'rssPath',
'showNewIssueLink',
@@ -157,11 +172,11 @@ export default {
},
apollo: {
issues: {
- query() {
- return this.hasCrmParameter ? getIssuesQuery : getIssuesWithoutCrmQuery;
- },
+ query: getIssuesQuery,
variables() {
- return this.queryVariables;
+ const { types } = this.queryVariables;
+
+ return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes };
},
update(data) {
return data[this.namespace]?.issues.nodes ?? [];
@@ -183,11 +198,11 @@ export default {
debounce: 200,
},
issuesCounts: {
- query() {
- return this.hasCrmParameter ? getIssuesCountsQuery : getIssuesCountsWithoutCrmQuery;
- },
+ query: getIssuesCountsQuery,
variables() {
- return this.queryVariables;
+ const { types } = this.queryVariables;
+
+ return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes };
},
update(data) {
return data[this.namespace] ?? {};
@@ -363,6 +378,28 @@ export default {
});
}
+ if (this.canReadCrmContact) {
+ tokens.push({
+ type: TOKEN_TYPE_CONTACT,
+ title: TOKEN_TITLE_CONTACT,
+ icon: 'user',
+ token: GlFilteredSearchToken,
+ operators: OPERATOR_IS_ONLY,
+ unique: true,
+ });
+ }
+
+ if (this.canReadCrmOrganization) {
+ tokens.push({
+ type: TOKEN_TYPE_ORGANIZATION,
+ title: TOKEN_TITLE_ORGANIZATION,
+ icon: 'users',
+ token: GlFilteredSearchToken,
+ operators: OPERATOR_IS_ONLY,
+ unique: true,
+ });
+ }
+
if (this.eeSearchTokens.length) {
tokens.push(...this.eeSearchTokens);
}
@@ -390,20 +427,16 @@ export default {
},
urlParams() {
return {
- page_after: this.pageParams.afterCursor,
- page_before: this.pageParams.beforeCursor,
search: this.searchQuery,
sort: urlSortParams[this.sortKey],
state: this.state,
...this.urlFilterParams,
+ first_page_size: this.pageParams.firstPageSize,
+ last_page_size: this.pageParams.lastPageSize,
+ page_after: this.pageParams.afterCursor,
+ page_before: this.pageParams.beforeCursor,
};
},
- hasCrmParameter() {
- return (
- window.location.search.includes('crm_contact_id=') ||
- window.location.search.includes('crm_organization_id=')
- );
- },
},
watch: {
$route(newValue, oldValue) {
@@ -632,6 +665,8 @@ export default {
this.showBulkEditSidebar = showBulkEditSidebar;
},
updateData(sortValue) {
+ const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
+ const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
const pageAfter = getParameterByName(PARAM_PAGE_AFTER);
const pageBefore = getParameterByName(PARAM_PAGE_BEFORE);
const state = getParameterByName(PARAM_STATE);
@@ -660,7 +695,13 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
- this.pageParams = getInitialPageParams(sortKey, pageAfter, pageBefore);
+ this.pageParams = getInitialPageParams(
+ sortKey,
+ isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
+ isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
+ pageAfter,
+ pageBefore,
+ );
this.sortKey = sortKey;
this.state = state || IssuableStates.Opened;
},
@@ -676,6 +717,7 @@ export default {
recent-searches-storage-key="issues"
:search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
+ :has-scoped-labels-feature="hasScopedLabelsFeature"
:initial-filter-value="filterTokens"
:sort-options="sortOptions"
:initial-sort-by="sortKey"
@@ -815,12 +857,17 @@ export default {
</issuable-list>
<template v-else-if="isSignedIn">
- <gl-empty-state
- :description="$options.i18n.noIssuesSignedInDescription"
- :title="$options.i18n.noIssuesSignedInTitle"
- :svg-path="emptyStateSvgPath"
- >
+ <gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath">
+ <template #description>
+ <p>{{ $options.i18n.noIssuesSignedInDescription }}</p>
+ <p v-if="canCreateProjects">
+ <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong>
+ </p>
+ </template>
<template #actions>
+ <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm">
+ {{ $options.i18n.newProjectLabel }}
+ </gl-button>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
@@ -830,7 +877,7 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
- <new-issue-dropdown v-if="showNewIssueDropdown" />
+ <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" />
</template>
</gl-empty-state>
<hr />
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 0795df10a7c..74f801f685c 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -29,7 +29,11 @@ export const i18n = {
jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'),
jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'),
newIssueLabel: __('New issue'),
+ newProjectLabel: __('New project'),
noClosedIssuesTitle: __('There are no closed issues'),
+ noGroupIssuesSignedInDescription: __(
+ 'Issues exist in projects, so to create an issue, first create a project.',
+ ),
noOpenIssuesDescription: __('To keep this project going, create a new issue'),
noOpenIssuesTitle: __('There are no open issues'),
noIssuesSignedInDescription: __(
@@ -57,6 +61,8 @@ export const MAX_LIST_SIZE = 10;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
export const PARAM_ASSIGNEE_ID = 'assignee_id';
+export const PARAM_FIRST_PAGE_SIZE = 'first_page_size';
+export const PARAM_LAST_PAGE_SIZE = 'last_page_size';
export const PARAM_PAGE_AFTER = 'page_after';
export const PARAM_PAGE_BEFORE = 'page_before';
export const PARAM_SORT = 'sort';
diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js
index f5cb160e344..93333c31b34 100644
--- a/app/assets/javascripts/issues/list/index.js
+++ b/app/assets/javascripts/issues/list/index.js
@@ -80,8 +80,11 @@ export function mountIssuesListApp() {
autocompleteAwardEmojisPath,
calendarPath,
canBulkUpdate,
+ canCreateProjects,
canEdit,
canImportIssues,
+ canReadCrmContact,
+ canReadCrmOrganization,
email,
emailsHelpPagePath,
emptyStateSvgPath,
@@ -95,6 +98,7 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature,
hasIterationsFeature,
hasMultipleIssueAssigneesFeature,
+ hasScopedLabelsFeature,
importCsvIssuesPath,
initialEmail,
initialSort,
@@ -107,6 +111,7 @@ export function mountIssuesListApp() {
markdownHelpPath,
maxAttachmentSize,
newIssuePath,
+ newProjectPath,
projectImportJiraPath,
quickActionsHelpPath,
releasesPath,
@@ -131,6 +136,9 @@ export function mountIssuesListApp() {
autocompleteAwardEmojisPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
+ canCreateProjects: parseBoolean(canCreateProjects),
+ canReadCrmContact: parseBoolean(canReadCrmContact),
+ canReadCrmOrganization: parseBoolean(canReadCrmOrganization),
emptyStateSvgPath,
fullPath,
groupPath,
@@ -141,6 +149,7 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
+ hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
initialSort,
isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
@@ -149,6 +158,7 @@ export function mountIssuesListApp() {
isSignedIn: parseBoolean(isSignedIn),
jiraIntegrationPath,
newIssuePath,
+ newProjectPath,
releasesPath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql
deleted file mode 100644
index ab91aab1218..00000000000
--- a/app/assets/javascripts/issues/list/queries/get_issues_counts_without_crm.query.graphql
+++ /dev/null
@@ -1,136 +0,0 @@
-query getIssuesCountWithoutCrm(
- $isProject: Boolean = false
- $fullPath: ID!
- $iid: String
- $search: String
- $assigneeId: String
- $assigneeUsernames: [String!]
- $authorUsername: String
- $confidential: Boolean
- $labelName: [String]
- $milestoneTitle: [String]
- $milestoneWildcardId: MilestoneWildcardId
- $myReactionEmoji: String
- $releaseTag: [String!]
- $releaseTagWildcardId: ReleaseTagWildcardId
- $types: [IssueType!]
- $not: NegatedIssueFilterInput
-) {
- group(fullPath: $fullPath) @skip(if: $isProject) {
- id
- openedIssues: issues(
- includeSubgroups: true
- state: opened
- iid: $iid
- search: $search
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- types: $types
- not: $not
- ) {
- count
- }
- closedIssues: issues(
- includeSubgroups: true
- state: closed
- iid: $iid
- search: $search
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- types: $types
- not: $not
- ) {
- count
- }
- allIssues: issues(
- includeSubgroups: true
- state: all
- iid: $iid
- search: $search
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- types: $types
- not: $not
- ) {
- count
- }
- }
- project(fullPath: $fullPath) @include(if: $isProject) {
- id
- openedIssues: issues(
- state: opened
- iid: $iid
- search: $search
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- releaseTag: $releaseTag
- releaseTagWildcardId: $releaseTagWildcardId
- types: $types
- not: $not
- ) {
- count
- }
- closedIssues: issues(
- state: closed
- iid: $iid
- search: $search
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- releaseTag: $releaseTag
- releaseTagWildcardId: $releaseTagWildcardId
- types: $types
- not: $not
- ) {
- count
- }
- allIssues: issues(
- state: all
- iid: $iid
- search: $search
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- releaseTag: $releaseTag
- releaseTagWildcardId: $releaseTagWildcardId
- types: $types
- not: $not
- ) {
- count
- }
- }
-}
diff --git a/app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql
deleted file mode 100644
index 4a8b1dfd618..00000000000
--- a/app/assets/javascripts/issues/list/queries/get_issues_without_crm.query.graphql
+++ /dev/null
@@ -1,94 +0,0 @@
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
-#import "./issue.fragment.graphql"
-
-query getIssuesWithoutCrm(
- $hideUsers: Boolean = false
- $isProject: Boolean = false
- $isSignedIn: Boolean = false
- $fullPath: ID!
- $iid: String
- $search: String
- $sort: IssueSort
- $state: IssuableState
- $assigneeId: String
- $assigneeUsernames: [String!]
- $authorUsername: String
- $confidential: Boolean
- $labelName: [String]
- $milestoneTitle: [String]
- $milestoneWildcardId: MilestoneWildcardId
- $myReactionEmoji: String
- $releaseTag: [String!]
- $releaseTagWildcardId: ReleaseTagWildcardId
- $types: [IssueType!]
- $not: NegatedIssueFilterInput
- $beforeCursor: String
- $afterCursor: String
- $firstPageSize: Int
- $lastPageSize: Int
-) {
- group(fullPath: $fullPath) @skip(if: $isProject) {
- id
- issues(
- includeSubgroups: true
- iid: $iid
- search: $search
- sort: $sort
- state: $state
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- types: $types
- not: $not
- before: $beforeCursor
- after: $afterCursor
- first: $firstPageSize
- last: $lastPageSize
- ) {
- pageInfo {
- ...PageInfo
- }
- nodes {
- ...IssueFragment
- reference(full: true)
- }
- }
- }
- project(fullPath: $fullPath) @include(if: $isProject) {
- id
- issues(
- iid: $iid
- search: $search
- sort: $sort
- state: $state
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- confidential: $confidential
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- myReactionEmoji: $myReactionEmoji
- releaseTag: $releaseTag
- releaseTagWildcardId: $releaseTagWildcardId
- types: $types
- not: $not
- before: $beforeCursor
- after: $afterCursor
- first: $firstPageSize
- last: $lastPageSize
- ) {
- pageInfo {
- ...PageInfo
- }
- nodes {
- ...IssueFragment
- }
- }
- }
-}
diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js
index 3ca93069628..dfdc6e27f0d 100644
--- a/app/assets/javascripts/issues/list/utils.js
+++ b/app/assets/javascripts/issues/list/utils.js
@@ -46,8 +46,15 @@ import {
WEIGHT_DESC,
} from './constants';
-export const getInitialPageParams = (sortKey, afterCursor, beforeCursor) => ({
- firstPageSize: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+export const getInitialPageParams = (
+ sortKey,
+ firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ lastPageSize,
+ afterCursor,
+ beforeCursor,
+) => ({
+ firstPageSize: lastPageSize ? undefined : firstPageSize,
+ lastPageSize,
afterCursor,
beforeCursor,
});
diff --git a/app/assets/javascripts/issues/new/components/title_suggestions.vue b/app/assets/javascripts/issues/new/components/title_suggestions.vue
index 0a9cdb12519..4fd018ab4ce 100644
--- a/app/assets/javascripts/issues/new/components/title_suggestions.vue
+++ b/app/assets/javascripts/issues/new/components/title_suggestions.vue
@@ -66,8 +66,8 @@ export default {
</script>
<template>
- <div v-show="showSuggestions" class="form-group row">
- <div v-once class="col-form-label col-sm-2 pt-0">
+ <div v-show="showSuggestions" class="form-group">
+ <div v-once class="gl-pb-3">
{{ __('Similar issues') }}
<gl-icon
v-gl-tooltip.bottom
@@ -77,18 +77,16 @@ export default {
class="text-secondary gl-cursor-help"
/>
</div>
- <div class="col-sm-10">
- <ul class="list-unstyled m-0">
- <li
- v-for="(suggestion, index) in issues"
- :key="suggestion.id"
- :class="{
- 'gl-mb-3': index !== issues.length - 1,
- }"
- >
- <title-suggestions-item :suggestion="suggestion" />
- </li>
- </ul>
- </div>
+ <ul class="gl-list-style-none gl-m-0 gl-p-0">
+ <li
+ v-for="(suggestion, index) in issues"
+ :key="suggestion.id"
+ :class="{
+ 'gl-mb-3': index !== issues.length - 1,
+ }"
+ >
+ <title-suggestions-item :suggestion="suggestion" />
+ </li>
+ </ul>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index daa1632c4aa..892c631f8ea 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -23,6 +23,7 @@ import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import animateMixin from '../mixins/animate';
import { convertDescriptionWithNewSort } from '../utils';
@@ -135,9 +136,6 @@ export default {
this.$nextTick(() => {
this.renderGFM();
- if (this.workItemsEnabled) {
- this.renderTaskActions();
- }
});
},
taskStatus() {
@@ -148,10 +146,6 @@ export default {
this.renderGFM();
this.updateTaskStatusText();
- if (this.workItemsEnabled) {
- this.renderTaskActions();
- }
-
if (this.workItemId) {
const taskLink = this.$el.querySelector(
`.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`,
@@ -178,15 +172,20 @@ export default {
onError: this.taskListUpdateError.bind(this),
});
- if (this.issuableType === IssuableType.Issue) {
- this.renderSortableLists();
+ this.removeAllPointerEventListeners();
+
+ this.renderSortableLists();
+
+ if (this.workItemsEnabled) {
+ this.renderTaskActions();
}
}
},
renderSortableLists() {
- this.removeAllPointerEventListeners();
-
- const lists = document.querySelectorAll('.description ul, .description ol');
+ // We exclude GLFM table of contents which have a `section-nav` class on the root `ul`.
+ const lists = document.querySelectorAll(
+ '.description .md > ul:not(.section-nav), .description .md > ul:not(.section-nav) ul, .description ol',
+ );
lists.forEach((list) => {
if (list.children.length <= 1) {
return;
@@ -194,7 +193,7 @@ export default {
Array.from(list.children).forEach((listItem) => {
listItem.prepend(this.createDragIconElement());
- this.addPointerEventListeners(listItem);
+ this.addPointerEventListeners(listItem, '.drag-icon');
});
Sortable.create(
@@ -216,20 +215,20 @@ export default {
</svg>`;
return container.firstChild;
},
- addPointerEventListeners(listItem) {
+ addPointerEventListeners(listItem, iconSelector) {
const pointeroverListener = (event) => {
- const dragIcon = event.target.closest('li').querySelector('.drag-icon');
- if (!dragIcon || isDragging() || this.isUpdating) {
+ const icon = event.target.closest('li').querySelector(iconSelector);
+ if (!icon || isDragging() || this.isUpdating) {
return;
}
- dragIcon.style.visibility = 'visible';
+ icon.style.visibility = 'visible';
};
const pointeroutListener = (event) => {
- const dragIcon = event.target.closest('li').querySelector('.drag-icon');
- if (!dragIcon) {
+ const icon = event.target.closest('li').querySelector(iconSelector);
+ if (!icon) {
return;
}
- dragIcon.style.visibility = 'hidden';
+ icon.style.visibility = 'hidden';
};
// We use pointerover/pointerout instead of CSS so that when we hover over a
@@ -238,10 +237,16 @@ export default {
listItem.addEventListener('pointerout', pointeroutListener);
this.pointerEventListeners = this.pointerEventListeners || new Map();
- this.pointerEventListeners.set(listItem, [
+ const events = [
{ type: 'pointerover', listener: pointeroverListener },
{ type: 'pointerout', listener: pointeroutListener },
- ]);
+ ];
+ if (this.pointerEventListeners.has(listItem)) {
+ const concatenatedEvents = this.pointerEventListeners.get(listItem).concat(events);
+ this.pointerEventListeners.set(listItem, concatenatedEvents);
+ } else {
+ this.pointerEventListeners.set(listItem, events);
+ }
},
removeAllPointerEventListeners() {
this.pointerEventListeners?.forEach((events, listItem) => {
@@ -311,13 +316,14 @@ export default {
this.workItemId = workItemId;
this.updateWorkItemIdUrlQuery(issue);
this.track('viewed_work_item_from_modal', {
- category: 'workItems:show',
+ category: TRACKING_CATEGORY_SHOW,
label: 'work_item_view',
property: `type_${referenceType}`,
});
});
return;
}
+ this.addPointerEventListeners(item, '.js-add-task');
const button = document.createElement('button');
button.classList.add(
'btn',
@@ -325,6 +331,7 @@ export default {
'btn-md',
'gl-button',
'btn-default-tertiary',
+ 'gl-visibility-hidden',
'gl-p-0!',
'gl-mt-n1',
'gl-ml-3',
@@ -339,7 +346,7 @@ export default {
`;
button.setAttribute('aria-label', s__('WorkItem|Convert to work item'));
button.addEventListener('click', () => this.openCreateTaskModal(button));
- item.append(button);
+ this.insertButtonNextToTaskText(item, button);
});
},
addHoverListeners(taskLink, id) {
@@ -355,9 +362,24 @@ export default {
}
});
},
+ insertButtonNextToTaskText(listItem, button) {
+ const paragraph = Array.from(listItem.children).find((element) => element.tagName === 'P');
+ const lastChild = listItem.lastElementChild;
+ if (paragraph) {
+ // If there's a `p` element, then it's a multi-paragraph task item
+ // and the task text exists within the `p` element as the last child
+ paragraph.append(button);
+ } else if (lastChild.tagName === 'OL' || lastChild.tagName === 'UL') {
+ // Otherwise, the task item can have a child list which exists directly after the task text
+ lastChild.insertAdjacentElement('beforebegin', button);
+ } else {
+ // Otherwise, the task item is a simple one where the task text exists as the last child
+ listItem.append(button);
+ }
+ },
setActiveTask(el) {
const { parentElement } = el;
- const lineNumbers = parentElement.getAttribute('data-sourcepos').match(/\b\d+(?=:)/g);
+ const lineNumbers = parentElement.dataset.sourcepos.match(/\b\d+(?=:)/g);
this.activeTask = {
title: parentElement.innerText,
lineNumberStart: lineNumbers[0],
@@ -431,13 +453,7 @@ export default {
>
</textarea>
- <gl-modal
- ref="modal"
- modal-id="create-task-modal"
- :title="s__('WorkItem|New Task')"
- hide-footer
- body-class="gl-p-0!"
- >
+ <gl-modal ref="modal" size="lg" modal-id="create-task-modal" hide-footer body-class="gl-p-0!">
<create-work-item
is-modal
:initial-title="activeTask.title"
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 4daf6f2b61b..9b31014c1ba 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -77,7 +77,7 @@ export default {
return this.formState.title.trim() !== '';
},
shouldShowDeleteButton() {
- return this.canDestroy && this.showDeleteButton;
+ return this.canDestroy && this.showDeleteButton && this.typeToShow;
},
typeToShow() {
const { issueState, issuableType } = this;
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
new file mode 100644
index 00000000000..7e049d98c1a
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql
@@ -0,0 +1,21 @@
+query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) {
+ project(fullPath: $fullPath) {
+ id
+ incidentManagementTimelineEvents(incidentId: $incidentId) {
+ nodes {
+ id
+ author {
+ id
+ name
+ username
+ }
+ note
+ noteHtml
+ action
+ occurredAt
+ createdAt
+ updatedAt
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
index ea0e15adfed..6fdce6045f2 100644
--- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue
@@ -9,6 +9,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DescriptionComponent from '../description.vue';
import getAlert from './graphql/queries/get_alert.graphql';
import HighlightBar from './highlight_bar.vue';
+import TimelineTab from './timeline_events_tab.vue';
export default {
components: {
@@ -17,8 +18,7 @@ export default {
GlTab,
GlTabs,
HighlightBar,
- TimelineTab: () =>
- import('ee_component/issues/show/components/incidents/timeline_events_tab.vue'),
+ TimelineTab,
IncidentMetricTab: () =>
import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'),
},
@@ -53,7 +53,7 @@ export default {
return this.$apollo.queries.alert.loading;
},
incidentTabEnabled() {
- return this.glFeatures.incidentTimelineEvents && this.glFeatures.incidentTimeline;
+ return this.glFeatures.incidentTimeline;
},
},
mounted() {
@@ -65,17 +65,26 @@ export default {
Tracking.event(category, action);
},
handleTabChange(tabIndex) {
+ /**
+ * TODO: Implement a solution that does not violate Vue principles in using
+ * DOM manipulation directly (#361618)
+ */
const parent = document.querySelector('.js-issue-details');
if (parent !== null) {
const itemsToHide = parent.querySelectorAll('.js-issue-widgets');
const lineSeparator = parent.querySelector('.js-detail-page-description');
+ const editButton = document.querySelector('.js-issuable-edit');
+ const isSummaryTab = tabIndex === 0;
- lineSeparator.classList.toggle('gl-border-b-0', tabIndex > 0);
+ lineSeparator.classList.toggle('gl-border-b-0', !isSummaryTab);
itemsToHide.forEach(function hide(item) {
- item.classList.toggle('gl-display-none', tabIndex > 0);
+ item.classList.toggle('gl-display-none', !isSummaryTab);
});
+
+ editButton.classList.toggle('gl-display-none', !isSummaryTab);
+ editButton.classList.toggle('gl-sm-display-inline-flex!', isSummaryTab);
}
},
},
@@ -103,7 +112,7 @@ export default {
>
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
- <timeline-tab v-if="incidentTabEnabled" data-testid="timeline-events-tab" />
+ <timeline-tab v-if="incidentTabEnabled" />
</gl-tabs>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
new file mode 100644
index 00000000000..a6e58ee0bdc
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -0,0 +1,73 @@
+<script>
+import { formatDate } from '~/lib/utils/datetime_utility';
+import IncidentTimelineEventListItem from './timeline_events_list_item.vue';
+
+export default {
+ name: 'IncidentTimelineEventList',
+ components: {
+ IncidentTimelineEventListItem,
+ },
+ props: {
+ timelineEventLoading: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ timelineEvents: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+ computed: {
+ dateGroupedEvents() {
+ const groupedEvents = new Map();
+
+ this.timelineEvents.forEach((event) => {
+ const date = formatDate(event.occurredAt, 'isoDate', true);
+
+ if (groupedEvents.has(date)) {
+ groupedEvents.get(date).push(event);
+ } else {
+ groupedEvents.set(date, [event]);
+ }
+ });
+
+ return groupedEvents;
+ },
+ },
+ methods: {
+ isLastItem(groups, groupIndex, events, eventIndex) {
+ if (groupIndex < groups.size - 1) {
+ return false;
+ }
+ return eventIndex === events.length - 1;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-discussion incident-timeline-events">
+ <div
+ v-for="([eventDate, events], groupIndex) in dateGroupedEvents"
+ :key="eventDate"
+ data-testid="timeline-group"
+ >
+ <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
+ <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
+ </div>
+ <ul class="notes main-notes-list gl-pl-n3">
+ <incident-timeline-event-list-item
+ v-for="(event, eventIndex) in events"
+ :key="event.id"
+ :action="event.action"
+ :occurred-at="event.occurredAt"
+ :note-html="event.noteHtml"
+ :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
+ data-testid="timeline-event"
+ />
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
new file mode 100644
index 00000000000..fef9bf713b7
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
+import { getEventIcon } from './utils';
+
+export default {
+ name: 'IncidentTimelineEventListItem',
+ i18n: {
+ timeUTC: __('%{time} UTC'),
+ },
+ components: {
+ GlIcon,
+ GlSprintf,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ isLastItem: {
+ type: Boolean,
+ required: true,
+ },
+ occurredAt: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ noteHtml: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ time() {
+ return formatDate(this.occurredAt, 'HH:MM', true);
+ },
+ },
+ methods: {
+ getEventIcon,
+ },
+};
+</script>
+<template>
+ <li
+ class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
+ >
+ <gl-icon :name="getEventIcon(action)" class="note-icon" />
+ </div>
+ <div
+ class="timeline-event-note gl-w-full"
+ :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
+ data-testid="event-text-container"
+ >
+ <strong class="gl-font-lg" data-testid="event-time">
+ <gl-sprintf :message="$options.i18n.timeUTC">
+ <template #time>{{ time }}</template>
+ </gl-sprintf>
+ </strong>
+ <div v-safe-html="noteHtml"></div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
new file mode 100644
index 00000000000..400e1f0b725
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { fetchPolicies } from '~/lib/graphql';
+import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+import { displayAndLogError } from './utils';
+
+import IncidentTimelineEventsList from './timeline_events_list.vue';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlLoadingIcon,
+ GlTab,
+ IncidentTimelineEventsList,
+ },
+ inject: ['fullPath', 'issuableId'],
+ data() {
+ return {
+ timelineEvents: [],
+ };
+ },
+ apollo: {
+ timelineEvents: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: getTimelineEvents,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ };
+ },
+ update(data) {
+ return data.project.incidentManagementTimelineEvents.nodes;
+ },
+ error(error) {
+ displayAndLogError(error);
+ },
+ },
+ },
+ computed: {
+ timelineEventLoading() {
+ return this.$apollo.queries.timelineEvents.loading;
+ },
+ hasTimelineEvents() {
+ return Boolean(this.timelineEvents.length);
+ },
+ showEmptyState() {
+ return !this.timelineEventLoading && !this.hasTimelineEvents;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tab :title="s__('Incident|Timeline')">
+ <gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" />
+ <gl-empty-state
+ v-else-if="showEmptyState"
+ :compact="true"
+ :description="s__('Incident|No timeline items have been added yet.')"
+ />
+ <incident-timeline-events-list
+ v-if="hasTimelineEvents"
+ :timeline-event-loading="timelineEventLoading"
+ :timeline-events="timelineEvents"
+ />
+ </gl-tab>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
new file mode 100644
index 00000000000..8b5a2ec4031
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -0,0 +1,18 @@
+import { createAlert } from '~/flash';
+import { s__ } from '~/locale';
+
+export const displayAndLogError = (error) =>
+ createAlert({
+ message: s__('Incident|Something went wrong while fetching incident timeline events.'),
+ captureError: true,
+ error,
+ });
+
+const EVENT_ICONS = {
+ comment: 'comment',
+ default: 'comment',
+};
+
+export const getEventIcon = (actionName) => {
+ return EVENT_ICONS[actionName] ?? EVENT_ICONS.default;
+};
diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue
index 1982147e454..7f67b31b122 100644
--- a/app/assets/javascripts/issues/show/components/title.vue
+++ b/app/assets/javascripts/issues/show/components/title.vue
@@ -74,7 +74,7 @@ export default {
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="title qa-title"
+ class="title qa-title gl-font-size-h-display"
dir="auto"
></h1>
<gl-button
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 6b0b26ef2e3..5bdad010af7 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -33,6 +33,7 @@ export function initIncidentApp(issueData = {}) {
canCreateIncident,
canUpdate,
iid,
+ issuableId,
projectNamespace,
projectPath,
projectId,
@@ -53,6 +54,7 @@ export function initIncidentApp(issueData = {}) {
canUpdate,
fullPath,
iid,
+ issuableId,
projectId,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable),
@@ -83,7 +85,7 @@ export function initIssueApp(issueData, store) {
bootstrapApollo({ ...issueState, issueType: el.dataset.issueType });
- const { canCreateIncident, ...issueProps } = issueData;
+ const { canCreateIncident, hasIssueWeightsFeature, ...issueProps } = issueData;
return new Vue({
el,
@@ -93,6 +95,7 @@ export function initIssueApp(issueData, store) {
provide: {
canCreateIncident,
fullPath,
+ hasIssueWeightsFeature,
},
computed: {
...mapGetters(['getNoteableData']),
diff --git a/app/assets/javascripts/jira_connect/branches/pages/index.vue b/app/assets/javascripts/jira_connect/branches/pages/index.vue
index d72dec6cdee..953e823ec96 100644
--- a/app/assets/javascripts/jira_connect/branches/pages/index.vue
+++ b/app/assets/javascripts/jira_connect/branches/pages/index.vue
@@ -46,7 +46,7 @@ export default {
<template>
<div>
<div class="gl-border-1 gl-border-b-solid gl-border-gray-100 gl-mb-5 gl-mt-7">
- <h1 data-testid="page-title" class="page-title">{{ pageTitle }}</h1>
+ <h1 data-testid="page-title" class="page-title gl-font-size-h-display">{{ pageTitle }}</h1>
</div>
<new-branch-form v-if="showForm" @success="onNewBranchFormSuccess" />
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
index 7f035dddafe..a9ec7bd971e 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/add_namespace_modal/groups_list.vue
@@ -104,7 +104,7 @@ export default {
@input="onGroupSearch"
/>
- <gl-loading-icon v-if="isLoadingInitial" size="md" />
+ <gl-loading-icon v-if="isLoadingInitial" size="lg" />
<div v-else-if="groups.length === 0" class="gl-text-center">
<h5>{{ s__('Integrations|No available namespaces.') }}</h5>
<p class="gl-mt-5">
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 22422872183..66aea60c5b5 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -60,6 +60,12 @@ export default {
isBrowserSupported() {
return !this.isOauthEnabled || AccessorUtilities.canUseCrypto();
},
+ gitlabUrl() {
+ return gon.gitlab_url;
+ },
+ gitlabLogo() {
+ return gon.gitlab_logo;
+ },
},
created() {
this.setInitialAlert();
@@ -99,43 +105,55 @@ export default {
</script>
<template>
- <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" />
- <div v-else data-testid="jira-connect-app">
- <compatibility-alert class="gl-mb-7" />
-
- <gl-alert
- v-if="shouldShowAlert"
- :variant="alert.variant"
- :title="alert.title"
- class="gl-mb-5"
- data-testid="jira-connect-persisted-alert"
- @dismiss="setAlert"
+ <div>
+ <header
+ class="jira-connect-header gl-display-flex gl-align-items-center gl-justify-content-center gl-px-5 gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-bg-white"
>
- <gl-sprintf v-if="alert.linkUrl" :message="alert.message">
- <template #link="{ content }">
- <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
+ <gl-link :href="gitlabUrl" target="_blank">
+ <img :src="gitlabLogo" class="gl-h-6" :alt="__('GitLab')" />
+ </gl-link>
+ <user-link
+ :user-signed-in="userSignedIn"
+ :has-subscriptions="hasSubscriptions"
+ :user="currentUser"
+ class="gl-fixed gl-right-4"
+ />
+ </header>
- <template v-else>
- {{ alert.message }}
- </template>
- </gl-alert>
+ <main class="jira-connect-app gl-px-5 gl-pt-7 gl-mx-auto">
+ <browser-support-alert v-if="!isBrowserSupported" class="gl-mb-7" />
+ <div v-else data-testid="jira-connect-app">
+ <compatibility-alert class="gl-mb-7" />
- <user-link
- :user-signed-in="userSignedIn"
- :has-subscriptions="hasSubscriptions"
- :user="currentUser"
- />
+ <gl-alert
+ v-if="shouldShowAlert"
+ :variant="alert.variant"
+ :title="alert.title"
+ class="gl-mb-5"
+ data-testid="jira-connect-persisted-alert"
+ @dismiss="setAlert"
+ >
+ <gl-sprintf v-if="alert.linkUrl" :message="alert.message">
+ <template #link="{ content }">
+ <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
- <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
- <sign-in-page
- v-if="!userSignedIn"
- :has-subscriptions="hasSubscriptions"
- @sign-in-oauth="onSignInOauth"
- @error="onSignInError"
- />
- <subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
- </div>
+ <template v-else>
+ {{ alert.message }}
+ </template>
+ </gl-alert>
+
+ <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
+ <sign-in-page
+ v-if="!userSignedIn"
+ :has-subscriptions="hasSubscriptions"
+ @sign-in-oauth="onSignInOauth"
+ @error="onSignInError"
+ />
+ <subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
+ </div>
+ </div>
+ </main>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index b9e8bab019f..ad3e70bcb5f 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -112,7 +112,7 @@ export default {
</script>
<template>
<gl-button
- category="primary"
+ v-bind="$attrs"
variant="info"
:loading="loading"
:disabled="!canUseCrypto"
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
index 5e2c83aff65..b253f888d22 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
@@ -7,7 +8,9 @@ export default {
components: {
GlLink,
GlSprintf,
+ SignInOauthButton: () => import('./sign_in_oauth_button.vue'),
},
+ mixins: [glFeatureFlagMixin()],
inject: {
usersPath: {
default: '',
@@ -51,6 +54,9 @@ export default {
? this.$options.i18n.signedInAsUserText
: this.$options.i18n.signedInText;
},
+ isOauthEnabled() {
+ return this.glFeatures.jiraConnectOauth;
+ },
},
async created() {
this.signInURL = await getGitlabSignInURL(this.usersPath);
@@ -63,7 +69,7 @@ export default {
};
</script>
<template>
- <div class="jira-connect-user gl-font-base">
+ <div class="gl-font-base">
<gl-sprintf v-if="userSignedIn" :message="signedInText">
<template #user_link>
<gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank">
@@ -72,13 +78,14 @@ export default {
</template>
</gl-sprintf>
- <gl-link
- v-else-if="hasSubscriptions"
- data-testid="sign-in-link"
- :href="signInURL"
- target="_blank"
- >
- {{ $options.i18n.signInText }}
- </gl-link>
+ <template v-else-if="hasSubscriptions">
+ <sign-in-oauth-button v-if="isOauthEnabled" category="tertiary">
+ {{ $options.i18n.signInText }}
+ </sign-in-oauth-button>
+
+ <gl-link v-else data-testid="sign-in-link" :href="signInURL" target="_blank">
+ {{ $options.i18n.signInText }}
+ </gl-link>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
index b1c1ae73e14..d7213f683d8 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/subscriptions_page.vue
@@ -29,7 +29,7 @@ export default {
<div>
<h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2>
- <gl-loading-icon v-if="subscriptionsLoading" size="md" />
+ <gl-loading-icon v-if="subscriptionsLoading" size="lg" />
<div v-else-if="hasSubscriptions && !subscriptionsError">
<div class="gl-display-flex gl-justify-content-end gl-mb-3">
<add-namespace-button />
diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue
index 0f690d17da9..5e388900d2a 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -95,7 +95,7 @@ export default {
:illustration="setupIllustration"
:jira-integration-path="jiraIntegrationPath"
/>
- <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" />
+ <gl-loading-icon v-else-if="$apollo.loading" size="lg" class="mt-3" />
<jira-import-progress
v-else-if="jiraImportDetails.isInProgress"
:illustration="inProgressIllustration"
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 8a36a4d2466..f8ca62da1a5 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -254,7 +254,7 @@ export default {
</gl-sprintf>
</gl-alert>
- <h3 class="page-title">{{ __('New Jira import') }}</h3>
+ <h1 class="page-title gl-font-size-h-display">{{ __('New Jira import') }}</h1>
<hr />
@@ -331,7 +331,7 @@ export default {
</template>
</gl-table-lite>
- <gl-loading-icon v-if="isInitialLoadingState" size="md" />
+ <gl-loading-icon v-if="isInitialLoadingState" size="lg" />
<gl-button
v-if="hasMoreUsers"
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index d0594d1ad27..097ab3b4cf6 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -64,9 +64,10 @@ export default {
v-if="isActive"
name="arrow-right"
class="icon-arrow-right gl-absolute gl-display-block"
+ :size="14"
/>
- <ci-icon :status="job.status" />
+ <ci-icon :status="job.status" class="gl-mr-2" :size="14" />
<span class="gl-text-truncate gl-w-full">{{ jobName }}</span>
diff --git a/app/assets/javascripts/jobs/components/log/line_header.vue b/app/assets/javascripts/jobs/components/log/line_header.vue
index c72d488f844..de774e8408b 100644
--- a/app/assets/javascripts/jobs/components/log/line_header.vue
+++ b/app/assets/javascripts/jobs/components/log/line_header.vue
@@ -30,7 +30,7 @@ export default {
},
computed: {
iconName() {
- return this.isClosed ? 'angle-right' : 'angle-down';
+ return this.isClosed ? 'chevron-lg-right' : 'chevron-lg-down';
},
},
methods: {
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
index 7a52a1b0d6b..07ef4f054b4 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -178,7 +178,7 @@ export default {
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-button
class="gl-mt-5"
- variant="info"
+ variant="confirm"
category="primary"
:aria-label="__('Trigger manual job')"
:disabled="triggerBtnDisabled"
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue
index f9cde61e917..d7a26d22406 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/stuck_block.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlBadge, GlLink } from '@gitlab/ui';
+import { GlAlert, GlBadge, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
/**
* Renders Stuck Runners block for job's view.
@@ -9,6 +9,7 @@ export default {
GlAlert,
GlBadge,
GlLink,
+ GlSprintf,
},
props: {
hasOfflineRunnersForProject: {
@@ -29,11 +30,15 @@ export default {
hasNoRunnersWithCorrespondingTags() {
return this.tags.length > 0;
},
+ protectedBranchSettingsDocsLink() {
+ return 'https://docs.gitlab.com/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers';
+ },
stuckData() {
if (this.hasNoRunnersWithCorrespondingTags) {
return {
- text: s__(`Job|This job is stuck because you don't have
- any active runners online or available with any of these tags assigned to them:`),
+ text: s__(
+ `Job|This job is stuck because of one of the following problems. There are no active runners online, no runners for the %{linkStart}protected branch%{linkEnd}, or no runners that match all of the job's tags:`,
+ ),
dataTestId: 'job-stuck-with-tags',
showTags: true,
};
@@ -59,7 +64,17 @@ export default {
<template>
<gl-alert variant="warning" :dismissible="false">
<p class="gl-mb-0" :data-testid="stuckData.dataTestId">
- {{ stuckData.text }}
+ <gl-sprintf :message="stuckData.text">
+ <template #link="{ content }">
+ <a
+ class="gl-display-inline-block"
+ :href="protectedBranchSettingsDocsLink"
+ target="_blank"
+ >
+ {{ content }}
+ </a>
+ </template>
+ </gl-sprintf>
<template v-if="stuckData.showTags">
<gl-badge v-for="tag in tags" :key="tag" variant="info">
{{ tag }}
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 3ea50dfb7a3..1ac1a2d68e2 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -213,7 +213,7 @@ export default {
<gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs">
<gl-loading-icon
v-if="showLoadingSpinner"
- size="md"
+ size="lg"
:aria-label="$options.i18n.loadingAriaLabel"
/>
</gl-intersection-observer>
diff --git a/app/assets/javascripts/labels/components/promote_label_modal.vue b/app/assets/javascripts/labels/components/promote_label_modal.vue
index e708cd32fff..8598500c842 100644
--- a/app/assets/javascripts/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/labels/components/promote_label_modal.vue
@@ -9,7 +9,7 @@ import eventHub from '../event_hub';
export default {
primaryProps: {
text: s__('Labels|Promote Label'),
- attributes: [{ variant: 'warning' }, { category: 'primary' }],
+ attributes: [{ variant: 'confirm' }],
},
cancelProps: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 2b4dd205cf1..ba801082377 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -127,7 +127,7 @@ export default class LazyLoader {
// Loading Images which are in the current viewport or close to them
this.lazyImages = this.lazyImages.filter((selectedImage) => {
- if (selectedImage.getAttribute('data-src')) {
+ if (selectedImage.dataset.src) {
const imgBoundRect = selectedImage.getBoundingClientRect();
const imgTop = scrollTop + imgBoundRect.top;
const imgBound = imgTop + imgBoundRect.height;
@@ -156,16 +156,17 @@ export default class LazyLoader {
}
static loadImage(img) {
- if (img.getAttribute('data-src')) {
+ if (img.dataset.src) {
img.setAttribute('loading', 'lazy');
- let imgUrl = img.getAttribute('data-src');
+ let imgUrl = img.dataset.src;
// Only adding width + height for avatars for now
if (imgUrl.indexOf('/avatar/') > -1 && imgUrl.indexOf('?') === -1) {
const targetWidth = img.getAttribute('width') || img.width;
imgUrl += `?width=${targetWidth}`;
}
img.setAttribute('src', imgUrl);
- img.removeAttribute('data-src');
+ // eslint-disable-next-line no-param-reassign
+ delete img.dataset.src;
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
img.classList.add('qa-js-lazy-loaded');
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index 4e704eb69b2..b4f941294de 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -1,10 +1,33 @@
import { unified } from 'unified';
import remarkParse from 'remark-parse';
-import remarkRehype from 'remark-rehype';
+import remarkGfm from 'remark-gfm';
+import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
const createParser = () => {
- return unified().use(remarkParse).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw);
+ return unified()
+ .use(remarkParse)
+ .use(remarkGfm)
+ .use(remarkRehype, {
+ allowDangerousHtml: true,
+ handlers: {
+ footnoteReference: (h, node) =>
+ h(
+ node.position,
+ 'footnoteReference',
+ { identifier: node.identifier, label: node.label },
+ [],
+ ),
+ footnoteDefinition: (h, node) =>
+ h(
+ node.position,
+ 'footnoteDefinition',
+ { identifier: node.identifier, label: node.label },
+ all(h, node),
+ ),
+ },
+ })
+ .use(rehypeRaw);
};
const compilerFactory = (renderer) =>
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 451950346b0..cfcce234bfb 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -105,7 +105,7 @@ export default (resolvers = {}, config = {}) => {
const {
baseUrl,
batchMax = 10,
- cacheConfig,
+ cacheConfig = { typePolicies: {}, possibleTypes: {} },
fetchPolicy = fetchPolicies.CACHE_FIRST,
typeDefs,
path = '/api/graphql',
@@ -166,6 +166,7 @@ export default (resolvers = {}, config = {}) => {
PerformanceBarService.interceptor({
config: {
url: httpResponse.url,
+ operationName: operation.operationName,
},
headers: {
'x-request-id': httpResponse.headers.get('x-request-id'),
@@ -221,9 +222,15 @@ export default (resolvers = {}, config = {}) => {
typeDefs,
link: appLink,
cache: new InMemoryCache({
- typePolicies,
- possibleTypes,
...cacheConfig,
+ typePolicies: {
+ ...typePolicies,
+ ...cacheConfig.typePolicies,
+ },
+ possibleTypes: {
+ ...possibleTypes,
+ ...cacheConfig.possibleTypes,
+ },
}),
resolvers,
defaultOptions: {
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
index 66d52051905..3d8df4fde05 100644
--- a/app/assets/javascripts/lib/utils/color_utils.js
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -67,7 +67,7 @@ export function darkModeEnabled() {
const ideDarkThemes = ['dark', 'solarized-dark', 'monokai'];
// eslint-disable-next-line @gitlab/require-i18n-strings
- const isWebIde = document.body.dataset.page.startsWith('ide:');
+ const isWebIde = document.body.dataset.page?.startsWith('ide:');
if (isWebIde) {
return ideDarkThemes.includes(window.gon?.user_color_scheme);
diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
index 173116062c9..2dc479db80a 100644
--- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
+++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal.js
@@ -56,7 +56,7 @@ export function confirmAction(
export function confirmViaGlModal(message, element) {
const primaryBtnConfig = {};
- const confirmBtnVariant = element.getAttribute('data-confirm-btn-variant');
+ const { confirmBtnVariant } = element.dataset;
if (confirmBtnVariant) {
primaryBtnConfig.primaryBtnVariant = confirmBtnVariant;
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 4262329aae7..bca6978c206 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -67,17 +67,6 @@ export const parseBooleanDataAttributes = ({ dataset }, names) =>
export const isElementVisible = (element) =>
Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
-/**
- * The opposite of `isElementVisible`.
- * Returns whether or not the provided element is currently hidden.
- * This function operates identically to jQuery's `:hidden` pseudo-selector.
- * Documentation for this selector: https://api.jquery.com/hidden-selector/
- * Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L6
- * @param {HTMLElement} element The element to test
- * @returns {Boolean} `true` if the element is currently hidden, otherwise false
- */
-export const isElementHidden = (element) => !isElementVisible(element);
-
export const getParents = (element) => {
const parents = [];
let parent = element.parentNode;
diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js
index b58aef15dda..1d8c6ee23fc 100644
--- a/app/assets/javascripts/lib/utils/forms.js
+++ b/app/assets/javascripts/lib/utils/forms.js
@@ -119,12 +119,14 @@ export const parseRailsFormFields = (mountEl) => {
}
const fieldNameCamelCase = convertToCamelCase(fieldName);
- const { id, placeholder, name, value, type, checked } = input;
+ const { id, placeholder, name, value, type, checked, maxLength, pattern } = input;
const attributes = {
name,
id,
value,
...(placeholder && { placeholder }),
+ ...(input.hasAttribute('maxlength') && { maxLength }),
+ ...(pattern && { pattern }),
};
// Store radio buttons and checkboxes as an array so they can be
diff --git a/app/assets/javascripts/lib/utils/rails_ujs.js b/app/assets/javascripts/lib/utils/rails_ujs.js
index b4f425da871..48f1b32526f 100644
--- a/app/assets/javascripts/lib/utils/rails_ujs.js
+++ b/app/assets/javascripts/lib/utils/rails_ujs.js
@@ -37,9 +37,7 @@ function monkeyPatchConfirmModal() {
Rails.confirm = confirmViaModal;
}
-if (gon?.features?.bootstrapConfirmationModals) {
- monkeyPatchConfirmModal();
-}
+monkeyPatchConfirmModal();
export const initRails = () => {
// eslint-disable-next-line no-underscore-dangle
diff --git a/app/assets/javascripts/lib/utils/table_utility.js b/app/assets/javascripts/lib/utils/table_utility.js
index 6d66335b832..5d3aba9f4ed 100644
--- a/app/assets/javascripts/lib/utils/table_utility.js
+++ b/app/assets/javascripts/lib/utils/table_utility.js
@@ -2,6 +2,7 @@ import { convertToSnakeCase, convertToCamelCase } from '~/lib/utils/text_utility
import { DEFAULT_TH_CLASSES } from './constants';
/**
+ * Deprecated: use thWidthPercent instead
* Generates the table header classes to be used for GlTable fields.
*
* @param {Number} width - The column width as a percentage.
@@ -10,6 +11,15 @@ import { DEFAULT_TH_CLASSES } from './constants';
export const thWidthClass = (width) => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
/**
+ * Generates the table header class for width to be used for GlTable fields.
+ *
+ * @param {Number} width - The column width as a percentage. Only accepts values
+ * as defined in https://gitlab.com/gitlab-org/gitlab-ui/blob/main/src/scss/utility-mixins/sizing.scss
+ * @returns {String} The class to be used in GlTable fields object.
+ */
+export const thWidthPercent = (width) => `gl-w-${width}p`;
+
+/**
* Converts a GlTable sort-changed event object into string format.
* This string can be used as a sort argument on GraphQL queries.
*
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
index bd000bb26fe..670acbbabd7 100644
--- a/app/assets/javascripts/lib/utils/users_cache.js
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -29,8 +29,11 @@ class UsersCache extends Cache {
}
return getUser(userId).then(({ data }) => {
- this.internalStorage[userId] = data;
- return data;
+ this.internalStorage[userId] = {
+ ...this.get(userId),
+ ...data,
+ };
+ return this.internalStorage[userId];
});
// missing catch is intentional, error handling depends on use case
}
diff --git a/app/assets/javascripts/logo.js b/app/assets/javascripts/logo.js
index 403e216e70f..c570f8810a8 100644
--- a/app/assets/javascripts/logo.js
+++ b/app/assets/javascripts/logo.js
@@ -1,7 +1,5 @@
-import $ from 'jquery';
-
export default function initLogoAnimation() {
window.addEventListener('beforeunload', () => {
- $('.tanuki-logo').addClass('animate');
+ document.querySelector('.tanuki-logo').classList.add('animate');
});
}
diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js
index 8e21863dd0c..74c2f8a68f8 100644
--- a/app/assets/javascripts/logs/utils.js
+++ b/app/assets/javascripts/logs/utils.js
@@ -1,25 +1,4 @@
import dateFormat from 'dateformat';
-import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import { dateFormatMask } from './constants';
-/**
- * Returns a time range (`start`, `end`) where `start` is the
- * current time minus a given number of seconds and `end`
- * is the current time (`now()`).
- *
- * @param {Number} seconds Seconds duration, defaults to 0.
- * @returns {Object} range Time range
- * @returns {String} range.start ISO String of current time minus given seconds
- * @returns {String} range.end ISO String of current time
- */
-export const getTimeRange = (seconds = 0) => {
- const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
- const start = end - seconds;
-
- return {
- start: new Date(secondsToMilliseconds(start)).toISOString(),
- end: new Date(secondsToMilliseconds(end)).toISOString(),
- };
-};
-
export const formatDate = (timestamp) => dateFormat(timestamp, dateFormatMask);
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 2f3cdc525a7..e3e8efdd771 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -299,3 +299,10 @@ if (flashContainer && flashContainer.children.length) {
$('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form));
requestIdleCallback(deferredInitialisation);
+
+// initialize hiding of tooltip after clicking on dropdown's links and buttons
+document
+ .querySelectorAll('a[data-toggle="dropdown"], button[data-toggle="dropdown"]')
+ .forEach((element) => {
+ element.addEventListener('click', () => tooltips.hide(element));
+ });
diff --git a/app/assets/javascripts/members/components/members_tabs.vue b/app/assets/javascripts/members/components/members_tabs.vue
index ee4743010cf..98995730df4 100644
--- a/app/assets/javascripts/members/components/members_tabs.vue
+++ b/app/assets/javascripts/members/components/members_tabs.vue
@@ -77,6 +77,10 @@ export default {
urlParams.push(state.filteredSearchBar.searchParam);
}
+ if (state?.filteredSearchBar?.tokens) {
+ urlParams.push(...state.filteredSearchBar.tokens);
+ }
+
return urlParams;
},
getTabCount({ namespace }) {
diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index 14d628e455c..460dc0041ab 100644
--- a/app/assets/javascripts/members/components/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -4,7 +4,6 @@ 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 UserDate from '~/vue_shared/components/user_date.vue';
import {
FIELD_KEY_ACTIONS,
@@ -13,9 +12,9 @@ import {
TAB_QUERY_PARAM_VALUES,
MEMBER_STATE_AWAITING,
MEMBER_STATE_ACTIVE,
- USER_STATE_BLOCKED_PENDING_APPROVAL,
- BADGE_LABELS_AWAITING_USER_SIGNUP,
- BADGE_LABELS_PENDING_OWNER_APPROVAL,
+ USER_STATE_BLOCKED,
+ BADGE_LABELS_AWAITING_SIGNUP,
+ BADGE_LABELS_PENDING,
} from '../../constants';
import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue';
import RemoveMemberModal from '../modals/remove_member_modal.vue';
@@ -85,9 +84,6 @@ export default {
return this.tabQueryParamValue === TAB_QUERY_PARAM_VALUES.invite;
},
},
- mounted() {
- initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
- },
methods: {
hasActionButtons(member) {
return (
@@ -166,7 +162,7 @@ export default {
);
},
/**
- * Returns whether the user is awaiting root approval
+ * Returns whether the user is blocked awaiting root approval
*
* This checks User.state exposed via MemberEntity
*
@@ -174,11 +170,11 @@ export default {
* @see {@link ~/app/serializers/member_entity.rb}
* @returns {boolean}
*/
- isUserPendingRootApproval(memberInviteMetadata) {
- return memberInviteMetadata?.userState === USER_STATE_BLOCKED_PENDING_APPROVAL;
+ isUserBlocked(memberInviteMetadata) {
+ return memberInviteMetadata?.userState === USER_STATE_BLOCKED;
},
/**
- * Returns whether the member is awaiting owner approval
+ * Returns whether the member is awaiting state
*
* This checks Member.state exposed via MemberEntity
*
@@ -187,16 +183,13 @@ export default {
* @see {@link ~/app/serializers/member_entity.rb}
* @returns {boolean}
*/
- isMemberPendingOwnerApproval(memberState) {
+ isMemberAwaiting(memberState) {
return memberState === MEMBER_STATE_AWAITING;
},
isUserAwaiting(memberInviteMetadata, memberState) {
- return (
- this.isUserPendingRootApproval(memberInviteMetadata) ||
- this.isMemberPendingOwnerApproval(memberState)
- );
+ return this.isUserBlocked(memberInviteMetadata) || this.isMemberAwaiting(memberState);
},
- shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState) {
+ shouldAddPendingBadge(memberInviteMetadata, memberState) {
return (
this.isUserAwaiting(memberInviteMetadata, memberState) &&
!this.isNewUser(memberInviteMetadata)
@@ -213,11 +206,11 @@ export default {
*/
inviteBadge(memberInviteMetadata, memberState) {
if (this.isNewUser(memberInviteMetadata, memberState)) {
- return BADGE_LABELS_AWAITING_USER_SIGNUP;
+ return BADGE_LABELS_AWAITING_SIGNUP;
}
- if (this.shouldAddPendingOwnerApprovalBadge(memberInviteMetadata, memberState)) {
- return BADGE_LABELS_PENDING_OWNER_APPROVAL;
+ if (this.shouldAddPendingBadge(memberInviteMetadata, memberState)) {
+ return BADGE_LABELS_PENDING;
}
return '';
diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index fa895cf24c4..6cd8bf57313 100644
--- a/app/assets/javascripts/members/components/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -41,7 +41,7 @@ export default {
const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle');
if (dropdownToggle) {
- dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown');
+ dropdownToggle.dataset.qaSelector = 'access_level_dropdown';
}
},
methods: {
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index c66a19c4765..8c40cc3f29d 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -130,9 +130,15 @@ export const FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS = {
],
};
+export const FILTERED_SEARCH_TOKEN_GROUPS_WITH_INHERITED_PERMISSIONS = {
+ ...FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS,
+ type: 'groups_with_inherited_permissions',
+};
+
export const AVAILABLE_FILTERED_SEARCH_TOKENS = [
FILTERED_SEARCH_TOKEN_TWO_FACTOR,
FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS,
+ FILTERED_SEARCH_TOKEN_GROUPS_WITH_INHERITED_PERMISSIONS,
];
export const AVATAR_SIZE = 48;
@@ -154,7 +160,7 @@ export const TAB_QUERY_PARAM_VALUES = {
* This user state value comes from the User model
* see the state machine in app/models/user.rb
*/
-export const USER_STATE_BLOCKED_PENDING_APPROVAL = 'blocked_pending_approval';
+export const USER_STATE_BLOCKED = 'blocked_pending_approval';
/**
* This and following member state constants' values
@@ -164,8 +170,8 @@ export const MEMBER_STATE_CREATED = 0;
export const MEMBER_STATE_AWAITING = 1;
export const MEMBER_STATE_ACTIVE = 2;
-export const BADGE_LABELS_AWAITING_USER_SIGNUP = __('Awaiting user signup');
-export const BADGE_LABELS_PENDING_OWNER_APPROVAL = __('Pending owner approval');
+export const BADGE_LABELS_AWAITING_SIGNUP = __('Awaiting user signup');
+export const BADGE_LABELS_PENDING = __('Pending owner action');
export const DAYS_TO_EXPIRE_SOON = 7;
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 fdcb99351a7..20ee9a17fa0 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue
@@ -31,7 +31,7 @@ export default {
},
inject: ['mergeRequestPath', 'sourceBranchPath', 'resolveConflictsPath'],
i18n: {
- commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'),
+ commitStatSummary: __('Showing %{conflict}'),
resolveInfo: __(
'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}',
),
@@ -73,7 +73,7 @@ export default {
</script>
<template>
<div id="conflicts">
- <gl-loading-icon v-if="isLoading" size="md" data-testid="loading-spinner" />
+ <gl-loading-icon v-if="isLoading" size="lg" data-testid="loading-spinner" />
<div v-if="hasError" class="nothing-here-block">
{{ conflictsData.errorMessage }}
</div>
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 960b25bb552..8cdb9eb5fc4 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -95,7 +95,7 @@ MergeRequest.prototype.initMRBtnListeners = function () {
.then(({ data }) => {
draftToggle.removeAttribute('disabled');
eventHub.$emit('MRWidgetUpdateRequested');
- MergeRequest.toggleDraftStatus(data.title, wipEvent === 'unwip');
+ MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready');
})
.catch(() => {
createFlash({
@@ -156,11 +156,7 @@ MergeRequest.toggleDraftStatus = function (title, isReady) {
} else {
toast(__('Marked as draft. Can only be merged when marked as ready.'));
}
- const titleEl = document.querySelector(
- `.merge-request .detail-page-${
- window.gon?.features?.updatedMrHeader ? 'header' : 'description'
- } .title`,
- );
+ const titleEl = document.querySelector(`.merge-request .detail-page-header .title`);
if (titleEl) {
titleEl.textContent = title;
@@ -172,7 +168,7 @@ MergeRequest.toggleDraftStatus = function (title, isReady) {
draftToggles.forEach((el) => {
const draftToggle = el;
const url = setUrlParams(
- { 'merge_request[wip_event]': isReady ? 'wip' : 'unwip' },
+ { 'merge_request[wip_event]': isReady ? 'draft' : 'ready' },
draftToggle.href,
);
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index e02109d1fd1..94041d77bb0 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -177,6 +177,7 @@ export default class MergeRequestTabs {
this.peek = document.getElementById('js-peek');
this.sidebar = document.querySelector('.js-right-sidebar');
this.pageLayout = document.querySelector('.layout-page');
+ this.expandSidebar = document.querySelector('.js-expand-sidebar');
this.paddingTop = 16;
this.scrollPositions = {};
@@ -281,6 +282,8 @@ export default class MergeRequestTabs {
const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`);
if (tab) tab.classList.add('active');
+ this.expandSidebar?.classList.toggle('gl-display-none!', action !== 'show');
+
if (action === 'commits') {
this.loadCommits(href);
// this.hideSidebar();
diff --git a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
index b41611001ab..cac6d722ced 100644
--- a/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/milestones/components/promote_milestone_modal.vue
@@ -80,7 +80,7 @@ export default {
},
primaryAction: {
text: s__('Milestones|Promote Milestone'),
- attributes: [{ variant: 'warning' }],
+ attributes: [{ variant: 'confirm' }],
},
cancelAction: {
text: __('Cancel'),
diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
index 288487d25a5..10178366db5 100644
--- a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
+++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
@@ -47,7 +47,7 @@ export default {
<gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button>
<gl-button
category="secondary"
- variant="info"
+ variant="confirm"
target="_blank"
:href="addDashboardDocumentationPath"
data-testid="create-dashboard-modal-docs-button"
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 6a85833db27..70e253508ce 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -132,16 +132,6 @@ export default {
required: false,
default: false,
},
- alertsEndpoint: {
- type: String,
- required: false,
- default: null,
- },
- prometheusAlertsAvailable: {
- type: Boolean,
- required: false,
- default: false,
- },
rearrangePanelsAvailable: {
type: Boolean,
required: false,
@@ -461,9 +451,7 @@ export default {
:settings-path="settingsPath"
:clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
:graph-data="expandedPanel.panel"
- :alerts-endpoint="alertsEndpoint"
:height="600"
- :prometheus-alerts-available="prometheusAlertsAvailable"
@timerangezoom="onTimeRangeZoom"
>
<template #top-left>
@@ -526,8 +514,6 @@ export default {
:settings-path="settingsPath"
:clipboard-text="generatePanelUrl(groupData.group, graphData)"
:graph-data="graphData"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
@timerangezoom="onTimeRangeZoom"
@expand="onExpandPanel(groupData.group, graphData)"
/>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
index 8e5a0b5cda2..7f8fb3c223d 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
@@ -186,7 +186,7 @@ export default {
v-track-event="getAddMetricTrackingOptions()"
data-testid="add-metric-modal-submit-button"
:disabled="!customMetricsFormIsValid"
- variant="success"
+ variant="confirm"
@click="submitCustomMetricsForm"
>
{{ __('Save changes') }}
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index f53f78a3f13..f18290e7048 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -257,7 +257,7 @@ export default {
>
<gl-button
class="flex-grow-1 js-external-dashboard-link"
- variant="info"
+ variant="confirm"
category="primary"
:href="externalDashboardUrl"
target="_blank"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
index e5f0206bb8b..8efea2bfc3e 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue
@@ -106,7 +106,7 @@ export default {
<div class="gl-text-right">
<gl-button
ref="clipboardCopyBtn"
- variant="success"
+ variant="confirm"
category="secondary"
:data-clipboard-text="yml"
class="gl-xs-w-full gl-xs-mb-3"
@@ -116,7 +116,7 @@ export default {
</gl-button>
<gl-button
type="submit"
- variant="success"
+ variant="confirm"
:disabled="panelPreviewIsLoading"
class="js-no-auto-disable gl-xs-w-full"
>
@@ -162,7 +162,7 @@ export default {
ref="viewDocumentationBtn"
category="secondary"
class="gl-xs-w-full gl-xs-mb-3"
- variant="info"
+ variant="confirm"
target="_blank"
:href="addDashboardDocumentationPath"
>
@@ -170,7 +170,7 @@ export default {
</gl-button>
<gl-button
ref="openRepositoryBtn"
- variant="success"
+ variant="confirm"
:href="projectPath"
class="gl-xs-w-full"
>
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
index fd07a41ec37..d1ce7bad39a 100644
--- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
@@ -1,7 +1,7 @@
<script>
-import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { GlAlert, GlModal } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
const events = {
@@ -9,7 +9,7 @@ const events = {
};
export default {
- components: { GlAlert, GlLoadingIcon, GlModal, DuplicateDashboardForm },
+ components: { GlAlert, GlModal, DuplicateDashboardForm },
props: {
defaultBranch: {
type: String,
@@ -32,6 +32,20 @@ export default {
okButtonText() {
return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
},
+ actionPrimaryProps() {
+ return {
+ text: this.okButtonText,
+ attributes: {
+ loading: this.loading,
+ variant: 'confirm',
+ },
+ };
+ },
+ actionCancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
@@ -75,7 +89,8 @@ export default {
ref="duplicateDashboardModal"
:modal-id="modalId"
:title="s__('Metrics|Duplicate dashboard')"
- ok-variant="success"
+ :action-primary="actionPrimaryProps"
+ :action-cancel="actionCancelProps"
@ok="ok"
@hide="hide"
>
@@ -87,9 +102,5 @@ export default {
:default-branch="defaultBranch"
@change="formChange"
/>
- <template #modal-ok>
- <gl-loading-icon v-if="loading" size="sm" inline color="light" />
- {{ okButtonText }}
- </template>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 5b73fb4e10d..74a806c50a9 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -37,7 +37,7 @@ export default {
},
computed: {
caretIcon() {
- return this.isCollapsed ? 'angle-right' : 'angle-down';
+ return this.isCollapsed ? 'chevron-lg-right' : 'chevron-lg-down';
},
},
watch: {
diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js
deleted file mode 100644
index cb6dac7aa15..00000000000
--- a/app/assets/javascripts/monitoring/services/alerts_service.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-const mapAlert = ({ runbook_url, ...alert }) => {
- return { runbookUrl: runbook_url, ...alert };
-};
-
-export default class AlertsService {
- constructor({ alertsEndpoint }) {
- this.alertsEndpoint = alertsEndpoint;
- }
-
- getAlerts() {
- return axios.get(this.alertsEndpoint).then((resp) => mapAlert(resp.data));
- }
-
- createAlert({ prometheus_metric_id, operator, threshold, runbookUrl }) {
- return axios
- .post(this.alertsEndpoint, {
- prometheus_metric_id,
- operator,
- threshold,
- runbook_url: runbookUrl,
- })
- .then((resp) => mapAlert(resp.data));
- }
-
- // eslint-disable-next-line class-methods-use-this
- readAlert(alertPath) {
- return axios.get(alertPath).then((resp) => mapAlert(resp.data));
- }
-
- // eslint-disable-next-line class-methods-use-this
- updateAlert(alertPath, { operator, threshold, runbookUrl }) {
- return axios
- .put(alertPath, { operator, threshold, runbook_url: runbookUrl })
- .then((resp) => mapAlert(resp.data));
- }
-
- // eslint-disable-next-line class-methods-use-this
- deleteAlert(alertPath) {
- return axios.delete(alertPath).then((resp) => resp.data);
- }
-}
diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js
deleted file mode 100644
index 714cf67e0bd..00000000000
--- a/app/assets/javascripts/mr_popover/index.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import MRPopover from './components/mr_popover.vue';
-
-let renderedPopover;
-let renderFn;
-
-const handleUserPopoverMouseOut = ({ target }) => {
- target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
-
- if (renderFn) {
- clearTimeout(renderFn);
- }
- if (renderedPopover) {
- renderedPopover.$destroy();
- renderedPopover = null;
- }
-};
-
-/**
- * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
- * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
- */
-const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => ({ target }) => {
- // Add listener to actually remove it again
- target.addEventListener('mouseleave', handleUserPopoverMouseOut);
-
- renderFn = setTimeout(() => {
- const MRPopoverComponent = Vue.extend(MRPopover);
- renderedPopover = new MRPopoverComponent({
- propsData: {
- target,
- projectPath,
- mergeRequestIID: iid,
- mergeRequestTitle: mrTitle,
- },
- apolloProvider,
- });
-
- renderedPopover.$mount();
- }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
-};
-
-export default (elements) => {
- const mrLinks = elements || [...document.querySelectorAll('.gfm-merge_request')];
- if (mrLinks.length > 0) {
- Vue.use(VueApollo);
-
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
- const listenerAddedAttr = 'data-mr-listener-added';
-
- mrLinks.forEach((el) => {
- const { projectPath, mrTitle, iid } = el.dataset;
-
- if (!el.getAttribute(listenerAddedAttr) && projectPath && mrTitle && iid) {
- el.addEventListener(
- 'mouseenter',
- handleMRPopoverMount({ apolloProvider, projectPath, mrTitle, iid }),
- );
- el.setAttribute(listenerAddedAttr, true);
- }
- });
- }
-};
diff --git a/app/assets/javascripts/nav/components/responsive_header.vue b/app/assets/javascripts/nav/components/responsive_header.vue
index 8a1d21993b7..e29b4a67383 100644
--- a/app/assets/javascripts/nav/components/responsive_header.vue
+++ b/app/assets/javascripts/nav/components/responsive_header.vue
@@ -14,7 +14,7 @@ export default {
return {
id: 'home',
view: 'home',
- icon: 'angle-left',
+ icon: 'chevron-lg-left',
};
},
},
diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue
index 9638c20e28c..84bda1b0b5c 100644
--- a/app/assets/javascripts/notes/components/comment_field_layout.vue
+++ b/app/assets/javascripts/notes/components/comment_field_layout.vue
@@ -14,7 +14,7 @@ export default {
type: Object,
required: true,
},
- noteIsConfidential: {
+ isInternalNote: {
type: Boolean,
required: false,
default: false,
@@ -44,7 +44,7 @@ export default {
return this.noteableData.issue_email_participants?.map(({ email }) => email) || [];
},
showEmailParticipantsWarning() {
- return this.emailParticipants.length && !this.noteIsConfidential;
+ return this.emailParticipants.length && !this.isInternalNote;
},
},
};
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 8ef071034e5..e7ac27c5e3e 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -60,7 +60,7 @@ export default {
note: '',
noteType: constants.COMMENT,
errors: [],
- noteIsConfidential: false,
+ noteIsInternal: false,
isSubmitting: false,
};
},
@@ -91,13 +91,13 @@ export default {
},
commentButtonTitle() {
const { comment, internalComment, startThread, startInternalThread } = this.$options.i18n;
- if (this.noteIsConfidential) {
+ if (this.noteIsInternal) {
return this.noteType === constants.COMMENT ? internalComment : startInternalThread;
}
return this.noteType === constants.COMMENT ? comment : startThread;
},
textareaPlaceholder() {
- return this.noteIsConfidential
+ return this.noteIsInternal
? this.$options.i18n.bodyPlaceholderInternal
: this.$options.i18n.bodyPlaceholder;
},
@@ -110,7 +110,7 @@ export default {
canCreateNote() {
return this.getNoteableData.current_user.can_create_note;
},
- canSetConfidential() {
+ canSetInternalNote() {
return this.getNoteableData.current_user.can_update && (this.isIssue || this.isEpic);
},
issueActionButtonTitle() {
@@ -172,7 +172,7 @@ export default {
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
},
- confidentialNotesEnabled() {
+ internalNotesEnabled() {
return Boolean(this.glFeatures.confidentialNotes);
},
disableSubmitButton() {
@@ -217,7 +217,11 @@ export default {
note: {
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
- confidential: this.noteIsConfidential,
+ // Internal notes were identified as `confidential`
+ // before we decided to treat them as _internal_
+ // so now until API is updated we need to use `confidential`
+ // in request payload.
+ confidential: this.noteIsInternal,
note: this.note,
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
@@ -292,7 +296,7 @@ export default {
if (shouldClear) {
this.note = '';
- this.noteIsConfidential = false;
+ this.noteIsInternal = false;
this.resizeTextarea();
this.$refs.markdownField.previewMarkdown = false;
}
@@ -356,7 +360,7 @@ export default {
<comment-field-layout
:with-alert-container="true"
:noteable-data="getNoteableData"
- :note-is-confidential="noteIsConfidential"
+ :is-internal-note="noteIsInternal"
:noteable-type="noteableType"
>
<markdown-field
@@ -410,17 +414,17 @@ export default {
</template>
<template v-else>
<gl-form-checkbox
- v-if="confidentialNotesEnabled && canSetConfidential"
- v-model="noteIsConfidential"
- class="gl-mb-6"
- data-testid="confidential-note-checkbox"
+ v-if="internalNotesEnabled && canSetInternalNote"
+ v-model="noteIsInternal"
+ class="gl-mb-2"
+ data-testid="internal-note-checkbox"
>
- {{ $options.i18n.confidential }}
+ {{ $options.i18n.internal }}
<gl-icon
v-gl-tooltip:tooltipcontainer.bottom
name="question"
:size="16"
- :title="$options.i18n.confidentialVisibility"
+ :title="$options.i18n.internalVisibility"
class="gl-text-gray-500"
/>
</gl-form-checkbox>
@@ -429,7 +433,7 @@ export default {
class="gl-mr-3"
:disabled="disableSubmitButton"
:tracking-label="trackingLabel"
- :is-internal-note="noteIsConfidential"
+ :is-internal-note="noteIsInternal"
:noteable-display-name="noteableDisplayName"
:discussions-require-resolution="discussionsRequireResolution"
@click="handleSave"
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 5210d2ca287..0e213028c7c 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -97,14 +97,17 @@ export default {
</script>
<template>
- <div class="discussion-header note-wrapper">
- <div v-once class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-mr-4">
+ <div class="discussion-header gl-display-flex gl-align-items-center gl-p-5">
+ <div
+ v-once
+ class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-ml-3 gl-mr-4"
+ >
<user-avatar-link
v-if="author"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
- :img-size="32"
+ :img-size="24"
:img-css-classes="'gl-mr-0!' /* NOTE: this is needed only while we migrate user-avatar-image to GlAvatar (https://gitlab.com/groups/gitlab-org/-/epics/7731) */"
/>
</div>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index e2b0c7fee32..3bdf8349a12 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -1,8 +1,5 @@
<script>
-import {
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
+import { GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
@@ -16,7 +13,7 @@ const FIRST_CHAR_REGEX = /^(\+|-| )/;
export default {
components: {
DiffFileHeader,
- GlSkeletonLoading,
+ GlSkeletonLoader,
DiffViewer,
ImageDiffOverlay,
},
@@ -107,7 +104,7 @@ export default {
<td v-if="error" class="js-error-lazy-load-diff diff-loading-error-block">
{{ __('Unable to load the diff') }}
<button
- class="btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button"
+ class="gl-button btn-link btn-link-retry gl-p-0 js-toggle-lazy-diff-retry-button gl-reset-font-size!"
@click="fetchDiff"
>
{{ __('Try again') }}
@@ -115,7 +112,7 @@ export default {
</td>
<td v-else class="line_content js-success-lazy-load">
<span></span>
- <gl-skeleton-loading />
+ <gl-skeleton-loader />
<span></span>
</td>
</tr>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index f746f7ed0ed..eedcb0c09d4 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -75,25 +75,25 @@ export default {
<gl-button-group class="gl-ml-3">
<gl-button
v-gl-tooltip.hover
- :title="__('Jump to previous unresolved thread')"
- :aria-label="__('Jump to previous unresolved thread')"
+ :title="__('Go to previous unresolved thread')"
+ :aria-label="__('Go to previous unresolved thread')"
class="discussion-previous-btn gl-rounded-base! gl-px-2!"
data-track-action="click_button"
data-track-label="mr_previous_unresolved_thread"
data-track-property="click_previous_unresolved_thread_top"
- icon="angle-up"
+ icon="chevron-lg-up"
category="tertiary"
@click="jumpToPreviousDiscussion"
/>
<gl-button
v-gl-tooltip.hover
- :title="__('Jump to next unresolved thread')"
- :aria-label="__('Jump to next unresolved thread')"
+ :title="__('Go to next unresolved thread')"
+ :aria-label="__('Go to next unresolved thread')"
class="discussion-next-btn gl-rounded-base! gl-px-2!"
data-track-action="click_button"
data-track-label="mr_next_unresolved_thread"
data-track-property="click_next_unresolved_thread_top"
- icon="angle-down"
+ icon="chevron-lg-down"
category="tertiary"
@click="jumpToNextDiscussion"
/>
diff --git a/app/assets/javascripts/notes/components/email_participants_warning.vue b/app/assets/javascripts/notes/components/email_participants_warning.vue
index ecf42fce1d2..1875d48e7b2 100644
--- a/app/assets/javascripts/notes/components/email_participants_warning.vue
+++ b/app/assets/javascripts/notes/components/email_participants_warning.vue
@@ -58,7 +58,7 @@ export default {
<div class="issuable-note-warning" data-testid="email-participants-warning">
<gl-sprintf :message="message">
<template #andMore>
- <button type="button" class="btn-transparent btn-link" @click="showMoreParticipants">
+ <button type="button" class="gl-button btn-link" @click="showMoreParticipants">
{{ moreLabel }}
</button>
</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 1bd2f879e6c..10e3f57a56d 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -294,14 +294,20 @@ export default {
/>
<emoji-picker
v-if="canAwardEmoji"
- toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-3 gl-p-0! gl-shadow-none! gl-bg-transparent!"
+ toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
data-testid="note-emoji-button"
@click="setAwardEmoji"
>
<template #button-content>
- <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" />
- <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" />
- <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" />
+ <gl-icon class="award-control-icon-neutral gl-button-icon gl-icon" name="slight-smile" />
+ <gl-icon
+ class="award-control-icon-positive gl-button-icon gl-icon gl-left-3!"
+ name="smiley"
+ />
+ <gl-icon
+ class="award-control-icon-super-positive gl-button-icon gl-icon gl-left-3!"
+ name="smile"
+ />
</template>
</emoji-picker>
<reply-button
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 6c9bc4461c2..cc74c2ee605 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -8,6 +8,7 @@ import { __ } from '~/locale';
import '~/behaviors/markdown/render_gfm';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import autosave from '../mixins/autosave';
+import { INTERNAL_NOTE_CLASSES } from '../constants';
import noteAttachment from './note_attachment.vue';
import noteAwardsList from './note_awards_list.vue';
import noteEditedText from './note_edited_text.vue';
@@ -54,6 +55,11 @@ export default {
required: false,
default: '',
},
+ isInternalNote: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
@@ -95,6 +101,12 @@ export default {
return escape(suggestion);
},
+ internalNoteContainerClasses() {
+ if (this.isInternalNote && !this.isEditing) {
+ return INTERNAL_NOTE_CLASSES;
+ }
+ return '';
+ },
},
mounted() {
this.renderGFM();
@@ -160,53 +172,61 @@ export default {
</script>
<template>
- <div ref="note-body" :class="{ 'js-task-list-container': canEdit }" class="note-body">
- <suggestions
- v-if="hasSuggestion && !isEditing"
- :suggestions="note.suggestions"
- :suggestions-count="suggestionsCount"
- :batch-suggestions-info="batchSuggestionsInfo"
- :note-html="note.note_html"
- :line-type="lineType"
- :help-page-path="helpPagePath"
- :default-commit-message="commitMessage"
- :failed-to-load-metadata="failedToLoadMetadata"
- @apply="applySuggestion"
- @applyBatch="applySuggestionBatch"
- @addToBatch="addSuggestionToBatch"
- @removeFromBatch="removeSuggestionFromBatch"
- />
- <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div>
- <note-form
- v-if="isEditing"
- ref="noteForm"
- :note-body="noteBody"
- :note-id="note.id"
- :line="line"
- :note="note"
- :save-button-title="saveButtonTitle"
- :help-page-path="helpPagePath"
- :discussion="discussion"
- :resolve-discussion="note.resolve_discussion"
- @handleFormUpdate="handleFormUpdate"
- @cancelForm="formCancelHandler"
- />
- <!-- eslint-disable vue/no-mutating-props -->
- <textarea
- v-if="canEdit"
- v-model="note.note"
- :data-update-url="note.path"
- class="hidden js-task-list-field"
- dir="auto"
- ></textarea>
- <!-- eslint-enable vue/no-mutating-props -->
- <note-edited-text
- v-if="note.last_edited_at"
- :edited-at="note.last_edited_at"
- :edited-by="note.last_edited_by"
- action-text="Edited"
- class="note_edited_ago"
- />
+ <div
+ ref="note-body"
+ :class="{
+ 'js-task-list-container': canEdit,
+ }"
+ class="note-body"
+ >
+ <div :class="internalNoteContainerClasses" data-testid="note-internal-container">
+ <suggestions
+ v-if="hasSuggestion && !isEditing"
+ :suggestions="note.suggestions"
+ :suggestions-count="suggestionsCount"
+ :batch-suggestions-info="batchSuggestionsInfo"
+ :note-html="note.note_html"
+ :line-type="lineType"
+ :help-page-path="helpPagePath"
+ :default-commit-message="commitMessage"
+ :failed-to-load-metadata="failedToLoadMetadata"
+ @apply="applySuggestion"
+ @applyBatch="applySuggestionBatch"
+ @addToBatch="addSuggestionToBatch"
+ @removeFromBatch="removeSuggestionFromBatch"
+ />
+ <div v-else v-safe-html:[$options.safeHtmlConfig]="note.note_html" class="note-text md"></div>
+ <note-form
+ v-if="isEditing"
+ ref="noteForm"
+ :note-body="noteBody"
+ :note-id="note.id"
+ :line="line"
+ :note="note"
+ :save-button-title="saveButtonTitle"
+ :help-page-path="helpPagePath"
+ :discussion="discussion"
+ :resolve-discussion="note.resolve_discussion"
+ @handleFormUpdate="handleFormUpdate"
+ @cancelForm="formCancelHandler"
+ />
+ <!-- eslint-disable vue/no-mutating-props -->
+ <textarea
+ v-if="canEdit"
+ v-model="note.note"
+ :data-update-url="note.path"
+ class="hidden js-task-list-field"
+ dir="auto"
+ ></textarea>
+ <!-- eslint-enable vue/no-mutating-props -->
+ <note-edited-text
+ v-if="note.last_edited_at"
+ :edited-at="note.last_edited_at"
+ :edited-by="note.last_edited_by"
+ action-text="Edited"
+ class="note_edited_ago"
+ />
+ </div>
<note-awards-list
v-if="note.award_emoji && note.award_emoji.length"
:note-id="note.id"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 5dd032abd72..a4cd20e6db8 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -329,7 +329,7 @@ export default {
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:noteable-data="getNoteableData"
- :note-is-confidential="discussion.confidential"
+ :is-internal-note="discussion.confidential"
>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 1ad9d593ccc..9917249f0db 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -67,7 +67,7 @@ export default {
required: false,
default: true,
},
- isConfidential: {
+ isInternalNote: {
type: Boolean,
required: false,
default: false,
@@ -110,7 +110,7 @@ export default {
authorName() {
return this.author.name;
},
- noteConfidentialityTooltip() {
+ internalNoteTooltip() {
return s__('Notes|This internal note will always remain confidential');
},
},
@@ -231,12 +231,13 @@ export default {
<time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" />
</template>
<gl-badge
- v-if="isConfidential"
+ v-if="isInternalNote"
v-gl-tooltip:tooltipcontainer.bottom
data-testid="internalNoteIndicator"
variant="warning"
size="sm"
- :title="noteConfidentialityTooltip"
+ class="gl-mb-3 gl-ml-2"
+ :title="internalNoteTooltip"
>
{{ __('Internal note') }}
</gl-badge>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 0f5a517a4c5..c5d174ed890 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -108,7 +108,7 @@ export default {
return this.discussion.notes.slice(0, 1)[0];
},
saveButtonTitle() {
- return this.discussion.confidential ? __('Reply internally') : __('Comment');
+ return this.discussion.confidential ? __('Reply internally') : __('Reply');
},
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index cda22b58c5b..af0c1e9619e 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -447,7 +447,7 @@ export default {
:author="author"
:created-at="note.created_at"
:note-id="note.id"
- :is-confidential="note.confidential"
+ :is-internal-note="note.confidential"
:noteable-type="noteableType"
>
<template #note-header-info>
@@ -493,9 +493,10 @@ export default {
<note-body
ref="noteBody"
:note="note"
+ :can-edit="note.current_user.can_edit"
+ :is-internal-note="note.confidential"
:line="line"
:file="diffFile"
- :can-edit="note.current_user.can_edit"
:is-editing="isEditing"
:help-page-path="helpPagePath"
@handleFormUpdate="formUpdateHandler"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 7d8d23335e0..754c2917182 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -3,7 +3,6 @@ import { mapGetters, mapActions } from 'vuex';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import initUserPopovers from '~/user_popovers';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -169,7 +168,6 @@ export default {
updated() {
this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
- initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
beforeDestroy() {
@@ -212,10 +210,6 @@ export default {
this.setFetchingState(true);
- if (this.glFeatures.paginatedNotes) {
- return this.initPolling();
- }
-
return this.fetchDiscussions(this.getFetchDiscussionsConfig())
.then(this.initPolling)
.then(() => {
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 65b3fd6f8b3..8cd4477a3bb 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -72,7 +72,7 @@ export default {
{{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
</gl-button>
{{ __('Last reply by') }}
- <a :href="lastReply.author.path" class="btn btn-link author-link gl-mx-2">
+ <a :href="lastReply.author.path" class="btn btn-link author-link gl-mx-2 gl-button">
{{ lastReply.author.name }}
</a>
<time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" />
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index cc14ea42a89..b8575016762 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -51,3 +51,5 @@ export const toggleStateErrorMessage = {
[REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
},
};
+
+export const INTERNAL_NOTE_CLASSES = ['gl-bg-orange-50', 'gl-px-4', 'gl-py-2'];
diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js
index 4c0ee81bec0..08792fd1a3f 100644
--- a/app/assets/javascripts/notes/i18n.js
+++ b/app/assets/javascripts/notes/i18n.js
@@ -14,8 +14,8 @@ export const COMMENT_FORM = {
epic: __('epic'),
bodyPlaceholder: __('Write a comment or drag your files hereā€¦'),
bodyPlaceholderInternal: __('Write an internal note or drag your files hereā€¦'),
- confidential: s__('Notes|Make this an internal note'),
- confidentialVisibility: s__(
+ internal: s__('Notes|Make this an internal note'),
+ internalVisibility: s__(
'Notes|Internal notes are only visible to the author, assignees, and members with the role of Reporter or higher',
),
discussionThatNeedsResolution: __(
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 0cfc17a6ae9..57bb9e295f9 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -19,7 +19,6 @@ import TaskList from '~/task_list';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
import SidebarStore from '~/sidebar/stores/sidebar_store';
import * as constants from '../constants';
-import eventHub from '../event_hub';
import * as types from './mutation_types';
import * as utils from './utils';
@@ -90,7 +89,10 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFi
? { params: { notes_filter: filter, persist_filter: persistFilter } }
: null;
- if (window.gon?.features?.paginatedIssueDiscussions) {
+ if (
+ window.gon?.features?.paginatedIssueDiscussions ||
+ window.gon?.features?.paginatedMrDiscussions
+ ) {
return dispatch('fetchDiscussionsBatch', { path, config, perPage: 20 });
}
@@ -128,6 +130,7 @@ export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, curs
});
}
+ commit(types.SET_DONE_FETCHING_BATCH_DISCUSSIONS, true);
commit(types.SET_FETCHING_DISCUSSIONS, false);
dispatch('updateResolvableDiscussionsCounts');
@@ -495,13 +498,6 @@ const pollSuccessCallBack = async (resp, commit, state, getters, dispatch) => {
return null;
}
- if (window.gon?.features?.paginatedNotes && !resp.more && state.isFetching) {
- eventHub.$emit('fetchedNotesData');
- dispatch('setFetchingState', false);
- dispatch('setNotesFetchedState', true);
- dispatch('setLoadingState', false);
- }
-
if (resp.notes?.length) {
await dispatch('updateOrCreateNotes', resp.notes);
dispatch('startTaskList');
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index f154edd3434..f779aad5679 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -14,6 +14,7 @@ export default () => ({
currentDiscussionId: null,
batchSuggestionsInfo: [],
currentlyFetchingDiscussions: false,
+ doneFetchingBatchDiscussions: false,
/**
* selectedCommentPosition & selectedCommentPositionHover structures are the same as `position.line_range`:
* {
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index ebda08a3d62..e28a7bc5cdd 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -41,6 +41,7 @@ export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION';
export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER';
export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS';
export const SET_RESOLVING_DISCUSSION = 'SET_RESOLVING_DISCUSSION';
+export const SET_DONE_FETCHING_BATCH_DISCUSSIONS = 'SET_DONE_FETCHING_BATCH_DISCUSSIONS';
// Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 5cc2c673391..39d0a46d6d0 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -32,20 +32,6 @@ export default {
}
}
- if (window.gon?.features?.paginatedNotes && note.base_discussion) {
- if (discussion.diff_file) {
- discussion.file_hash = discussion.diff_file.file_hash;
-
- discussion.truncated_diff_lines = utils.prepareDiffLines(
- discussion.truncated_diff_lines || [],
- );
- }
-
- discussion.resolvable = note.resolvable;
- discussion.expanded = note.base_discussion.expanded;
- discussion.resolved = note.resolved;
- }
-
// note.base_discussion = undefined; // No point keeping a reference to this
delete note.base_discussion;
discussion.notes = [note];
@@ -436,4 +422,7 @@ export default {
[types.SET_FETCHING_DISCUSSIONS](state, value) {
state.currentlyFetchingDiscussions = value;
},
+ [types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](state, value) {
+ state.doneFetchingBatchDiscussions = value;
+ },
};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
index 15d92ab0ef7..acf810257e6 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -25,6 +25,7 @@ import {
NOT_AVAILABLE_TEXT,
NOT_AVAILABLE_SIZE,
MORE_ACTIONS_TEXT,
+ COPY_IMAGE_PATH_TITLE,
} from '../../constants/index';
export default {
@@ -72,6 +73,7 @@ export default {
CONFIGURATION_DETAILS_ROW_TEST,
MISSING_MANIFEST_WARNING_TOOLTIP,
MORE_ACTIONS_TEXT,
+ COPY_IMAGE_PATH_TITLE,
},
computed: {
formattedSize() {
@@ -130,6 +132,7 @@ export default {
<div
v-gl-tooltip="{ title: tag.name }"
data-testid="name"
+ data-qa-selector="tag_name_content"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
:class="mobileClasses"
>
@@ -138,7 +141,7 @@ export default {
<clipboard-button
v-if="tag.location"
- :title="tag.location"
+ :title="$options.i18n.COPY_IMAGE_PATH_TITLE"
:text="tag.location"
category="tertiary"
:disabled="disabled"
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
index 3ae69731537..56da8e88b7a 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
@@ -56,6 +56,9 @@ export default {
calculatedTimeTilNextRun() {
return timeTilRun(this.expirationPolicy?.next_run);
},
+ expireIconName() {
+ return this.failedDelete ? 'expire' : 'clock';
+ },
},
statusPopoverOptions: {
triggers: 'hover',
@@ -75,7 +78,7 @@ export default {
class="gl-display-inline-flex gl-align-items-center"
>
<div class="gl-display-inline-flex gl-align-items-center">
- <gl-icon name="expire" data-testid="main-icon" />
+ <gl-icon :name="expireIconName" data-testid="main-icon" />
</div>
<span class="gl-mx-2">
{{ statusText }}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index d76a8245b63..e67d77210bb 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -14,6 +14,7 @@ import {
IMAGE_FAILED_DELETED_STATUS,
IMAGE_MIGRATING_STATE,
ROOT_IMAGE_TEXT,
+ COPY_IMAGE_PATH_TITLE,
} from '../../constants/index';
import DeleteButton from '../delete_button.vue';
import CleanupStatus from './cleanup_status.vue';
@@ -52,6 +53,7 @@ export default {
i18n: {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
+ COPY_IMAGE_PATH_TITLE,
},
computed: {
disabledDelete() {
@@ -115,7 +117,7 @@ export default {
v-if="item.location"
:disabled="deleting"
:text="item.location"
- :title="item.location"
+ :title="$options.i18n.COPY_IMAGE_PATH_TITLE"
category="tertiary"
/>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
index 4ffd8390e4d..19d35a135fd 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
@@ -107,7 +107,7 @@ export default {
<metadata-item
v-if="!hideExpirationPolicyData"
data-testid="expiration-policy"
- icon="expire"
+ icon="clock"
:text="expirationPolicyText"
size="xl"
/>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
index 2a58933cd64..98c24350f09 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -67,7 +67,7 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
-export const NOT_AVAILABLE_TEXT = __('N/A');
+export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
export const CLEANUP_UNSCHEDULED_TEXT = s__('ContainerRegistry|Cleanup will run %{time}');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
index ceaf8a65a10..c6a7591e0d9 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -41,6 +41,8 @@ export const EMPTY_RESULT_MESSAGE = s__(
'ContainerRegistry|To widen your search, change or remove the filters above.',
);
+export const COPY_IMAGE_PATH_TITLE = s__('ContainerRegistry|Copy image path');
+
// Parameters
export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
index 2519f6b74a2..b62c51bd208 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
@@ -35,5 +35,5 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'HarborRegistry|Invalid tag: missing manifest digest',
);
-export const NOT_AVAILABLE_TEXT = __('N/A');
+export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
index 7aaef2ed57a..9c69059c968 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
@@ -1,5 +1,6 @@
<script>
import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import { escape } from 'lodash';
import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue';
@@ -80,7 +81,7 @@ export default {
this.sorting = sort;
const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
- this.name = search?.value?.data;
+ this.name = escape(search?.value?.data);
this.fetchHarborImages();
},
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
index ab4cfccd023..28bfb82093c 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
@@ -100,7 +100,7 @@ export default {
<template #cell(name)="{ item, toggleDetails, detailsShowing }">
<gl-button
v-if="hasDetails(item)"
- :icon="detailsShowing ? 'angle-up' : 'angle-down'"
+ :icon="detailsShowing ? 'chevron-lg-up' : 'chevron-lg-down'"
:aria-label="detailsShowing ? __('Collapse') : __('Expand')"
category="tertiary"
size="small"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
index d3c38da1531..2046b717362 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue
@@ -31,7 +31,7 @@ export default {
<url-sync>
<template #default="{ updateQuery }">
<registry-search
- :filter="filter"
+ :filters="filter"
:sorting="sorting"
:tokens="[] /* eslint-disable-line @gitlab/vue-no-new-non-primitive-in-template */"
:sortable-fields="sortableFields"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
index a5f367bc1f6..a465fea0b74 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
@@ -1,7 +1,7 @@
<script>
import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import PackagesListRow from '~/packages_and_registries/infrastructure_registry/shared/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
@@ -42,12 +42,22 @@ export default {
isListEmpty() {
return !this.list || this.list.length === 0;
},
- modalAction() {
- return s__('PackageRegistry|Delete package');
- },
deletePackageName() {
return this.itemToBeDeleted?.name ?? '';
},
+ deleteModalActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalAction,
+ attributes: {
+ variant: 'danger',
+ },
+ };
+ },
+ deleteModalActionCancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
tracking() {
return {
category: TRACK_CATEGORY,
@@ -74,6 +84,7 @@ export default {
deleteModalContent: s__(
'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?',
),
+ modalAction: s__('PackageRegistry|Delete package'),
},
};
</script>
@@ -110,12 +121,12 @@ export default {
ref="packageListDeleteModal"
size="sm"
modal-id="confirm-delete-pacakge"
- ok-variant="danger"
+ :action-primary="deleteModalActionPrimaryProps"
+ :action-cancel="deleteModalActionCancelProps"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
- <template #modal-title>{{ modalAction }}</template>
- <template #modal-ok>{{ modalAction }}</template>
+ <template #modal-title>{{ $options.i18n.modalAction }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #name>
<strong>{{ deletePackageName }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue
index 74c0cb44c51..a3bbd569f41 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue
@@ -1,30 +1,68 @@
<script>
+import { GlAlert } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { s__ } from '~/locale';
import Composer from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue';
import Conan from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue';
import Maven from '~/packages_and_registries/package_registry/components/details/metadata/maven.vue';
import Nuget from '~/packages_and_registries/package_registry/components/details/metadata/nuget.vue';
import Pypi from '~/packages_and_registries/package_registry/components/details/metadata/pypi.vue';
import {
+ FETCH_PACKAGE_METADATA_ERROR_MESSAGE,
PACKAGE_TYPE_COMPOSER,
PACKAGE_TYPE_CONAN,
PACKAGE_TYPE_MAVEN,
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_PYPI,
} from '~/packages_and_registries/package_registry/constants';
+import getPackageMetadataQuery from '../../graphql/queries/get_package_metadata.query.graphql';
+import AdditionalMetadataLoader from './additional_metadata_loader.vue';
export default {
components: {
Composer,
Conan,
+ GlAlert,
Maven,
Nuget,
Pypi,
+ AdditionalMetadataLoader,
},
props: {
- packageEntity: {
- type: Object,
+ packageId: {
+ type: String,
required: true,
},
+ packageType: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ packageMetadata: {
+ query: getPackageMetadataQuery,
+ context: {
+ isSingleRequest: true,
+ },
+ variables() {
+ return {
+ id: this.packageId,
+ };
+ },
+ update(data) {
+ return data.package?.metadata || null;
+ },
+ error(error) {
+ this.fetchPackageMetadataError = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
+ data() {
+ return {
+ packageMetadata: null,
+ fetchPackageMetadataError: false,
+ };
},
computed: {
metadataComponent() {
@@ -34,22 +72,43 @@ export default {
[PACKAGE_TYPE_MAVEN]: Maven,
[PACKAGE_TYPE_NUGET]: Nuget,
[PACKAGE_TYPE_PYPI]: Pypi,
- }[this.packageEntity.packageType];
+ }[this.packageType];
},
showMetadata() {
- return this.metadataComponent && this.packageEntity.metadata;
+ return this.metadataComponent && this.packageMetadata;
+ },
+ isLoading() {
+ return this.$apollo.queries.packageMetadata.loading;
},
},
+ i18n: {
+ componentTitle: s__('PackageRegistry|Additional metadata'),
+ fetchPackageMetadataErrorMessage: FETCH_PACKAGE_METADATA_ERROR_MESSAGE,
+ },
};
</script>
<template>
- <div v-if="showMetadata">
- <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3>
- <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main">
+ <div>
+ <h3 v-if="isLoading || showMetadata" class="gl-font-lg" data-testid="title">
+ {{ $options.i18n.componentTitle }}
+ </h3>
+ <gl-alert
+ v-if="fetchPackageMetadataError"
+ variant="danger"
+ @dismiss="fetchPackageMetadataError = false"
+ >
+ {{ $options.i18n.fetchPackageMetadataErrorMessage }}
+ </gl-alert>
+ <additional-metadata-loader v-if="isLoading" />
+ <div
+ v-if="showMetadata"
+ class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base"
+ data-testid="main"
+ >
<component
:is="metadataComponent"
- :package-entity="packageEntity"
+ :package-metadata="packageMetadata"
data-testid="component-is"
/>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata_loader.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata_loader.vue
new file mode 100644
index 00000000000..628cf441831
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata_loader.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+ loader: {
+ width: 302,
+ height: 16,
+ repeat: 2,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base">
+ <div
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ class="gl-display-flex gl-align-items-center gl-p-4 gl-border-gray-100 gl-border-b-1"
+ >
+ <div class="gl-md-max-w-30p">
+ <gl-skeleton-loader :width="$options.loader.width" :height="$options.loader.height">
+ <rect :width="$options.loader.width" :height="$options.loader.height" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
index b6a36a0b00f..e3edaa3e45e 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
@@ -18,7 +18,7 @@ export default {
ClipboardButton,
},
props: {
- packageEntity: {
+ packageMetadata: {
type: Object,
required: true,
},
@@ -31,10 +31,10 @@ export default {
<details-row icon="information-o" padding="gl-p-4" dashed data-testid="composer-target-sha">
<gl-sprintf :message="$options.i18n.targetSha">
<template #sha>
- <strong>{{ packageEntity.metadata.targetSha }}</strong>
+ <strong>{{ packageMetadata.targetSha }}</strong>
<clipboard-button
:title="$options.i18n.targetShaCopyButton"
- :text="packageEntity.metadata.targetSha"
+ :text="packageMetadata.targetSha"
category="tertiary"
css-class="gl-p-0!"
/>
@@ -44,10 +44,10 @@ export default {
<details-row icon="information-o" padding="gl-p-4" data-testid="composer-json">
<gl-sprintf :message="$options.i18n.composerJson">
<template #license>
- <strong>{{ packageEntity.metadata.composerJson.license }}</strong>
+ <strong>{{ packageMetadata.composerJson.license }}</strong>
</template>
<template #version>
- <strong>{{ packageEntity.metadata.composerJson.version }}</strong>
+ <strong>{{ packageMetadata.composerJson.version }}</strong>
</template>
</gl-sprintf>
</details-row>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
index 10797d74acf..de7c1bc4cd3 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
@@ -13,7 +13,7 @@ export default {
GlSprintf,
},
props: {
- packageEntity: {
+ packageMetadata: {
type: Object,
required: true,
},
@@ -25,7 +25,7 @@ export default {
<div>
<details-row icon="information-o" padding="gl-p-4" data-testid="conan-recipe">
<gl-sprintf :message="$options.i18n.recipeText">
- <template #recipe>{{ packageEntity.metadata.recipe }}</template>
+ <template #recipe>{{ packageMetadata.recipe }}</template>
</gl-sprintf>
</details-row>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
index fd9fb49a9f2..7c3eb476a99 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
@@ -14,7 +14,7 @@ export default {
GlSprintf,
},
props: {
- packageEntity: {
+ packageMetadata: {
type: Object,
required: true,
},
@@ -27,14 +27,14 @@ export default {
<details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app">
<gl-sprintf :message="$options.i18n.appName">
<template #name>
- <strong>{{ packageEntity.metadata.appName }}</strong>
+ <strong>{{ packageMetadata.appName }}</strong>
</template>
</gl-sprintf>
</details-row>
<details-row icon="information-o" padding="gl-p-4" data-testid="maven-group">
<gl-sprintf :message="$options.i18n.appGroup">
<template #group>
- <strong>{{ packageEntity.metadata.appGroup }}</strong>
+ <strong>{{ packageMetadata.appGroup }}</strong>
</template>
</gl-sprintf>
</details-row>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
index 1360b03856f..1ddd419a639 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
@@ -14,7 +14,7 @@ export default {
GlSprintf,
},
props: {
- packageEntity: {
+ packageMetadata: {
type: Object,
required: true,
},
@@ -25,7 +25,7 @@ export default {
<template>
<div>
<details-row
- v-if="packageEntity.metadata.projectUrl"
+ v-if="packageMetadata.projectUrl"
icon="project"
padding="gl-p-4"
dashed
@@ -33,22 +33,22 @@ export default {
>
<gl-sprintf :message="$options.i18n.sourceText">
<template #link>
- <gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{
- packageEntity.metadata.projectUrl
+ <gl-link :href="packageMetadata.projectUrl" target="_blank">{{
+ packageMetadata.projectUrl
}}</gl-link>
</template>
</gl-sprintf>
</details-row>
<details-row
- v-if="packageEntity.metadata.licenseUrl"
+ v-if="packageMetadata.licenseUrl"
icon="license"
padding="gl-p-4"
data-testid="nuget-license"
>
<gl-sprintf :message="$options.i18n.licenseText">
<template #link>
- <gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{
- packageEntity.metadata.licenseUrl
+ <gl-link :href="packageMetadata.licenseUrl" target="_blank">{{
+ packageMetadata.licenseUrl
}}</gl-link>
</template>
</gl-sprintf>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
index 6534eef532c..ef35349c228 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
@@ -13,7 +13,7 @@ export default {
GlSprintf,
},
props: {
- packageEntity: {
+ packageMetadata: {
type: Object,
required: true,
},
@@ -26,7 +26,7 @@ export default {
<details-row icon="information-o" padding="gl-p-4" data-testid="pypi-required-python">
<gl-sprintf :message="$options.i18n.requiredPython">
<template #pythonVersion>
- <strong>{{ packageEntity.metadata.requiredPython }}</strong>
+ <strong>{{ packageMetadata.requiredPython }}</strong>
</template>
</gl-sprintf>
</details-row>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
index 3724e371e01..9e700a5236f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui';
+import { GlLink, GlTableLite, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui';
import { last } from 'lodash';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
@@ -12,7 +12,7 @@ export default {
name: 'PackageFiles',
components: {
GlLink,
- GlTable,
+ GlTableLite,
GlIcon,
GlDropdown,
GlDropdownItem,
@@ -94,7 +94,7 @@ export default {
<template>
<div>
<h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
- <gl-table
+ <gl-table-lite
:fields="filesTableHeaderFields"
:items="filesTableRows"
:tbody-tr-attr="{ 'data-testid': 'file-row' }"
@@ -102,7 +102,7 @@ export default {
<template #cell(name)="{ item, toggleDetails, detailsShowing }">
<gl-button
v-if="hasDetails(item)"
- :icon="detailsShowing ? 'angle-up' : 'angle-down'"
+ :icon="detailsShowing ? 'chevron-up' : 'chevron-down'"
:aria-label="detailsShowing ? __('Collapse') : __('Expand')"
category="tertiary"
size="small"
@@ -162,6 +162,6 @@ export default {
<file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" />
</div>
</template>
- </gl-table>
+ </gl-table-lite>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
index af6bd7079ba..96b82a20364 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
@@ -1,5 +1,6 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
@@ -7,6 +8,12 @@ import { s__, n__ } from '~/locale';
import { HISTORY_PIPELINES_LIMIT } from '~/packages_and_registries/shared/constants';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
+ FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE,
+} from '../../constants';
+import getPackagePipelinesQuery from '../../graphql/queries/get_package_pipelines.query.graphql';
+import PackageHistoryLoader from './package_history_loader.vue';
export default {
name: 'PackageHistory',
@@ -20,11 +27,14 @@ export default {
combinedUpdateText: s__(
'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}',
),
+ fetchPackagePipelinesErrorMessage: FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE,
},
components: {
+ GlAlert,
GlLink,
GlSprintf,
HistoryItem,
+ PackageHistoryLoader,
TimeAgoTooltip,
},
props: {
@@ -37,15 +47,28 @@ export default {
required: true,
},
},
+ apollo: {
+ pipelines: {
+ query: getPackagePipelinesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.package?.pipelines?.nodes || [];
+ },
+ error(error) {
+ this.fetchPackagePipelinesError = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
data() {
return {
- showDescription: false,
+ pipelines: [],
+ fetchPackagePipelinesError: false,
};
},
computed: {
- pipelines() {
- return this.packageEntity?.pipelines?.nodes || [];
- },
firstPipeline() {
return first(this.pipelines);
},
@@ -65,6 +88,15 @@ export default {
this.archivedLines,
);
},
+ isLoading() {
+ return this.$apollo.queries.pipelines.loading;
+ },
+ queryVariables() {
+ return {
+ id: this.packageEntity.id,
+ first: GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
+ };
+ },
},
methods: {
truncate(value) {
@@ -80,7 +112,15 @@ export default {
<template>
<div class="issuable-discussion">
<h3 class="gl-font-lg" data-testid="title">{{ __('History') }}</h3>
- <ul class="timeline main-notes-list notes gl-mb-4" data-testid="timeline">
+ <gl-alert
+ v-if="fetchPackagePipelinesError"
+ variant="danger"
+ @dismiss="fetchPackagePipelinesError = false"
+ >
+ {{ $options.i18n.fetchPackagePipelinesErrorMessage }}
+ </gl-alert>
+ <package-history-loader v-if="isLoading" />
+ <ul v-else class="timeline main-notes-list notes gl-mb-4" data-testid="timeline">
<history-item icon="clock" data-testid="created-on">
<gl-sprintf :message="$options.i18n.createdOn">
<template #name>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history_loader.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history_loader.vue
new file mode 100644
index 00000000000..950971c2f11
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history_loader.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+ loader: {
+ width: 580,
+ height: 80,
+ },
+};
+</script>
+
+<template>
+ <div class="gl-ml-5 gl-md-max-w-70p">
+ <gl-skeleton-loader :width="$options.loader.width" :height="$options.loader.height">
+ <rect x="49" y="9" width="531" height="16" rx="4" />
+ <circle cx="16" cy="16" r="16" />
+ <rect x="49" y="57" width="302" height="16" rx="4" />
+ <circle cx="16" cy="64" r="16" />
+ </gl-skeleton-loader>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 7a88e04d1f9..d28847c7900 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -105,7 +105,7 @@ export default {
<template #default="{ updateQuery }">
<registry-search
v-if="mountRegistrySearch"
- :filter="filters"
+ :filters="filters"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 1aff23bc112..a6ac2eb1b2b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -1,6 +1,6 @@
<script>
import { GlAlert, GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import {
@@ -73,6 +73,19 @@ export default {
}
},
},
+ deleteModalActionPrimaryProps() {
+ return {
+ text: this.$options.i18n.modalAction,
+ attributes: {
+ variant: 'danger',
+ },
+ };
+ },
+ deleteModalActionCancelProps() {
+ return {
+ text: __('Cancel'),
+ };
+ },
errorTitleAlert() {
return sprintf(
s__('PackageRegistry|There was an error publishing a %{packageName} package'),
@@ -161,12 +174,12 @@ export default {
v-model="showDeleteModal"
modal-id="confirm-delete-pacakge"
size="sm"
- ok-variant="danger"
+ :action-primary="deleteModalActionPrimaryProps"
+ :action-cancel="deleteModalActionCancelProps"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
<template #modal-title>{{ $options.i18n.modalAction }}</template>
- <template #modal-ok>{{ $options.i18n.modalAction }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #name>
<strong>{{ deletePackageName }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index c4d331fa384..3c090951b7d 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -72,6 +72,12 @@ export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
'PackageRegistry|Failed to load the package data',
);
+export const FETCH_PACKAGE_PIPELINES_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while fetching the package history.',
+);
+export const FETCH_PACKAGE_METADATA_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while fetching the package metadata.',
+);
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
export const PACKAGE_REGISTRY_TITLE = __('Package Registry');
@@ -149,3 +155,5 @@ export const CONAN_HELP_PATH = helpPagePath('user/packages/conan_repository/inde
export const NUGET_HELP_PATH = helpPagePath('user/packages/nuget_repository/index');
export const PYPI_HELP_PATH = helpPagePath('user/packages/pypi_repository/index');
export const COMPOSER_HELP_PATH = helpPagePath('user/packages/composer_repository/index');
+
+export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 41b0c285fff..5574020c9e4 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
@@ -27,18 +27,10 @@ query getPackageDetails($id: PackagesPackageID!) {
name
}
}
- pipelines(first: 10) {
+ pipelines(first: 1) {
nodes {
ref
id
- sha
- createdAt
- commitPath
- path
- user {
- id
- name
- }
project {
id
name
@@ -91,37 +83,15 @@ query getPackageDetails($id: PackagesPackageID!) {
}
}
metadata {
- ... on ComposerMetadata {
- targetSha
- composerJson {
- license
- version
- }
- }
- ... on PypiMetadata {
- id
- requiredPython
- }
- ... on ConanMetadata {
- id
- packageChannel
- packageUsername
- recipe
- recipePath
- }
... on MavenMetadata {
id
appName
appGroup
appVersion
- path
}
-
... on NugetMetadata {
id
iconUrl
- licenseUrl
- projectUrl
}
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql
new file mode 100644
index 00000000000..fc8b39b37ab
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_metadata.query.graphql
@@ -0,0 +1,39 @@
+query getPackageMetadata($id: PackagesPackageID!) {
+ package(id: $id) {
+ id
+ packageType
+ metadata {
+ ... on ComposerMetadata {
+ targetSha
+ composerJson {
+ license
+ version
+ }
+ }
+ ... on PypiMetadata {
+ id
+ requiredPython
+ }
+ ... on ConanMetadata {
+ id
+ packageChannel
+ packageUsername
+ recipe
+ recipePath
+ }
+ ... on MavenMetadata {
+ id
+ appName
+ appGroup
+ appVersion
+ path
+ }
+ ... on NugetMetadata {
+ id
+ iconUrl
+ licenseUrl
+ projectUrl
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql
new file mode 100644
index 00000000000..86e67320d63
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_pipelines.query.graphql
@@ -0,0 +1,24 @@
+query getPackagePipelines($id: PackagesPackageID!, $first: Int) {
+ package(id: $id) {
+ id
+ pipelines(first: $first) {
+ nodes {
+ ref
+ id
+ sha
+ createdAt
+ commitPath
+ path
+ user {
+ id
+ name
+ }
+ project {
+ id
+ name
+ webUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
index 162b420a784..768c8d6478b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue
@@ -27,6 +27,9 @@ import DeletePackage from '~/packages_and_registries/package_registry/components
import {
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_COMPOSER,
+ PACKAGE_TYPE_CONAN,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_PYPI,
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
@@ -122,6 +125,9 @@ export default {
packageFiles() {
return this.packageEntity.packageFiles?.nodes;
},
+ packageType() {
+ return this.packageEntity.packageType;
+ },
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
@@ -130,7 +136,7 @@ export default {
},
tracking() {
return {
- category: packageTypeToTrackCategory(this.packageEntity.packageType),
+ category: packageTypeToTrackCategory(this.packageType),
};
},
hasVersions() {
@@ -140,10 +146,19 @@ export default {
return this.packageEntity.dependencyLinks?.nodes || [];
},
showDependencies() {
- return this.packageEntity.packageType === PACKAGE_TYPE_NUGET;
+ return this.packageType === PACKAGE_TYPE_NUGET;
},
showFiles() {
- return this.packageEntity.packageType !== PACKAGE_TYPE_COMPOSER;
+ return this.packageType !== PACKAGE_TYPE_COMPOSER;
+ },
+ showMetadata() {
+ return [
+ PACKAGE_TYPE_COMPOSER,
+ PACKAGE_TYPE_CONAN,
+ PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NUGET,
+ PACKAGE_TYPE_PYPI,
+ ].includes(this.packageType);
},
},
methods: {
@@ -262,7 +277,11 @@ export default {
<installation-commands :package-entity="packageEntity" />
- <additional-metadata :package-entity="packageEntity" />
+ <additional-metadata
+ v-if="showMetadata"
+ :package-id="packageEntity.id"
+ :package-type="packageType"
+ />
</div>
<package-files
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
index a5189201112..130d6977936 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -77,9 +77,6 @@ export default {
this.updateDependencyProxyImageTtlGroupPolicy(payload);
},
},
- helpText() {
- return this.enabled ? this.$options.i18n.enabledProxyHelpText : '';
- },
},
methods: {
mutationVariables(payload) {
@@ -144,11 +141,10 @@ export default {
v-model="enabled"
:disabled="isLoading"
:label="$options.i18n.enabledProxyLabel"
- :help="helpText"
data-qa-selector="dependency_proxy_setting_toggle"
data-testid="dependency-proxy-setting-toggle"
>
- <template #help>
+ <template v-if="enabled" #help>
<span class="gl-overflow-break-word gl-max-w-100vw gl-display-inline-block">
<gl-sprintf :message="$options.i18n.enabledProxyHelpText">
<template #link="{ content }">
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
new file mode 100644
index 00000000000..fdc7bd39780
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { isEqual, get, isEmpty } from 'lodash';
+import {
+ CONTAINER_CLEANUP_POLICY_TITLE,
+ CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ UNAVAILABLE_USER_FEATURE_TEXT,
+ UNAVAILABLE_ADMIN_FEATURE_TEXT,
+} from '~/packages_and_registries/settings/project/constants';
+import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
+import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+
+import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
+
+export default {
+ components: {
+ SettingsBlock,
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ ContainerExpirationPolicyForm,
+ },
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
+ i18n: {
+ CONTAINER_CLEANUP_POLICY_TITLE,
+ CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ },
+ apollo: {
+ containerExpirationPolicy: {
+ query: expirationPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data.project?.containerExpirationPolicy,
+ result({ data }) {
+ this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ containerExpirationPolicy: null,
+ workingCopy: {},
+ };
+ },
+ computed: {
+ isDisabled() {
+ return !(this.containerExpirationPolicy || this.enableHistoricEntries);
+ },
+ showDisabledFormMessage() {
+ return this.isDisabled && !this.fetchSettingsError;
+ },
+ unavailableFeatureMessage() {
+ return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
+ },
+ isEdited() {
+ if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
+ return false;
+ }
+ return !isEqual(this.containerExpirationPolicy, this.workingCopy);
+ },
+ },
+ methods: {
+ restoreOriginal() {
+ this.workingCopy = { ...this.containerExpirationPolicy };
+ },
+ },
+};
+</script>
+
+<template>
+ <settings-block :collapsible="false">
+ <template #title> {{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</template>
+ <template #description>
+ <span>
+ <gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="helpPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #default>
+ <container-expiration-policy-form
+ v-if="!isDisabled"
+ v-model="workingCopy"
+ :is-loading="$apollo.queries.containerExpirationPolicy.loading"
+ :is-edited="isEdited"
+ @reset="restoreOriginal"
+ />
+ <template v-else>
+ <gl-alert
+ v-if="showDisabledFormMessage"
+ :dismissible="false"
+ :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
+ variant="tip"
+ >
+ {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
+
+ <gl-sprintf :message="unavailableFeatureMessage">
+ <template #link="{ content }">
+ <gl-link :href="adminSettingsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
+ </gl-alert>
+ </template>
+ </template>
+ </settings-block>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
index ae2d5f4fbc5..ae2d5f4fbc5 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/settings_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
index d6d85189792..3fbbfd75ffb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_input.vue
@@ -104,7 +104,7 @@ export default {
<span data-testid="description" class="gl-text-gray-400">
<gl-sprintf :message="description">
<template #link="{ content }">
- <gl-link :href="tagsRegexHelpPagePath" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="tagsRegexHelpPagePath">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 854c88b2ad3..95af19e6d85 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -1,128 +1,15 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { isEqual, get, isEmpty } from 'lodash';
-import {
- FETCH_SETTINGS_ERROR_MESSAGE,
- UNAVAILABLE_FEATURE_TITLE,
- UNAVAILABLE_FEATURE_INTRO_TEXT,
- UNAVAILABLE_USER_FEATURE_TEXT,
- UNAVAILABLE_ADMIN_FEATURE_TEXT,
-} from '~/packages_and_registries/settings/project/constants';
-import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
-
-import SettingsForm from './settings_form.vue';
+import ContainerExpirationPolicy from './container_expiration_policy.vue';
export default {
components: {
- SettingsBlock,
- SettingsForm,
- GlAlert,
- GlSprintf,
- GlLink,
- },
- inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
- i18n: {
- UNAVAILABLE_FEATURE_TITLE,
- UNAVAILABLE_FEATURE_INTRO_TEXT,
- FETCH_SETTINGS_ERROR_MESSAGE,
- },
- apollo: {
- containerExpirationPolicy: {
- query: expirationPolicyQuery,
- variables() {
- return {
- projectPath: this.projectPath,
- };
- },
- update: (data) => data.project?.containerExpirationPolicy,
- result({ data }) {
- this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
- },
- error(e) {
- this.fetchSettingsError = e;
- },
- },
- },
- data() {
- return {
- fetchSettingsError: false,
- containerExpirationPolicy: null,
- workingCopy: {},
- };
- },
- computed: {
- isDisabled() {
- return !(this.containerExpirationPolicy || this.enableHistoricEntries);
- },
- showDisabledFormMessage() {
- return this.isDisabled && !this.fetchSettingsError;
- },
- unavailableFeatureMessage() {
- return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
- },
- isEdited() {
- if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
- return false;
- }
- return !isEqual(this.containerExpirationPolicy, this.workingCopy);
- },
- },
- methods: {
- restoreOriginal() {
- this.workingCopy = { ...this.containerExpirationPolicy };
- },
+ ContainerExpirationPolicy,
},
};
</script>
<template>
<section data-testid="registry-settings-app">
- <settings-block :collapsible="false">
- <template #title> {{ __('Clean up image tags') }}</template>
- <template #description>
- <span data-testid="description">
- <gl-sprintf
- :message="
- __(
- 'Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link :href="helpPagePath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </span>
- </template>
- <template #default>
- <settings-form
- v-if="!isDisabled"
- v-model="workingCopy"
- :is-loading="$apollo.queries.containerExpirationPolicy.loading"
- :is-edited="isEdited"
- @reset="restoreOriginal"
- />
- <template v-else>
- <gl-alert
- v-if="showDisabledFormMessage"
- :dismissible="false"
- :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
- variant="tip"
- >
- {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
-
- <gl-sprintf :message="unavailableFeatureMessage">
- <template #link="{ content }">
- <gl-link :href="adminSettingsPath" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
- <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
- <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
- </gl-alert>
- </template>
- </template>
- </settings-block>
+ <container-expiration-policy />
</section>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 841585c5646..40f980d15fb 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -1,5 +1,9 @@
import { s__, __ } from '~/locale';
+export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`);
+export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__(
+ `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`,
+);
export const SET_CLEANUP_POLICY_BUTTON = __('Save');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
index de7ab3e6d7b..dc61f3c788c 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/cli_commands.vue
@@ -49,7 +49,7 @@ export default {
<template>
<gl-dropdown
:text="$options.i18n.QUICK_START"
- variant="info"
+ variant="confirm"
right
@shown="track('click_dropdown')"
>
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
index 9b2de1a1b84..b2b1d2c8212 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
@@ -66,7 +66,7 @@ export default {
<template #default="{ updateQuery }">
<registry-search
v-if="mountRegistrySearch"
- :filter="filters"
+ :filters="filters"
:sorting="sorting"
:tokens="$options.tokens"
:sortable-fields="sortableFields"
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue
index a1e3c06812c..e7b4229052e 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue
@@ -49,7 +49,7 @@ export default {
<gl-breadcrumb :key="isLoaded" :items="allCrumbs">
<template #separator>
<span class="gl-mx-n5">
- <gl-icon name="angle-right" :size="8" />
+ <gl-icon name="chevron-lg-right" :size="8" />
</span>
</template>
</gl-breadcrumb>
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
index 7c81cf80dc6..8cecc1d3ef7 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_downloader.js
@@ -19,7 +19,7 @@ export default class PayloadDownloader {
}
requestPayload() {
- this.spinner.classList.add('d-inline-flex');
+ this.spinner.classList.add('gl-display-inline');
return axios
.get(this.trigger.dataset.endpoint, {
@@ -34,7 +34,7 @@ export default class PayloadDownloader {
});
})
.finally(() => {
- this.spinner.classList.remove('d-inline-flex');
+ this.spinner.classList.remove('gl-display-inline');
});
}
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index ae08806fe4c..84027203783 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -29,7 +29,7 @@ export default class PayloadPreviewer {
requestPayload() {
if (this.isInserted) return this.showPayload();
- this.spinner.classList.add('gl-display-inline-flex');
+ this.spinner.classList.add('gl-display-inline');
const container = this.getContainer();
@@ -38,11 +38,11 @@ export default class PayloadPreviewer {
responseType: 'text',
})
.then(({ data }) => {
- this.spinner.classList.remove('gl-display-inline-flex');
+ this.spinner.classList.remove('gl-display-inline');
this.insertPayload(data);
})
.catch(() => {
- this.spinner.classList.remove('gl-display-inline-flex');
+ this.spinner.classList.remove('gl-display-inline');
createFlash({
message: __('Error fetching payload data.'),
});
diff --git a/app/assets/javascripts/pages/admin/application_settings/repository/index.js b/app/assets/javascripts/pages/admin/application_settings/repository/index.js
new file mode 100644
index 00000000000..9a67fe7b6f8
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/repository/index.js
@@ -0,0 +1,3 @@
+import initInactiveProjectDeletion from '~/admin/application_settings/inactive_project_deletion';
+
+initInactiveProjectDeletion();
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
index 01e03ed437d..a4df1cf8274 100644
--- a/app/assets/javascripts/pages/admin/groups/edit/index.js
+++ b/app/assets/javascripts/pages/admin/groups/edit/index.js
@@ -1,3 +1,5 @@
import initFilePickers from '~/file_pickers';
+import { initGroupNameAndPath } from '~/groups/create_edit_form';
initFilePickers();
+initGroupNameAndPath();
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 710d2d72f4c..a341ef9656d 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -1,6 +1,7 @@
import initFilePickers from '~/file_pickers';
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
+import { initGroupNameAndPath } from '~/groups/create_edit_form';
(() => {
BindInOut.initAll();
@@ -8,3 +9,5 @@ import Group from '~/group';
return new Group();
})();
+
+initGroupNameAndPath();
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
index 8fbc8dc17bc..d86ac891977 100644
--- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -1,8 +1,14 @@
-import { initExpiresAtField } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+} from '~/access_tokens';
import { initAdminUserActions, initDeleteUserModals } from '~/admin/users';
import initConfirmModal from '~/confirm_modal';
+initAccessTokenTableApp();
+initExpiresAtField();
+initNewAccessTokenApp();
initAdminUserActions();
initDeleteUserModals();
-initExpiresAtField();
initConfirmModal();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index c4bbbdcd8ec..44299d235d5 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -31,6 +31,7 @@ export default class Todos {
$('.js-done-todo, .js-undo-todo, .js-add-todo').off('click', this.updateRowStateClickedWrapper);
$('.js-todos-mark-all', '.js-todos-undo-all').off('click', this.updateallStateClickedWrapper);
$('.todo').off('click', this.goToTodoUrl);
+ $('.todo').off('auxclick', this.goToTodoUrl);
}
bindEvents() {
@@ -40,6 +41,7 @@ export default class Todos {
$('.js-done-todo, .js-undo-todo, .js-add-todo').on('click', this.updateRowStateClickedWrapper);
$('.js-todos-mark-all, .js-todos-undo-all').on('click', this.updateAllStateClickedWrapper);
$('.todo').on('click', this.goToTodoUrl);
+ $('.todo').on('auxclick', this.goToTodoUrl);
}
initFilters() {
@@ -198,11 +200,13 @@ export default class Todos {
e.stopPropagation();
e.preventDefault();
+ const isPrimaryClick = e.button === 0;
+
if (isMetaClick(e)) {
const windowTarget = '_blank';
window.open(todoLink, windowTarget);
- } else {
+ } else if (isPrimaryClick) {
visitUrl(todoLink);
}
}
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 79ac31f1659..c7c2f6f773e 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -32,25 +32,19 @@ initMembersApp(document.querySelector('.js-group-members-list-app'), {
},
},
[MEMBER_TYPES.group]: {
- tableFields: gon?.features?.groupMemberInheritedGroup
- ? SHARED_FIELDS.concat(['source', 'granted'])
- : SHARED_FIELDS.concat(['granted']),
+ tableFields: SHARED_FIELDS.concat(['source', 'granted']),
tableAttrs: {
table: { 'data-qa-selector': 'groups_list' },
tr: { 'data-qa-selector': 'group_row' },
},
requestFormatter: groupLinkRequestFormatter,
- ...(gon?.features?.groupMemberInheritedGroup
- ? {
- filteredSearchBar: {
- show: true,
- tokens: ['with_inherited_permissions'],
- searchParam: 'search_groups',
- placeholder: s__('Members|Filter groups'),
- recentSearchesStorageKey: 'group_links_members',
- },
- }
- : {}),
+ filteredSearchBar: {
+ show: true,
+ tokens: ['groups_with_inherited_permissions'],
+ searchParam: 'search_groups',
+ placeholder: s__('Members|Filter groups'),
+ recentSearchesStorageKey: 'group_links_members',
+ },
},
[MEMBER_TYPES.invite]: {
tableFields: SHARED_FIELDS.concat('invited'),
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 725c38defc3..912a4ea2c11 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,26 +1,3 @@
-import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
-import { initBulkUpdateSidebar } from '~/issuable/bulk_update_sidebar';
import { mountIssuesListApp } from '~/issues/list';
-import initManualOrdering from '~/issues/manual_ordering';
-import { FILTERED_SEARCH } from '~/filtered_search/constants';
-import initFilteredSearch from '~/pages/search/init_filtered_search';
-import projectSelect from '~/project_select';
-if (gon.features?.vueIssuesList) {
- mountIssuesListApp();
-} else {
- const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
-
- IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
- IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
- initBulkUpdateSidebar(ISSUE_BULK_UPDATE_PREFIX);
-
- initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- isGroupDecendent: true,
- useDefaultState: true,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- });
- projectSelect();
- initManualOrdering();
-}
+mountIssuesListApp();
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 702b152d25a..7c409010510 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -2,18 +2,19 @@ import Vue from 'vue';
import BindInOut from '~/behaviors/bind_in_out';
import initFilePickers from '~/file_pickers';
import Group from '~/group';
+import { initGroupNameAndPath } from '~/groups/create_edit_form';
import { parseBoolean } from '~/lib/utils/common_utils';
import NewGroupCreationApp from './components/app.vue';
import GroupPathValidator from './group_path_validator';
import initToggleInviteMembers from './toggle_invite_members';
new GroupPathValidator(); // eslint-disable-line no-new
+new Group(); // eslint-disable-line no-new
+initGroupNameAndPath();
BindInOut.initAll();
initFilePickers();
-new Group(); // eslint-disable-line no-new
-
function initNewGroupCreation(el) {
const { hasErrors, verificationRequired, verificationFormUrl, subscriptionsUrl } = el.dataset;
diff --git a/app/assets/javascripts/pages/groups/settings/access_tokens/index.js b/app/assets/javascripts/pages/groups/settings/access_tokens/index.js
index dc1bb88bf4b..b9f282a123c 100644
--- a/app/assets/javascripts/pages/groups/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/groups/settings/access_tokens/index.js
@@ -1,3 +1,9 @@
-import { initExpiresAtField } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+} from '~/access_tokens';
+initAccessTokenTableApp();
initExpiresAtField();
+initNewAccessTokenApp();
diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
index 52add416f38..bf77d968e7d 100644
--- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js
@@ -1,3 +1,4 @@
+import initStaleRunnerCleanupSetting from 'ee_else_ce/group_settings/stale_runner_cleanup';
import initVariableList from '~/ci_variable_list';
import initSharedRunnersForm from '~/group_settings/mount_shared_runners';
import initSettingsPanels from '~/settings_panels';
@@ -6,4 +7,5 @@ import initSettingsPanels from '~/settings_panels';
initSettingsPanels();
initSharedRunnersForm();
+initStaleRunnerCleanupSetting();
initVariableList();
diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
index 35a8d3d979a..6748a62e777 100644
--- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
+++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue
@@ -137,7 +137,7 @@ export default {
{{ s__('BulkImport|Group import history') }}
</h1>
</div>
- <gl-loading-icon v-if="loading" size="md" class="gl-mt-5" />
+ <gl-loading-icon v-if="loading" size="lg" class="gl-mt-5" />
<gl-empty-state
v-else-if="!hasHistoryItems"
:title="s__('BulkImport|No history is available')"
diff --git a/app/assets/javascripts/pages/import/history/components/import_error_details.vue b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
index 33ba73317f8..6af137cd722 100644
--- a/app/assets/javascripts/pages/import/history/components/import_error_details.vue
+++ b/app/assets/javascripts/pages/import/history/components/import_error_details.vue
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <gl-loading-icon v-if="loading" size="md" />
+ <gl-loading-icon v-if="loading" size="lg" />
<pre
v-else
><code>{{ error || s__('BulkImport|No additional information provided.') }}</code></pre>
diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
index 557e25f66e2..db6f0c23dbd 100644
--- a/app/assets/javascripts/pages/import/history/components/import_history_app.vue
+++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue
@@ -137,7 +137,7 @@ export default {
{{ s__('BulkImport|Project import history') }}
</h1>
</div>
- <gl-loading-icon v-if="loading" size="md" class="gl-mt-5" />
+ <gl-loading-icon v-if="loading" size="lg" class="gl-mt-5" />
<gl-empty-state
v-else-if="!hasHistoryItems"
:title="s__('BulkImport|No history is available')"
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index 37e9b7e99d4..3fae9809e51 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,5 +1,13 @@
-import { initExpiresAtField, initProjectsField, initTokensApp } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+ initProjectsField,
+ initTokensApp,
+} from '~/access_tokens';
+initAccessTokenTableApp();
initExpiresAtField();
+initNewAccessTokenApp();
initProjectsField();
initTokensApp();
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index f6f136f2402..49fdf5bb6b5 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -6,7 +6,7 @@ const twoFactorNode = document.querySelector('.js-two-factor-auth');
const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false;
if (skippable) {
- const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
+ const button = `<br/><a class="btn gl-button btn-sm btn-confirm gl-mt-3" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
const flashAlert = document.querySelector('.flash-alert');
if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button);
}
diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
index 3b5e764b712..92ae8128285 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -1,8 +1,8 @@
<script>
import { GlAlert, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
-import dateFormat from 'dateformat';
import { get } from 'lodash';
+import { formatDate } from '~/lib/utils/datetime_utility';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
@@ -38,7 +38,10 @@ export default {
},
xAxis: {
name: '',
- type: 'category',
+ type: 'time',
+ axisLabel: {
+ formatter: (value) => formatDate(value, 'mmm dd'),
+ },
},
},
};
@@ -74,7 +77,7 @@ export default {
);
},
formattedData() {
- return this.sortedData.map((value) => [dateFormat(value.date, 'mmm dd'), value.coverage]);
+ return this.sortedData.map((value) => [value.date, value.coverage]);
},
chartData() {
return [
@@ -106,7 +109,7 @@ export default {
this.selectedCoverageIndex = index;
},
formatTooltipText(params) {
- this.tooltipTitle = params.value;
+ this.tooltipTitle = formatDate(params.value, 'mmm dd');
this.coveragePercentage = get(params, 'seriesData[0].data[1]', '');
},
},
diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js
index 5a8cfcf8462..a83c4f1c0d2 100644
--- a/app/assets/javascripts/pages/projects/incidents/show/index.js
+++ b/app/assets/javascripts/pages/projects/incidents/show/index.js
@@ -1,7 +1,7 @@
import { initShow } from '~/issues';
-import initRelatedIssues from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initWorkItemLinks from '~/work_items/components/work_item_links';
initShow();
initSidebarBundle();
-initRelatedIssues();
+initWorkItemLinks();
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index 44b1d5277d1..b320d8a61c2 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -1,34 +1,6 @@
-import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable';
-import { initBulkUpdateSidebar, initIssueStatusSelect } from '~/issuable/bulk_update_sidebar';
import { mountIssuesListApp, mountJiraIssuesListApp } from '~/issues/list';
-import initManualOrdering from '~/issues/manual_ordering';
-import { FILTERED_SEARCH } from '~/filtered_search/constants';
-import { ISSUABLE_INDEX } from '~/issuable/constants';
-import initFilteredSearch from '~/pages/search/init_filtered_search';
-import UsersSelect from '~/users_select';
-
-if (gon.features?.vueIssuesList) {
- mountIssuesListApp();
-} else {
- IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
-
- initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
- useDefaultState: true,
- });
-
- initBulkUpdateSidebar(ISSUABLE_INDEX.ISSUE);
- initIssueStatusSelect();
- new UsersSelect(); // eslint-disable-line no-new
-
- initCsvImportExportButtons();
- initIssuableByEmail();
- initManualOrdering();
-}
-
-new ShortcutsNavigation(); // eslint-disable-line no-new
+mountIssuesListApp();
mountJiraIssuesListApp();
+new ShortcutsNavigation(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index 46a34c025b6..ca2b1a08be8 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -2,7 +2,9 @@ import { initShow } from '~/issues';
import { store } from '~/notes/stores';
import initRelatedIssues from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import initWorkItemLinks from '~/work_items/components/work_item_links';
initShow();
initSidebarBundle(store);
initRelatedIssues();
+initWorkItemLinks();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
index 545a39f4cf1..d3599ce5741 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js
@@ -1,5 +1,3 @@
import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle';
-import initSidebarBundle from '~/sidebar/sidebar_bundle';
-initSidebarBundle();
initMergeConflicts();
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 d61209f904d..2d26d3922bf 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
@@ -4,7 +4,8 @@ import { localTimeAgo } from '~/lib/utils/datetime_utility';
import initCompareAutocomplete from './compare_autocomplete';
import initTargetProjectDropdown from './target_project_dropdown';
-const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
+const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, params) => {
+ $emptyState.hide();
$loadingIndicator.show();
$commitList.empty();
@@ -16,6 +17,10 @@ const updateCommitList = (url, $loadingIndicator, $commitList, params) => {
$loadingIndicator.hide();
$commitList.html(data);
localTimeAgo($commitList.get(0).querySelectorAll('.js-timeago'));
+
+ if (!data) {
+ $emptyState.show();
+ }
});
};
@@ -26,6 +31,7 @@ export default (mrNewCompareNode) => {
const updateSourceBranchCommitList = () =>
updateCommitList(
sourceBranchUrl,
+ $(mrNewCompareNode).find('.js-source-commit-empty'),
$(mrNewCompareNode).find('.js-source-loading'),
$(mrNewCompareNode).find('.mr_source_commit'),
{
@@ -35,6 +41,7 @@ export default (mrNewCompareNode) => {
const updateTargetBranchCommitList = () =>
updateCommitList(
targetBranchUrl,
+ $(mrNewCompareNode).find('.js-target-commit-empty'),
$(mrNewCompareNode).find('.js-target-loading'),
$(mrNewCompareNode).find('.mr_target_commit'),
{
diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
index e5f97530c02..9a38c2cc765 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare_autocomplete.js
@@ -12,6 +12,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
$('.js-compare-dropdown').each(function () {
const $dropdown = $(this);
const selected = $dropdown.data('selected');
+ const defaultText = $dropdown.data('defaultText').trim();
const $dropdownContainer = $dropdown.closest('.dropdown');
const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
@@ -63,7 +64,11 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
return $el.attr('data-ref');
},
toggleLabel(obj, $el) {
- return $el.text().trim();
+ if ($el.hasClass('is-active')) {
+ return $el.text().trim();
+ }
+
+ return defaultText;
},
clicked: () => clickHandler($dropdown),
});
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
index dc1bb88bf4b..b9f282a123c 100644
--- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -1,3 +1,9 @@
-import { initExpiresAtField } from '~/access_tokens';
+import {
+ initAccessTokenTableApp,
+ initExpiresAtField,
+ initNewAccessTokenApp,
+} from '~/access_tokens';
+initAccessTokenTableApp();
initExpiresAtField();
+initNewAccessTokenApp();
diff --git a/app/assets/javascripts/pages/projects/settings/branch_rules/index.js b/app/assets/javascripts/pages/projects/settings/branch_rules/index.js
new file mode 100644
index 00000000000..c3d36ad5651
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/branch_rules/index.js
@@ -0,0 +1,3 @@
+import mountBranchRules from '~/projects/settings/branch_rules/mount_branch_rules';
+
+mountBranchRules(document.getElementById('js-branch-rules'));
diff --git a/app/assets/javascripts/pages/projects/services/edit/index.js b/app/assets/javascripts/pages/projects/settings/integrations/edit/index.js
index 64df0d07d74..64df0d07d74 100644
--- a/app/assets/javascripts/pages/projects/services/edit/index.js
+++ b/app/assets/javascripts/pages/projects/settings/integrations/edit/index.js
diff --git a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js b/app/assets/javascripts/pages/projects/settings/integrations/index/index.js
index 53068f72d3f..53068f72d3f 100644
--- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/integrations/index/index.js
diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
index d45052d76f4..655243eee30 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js
@@ -1,7 +1,10 @@
import MirrorRepos from '~/mirrors/mirror_repos';
+import mountBranchRules from '~/projects/settings/repository/branch_rules/mount_branch_rules';
import initForm from '../form';
initForm();
const mirrorReposContainer = document.querySelector('.js-mirror-settings');
if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init();
+
+mountBranchRules(document.getElementById('js-branch-rules'));
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 03bab0fa773..81b0dbec0bd 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -2,6 +2,7 @@
import { GlButton, GlIcon, GlSprintf, GlLink, GlFormCheckbox, GlToggle } from '@gitlab/ui';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import {
visibilityOptions,
@@ -16,7 +17,7 @@ import { toggleHiddenClassBySelector } from '../external';
import projectFeatureSetting from './project_feature_setting.vue';
import projectSettingRow from './project_setting_row.vue';
-const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
+const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')];
export default {
i18n: {
@@ -28,7 +29,14 @@ export default {
lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'),
mergeRequestsLabel: s__('ProjectSettings|Merge requests'),
operationsLabel: s__('ProjectSettings|Operations'),
+ packagesHelpText: s__(
+ 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
+ ),
+ packageRegistryHelpText: s__(
+ 'ProjectSettings|Every project can have its own space to store its packages.',
+ ),
packagesLabel: s__('ProjectSettings|Packages'),
+ packageRegistryLabel: s__('ProjectSettings|Package registry'),
pagesLabel: s__('ProjectSettings|Pages'),
ciCdLabel: __('CI/CD'),
repositoryLabel: s__('ProjectSettings|Repository'),
@@ -54,7 +62,7 @@ export default {
GlToggle,
ConfirmDanger,
},
- mixins: [settingsMixin],
+ mixins: [settingsMixin, glFeatureFlagsMixin()],
props: {
requestCveAvailable: {
@@ -183,6 +191,7 @@ export default {
repositoryAccessLevel: featureAccessLevel.EVERYONE,
forkingAccessLevel: featureAccessLevel.EVERYONE,
mergeRequestsAccessLevel: featureAccessLevel.EVERYONE,
+ packageRegistryAccessLevel: featureAccessLevel.EVERYONE,
buildsAccessLevel: featureAccessLevel.EVERYONE,
wikiAccessLevel: featureAccessLevel.EVERYONE,
snippetsAccessLevel: featureAccessLevel.EVERYONE,
@@ -196,6 +205,7 @@ export default {
warnAboutPotentiallyUnwantedCharacters: true,
lfsEnabled: true,
requestAccessEnabled: true,
+ enforceAuthChecksOnUploads: true,
highlightChangesClass: false,
emailsDisabled: false,
cveIdRequestEnabled: true,
@@ -229,6 +239,18 @@ export default {
);
},
+ packageRegistryFeatureAccessLevelOptions() {
+ const options = [FEATURE_ACCESS_LEVEL_ANONYMOUS];
+
+ if (this.visibilityLevel === visibilityOptions.PRIVATE) {
+ options.unshift(featureAccessLevelMembers);
+ } else if (this.visibilityLevel === visibilityOptions.INTERNAL) {
+ options.unshift(featureAccessLevelEveryone);
+ }
+
+ return options;
+ },
+
pagesFeatureAccessLevelOptions() {
const options = [featureAccessLevelMembers];
@@ -242,7 +264,7 @@ export default {
}
if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
- options.push([30, PAGE_FEATURE_ACCESS_LEVEL]);
+ options.push(FEATURE_ACCESS_LEVEL_ANONYMOUS);
}
}
return options;
@@ -285,6 +307,16 @@ export default {
this.visibilityLevel < this.currentSettings.visibilityLevel
);
},
+ packageRegistryAccessLevelEnabled() {
+ return this.glFeatures.packageRegistryAccessLevel;
+ },
+ showAdditonalSettings() {
+ if (this.glFeatures.enforceAuthChecksOnUploads) {
+ return true;
+ }
+
+ return this.visibilityLevel !== this.visibilityOptions.PRIVATE;
+ },
},
watch: {
@@ -307,6 +339,15 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.buildsAccessLevel,
);
+ if (this.packageRegistryAccessLevelEnabled) {
+ if (
+ this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE ||
+ (this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE &&
+ oldValue === visibilityOptions.PUBLIC)
+ ) {
+ this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
+ }
+ }
this.wikiAccessLevel = Math.min(featureAccessLevel.PROJECT_MEMBERS, this.wikiAccessLevel);
this.snippetsAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
@@ -349,6 +390,14 @@ export default {
this.repositoryAccessLevel = featureAccessLevel.EVERYONE;
if (this.mergeRequestsAccessLevel > featureAccessLevel.NOT_ENABLED)
this.mergeRequestsAccessLevel = featureAccessLevel.EVERYONE;
+ if (
+ this.packageRegistryAccessLevelEnabled &&
+ this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS
+ ) {
+ this.packageRegistryAccessLevel = Math.min(
+ ...this.packageRegistryFeatureAccessLevelOptions.map((option) => option[0]),
+ );
+ }
if (this.buildsAccessLevel > featureAccessLevel.NOT_ENABLED)
this.buildsAccessLevel = featureAccessLevel.EVERYONE;
if (this.wikiAccessLevel > featureAccessLevel.NOT_ENABLED)
@@ -369,6 +418,19 @@ export default {
this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE;
this.highlightChanges();
+ } else if (this.packageRegistryAccessLevelEnabled) {
+ if (
+ value === visibilityOptions.PUBLIC &&
+ this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE
+ ) {
+ // eslint-disable-next-line prefer-destructuring
+ this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0];
+ } else if (
+ value === visibilityOptions.INTERNAL &&
+ this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]
+ ) {
+ this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE;
+ }
}
},
@@ -465,15 +527,38 @@ export default {
)
}}</span>
<span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
- <label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="gl-line-height-28">
- <input
- :value="requestAccessEnabled"
- type="hidden"
- name="project[request_access_enabled]"
- />
- <input v-model="requestAccessEnabled" type="checkbox" />
- {{ s__('ProjectSettings|Users can request access') }}
- </label>
+ <div v-if="showAdditonalSettings" class="gl-mt-4">
+ <strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
+ <label
+ v-if="visibilityLevel !== visibilityOptions.PRIVATE"
+ class="gl-line-height-28 gl-font-weight-normal gl-mb-0"
+ >
+ <input
+ :value="requestAccessEnabled"
+ type="hidden"
+ name="project[request_access_enabled]"
+ />
+ <input v-model="requestAccessEnabled" type="checkbox" />
+ {{ s__('ProjectSettings|Users can request access') }}
+ </label>
+ <label
+ v-if="
+ visibilityLevel !== visibilityOptions.PUBLIC && glFeatures.enforceAuthChecksOnUploads
+ "
+ class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0"
+ >
+ <input
+ :value="enforceAuthChecksOnUploads"
+ type="hidden"
+ name="project[project_setting_attributes][enforce_auth_checks_on_uploads]"
+ />
+ <input v-model="enforceAuthChecksOnUploads" type="checkbox" />
+ {{ s__('ProjectSettings|Require authentication to view media files') }}
+ <span class="gl-text-gray-500 gl-display-block gl-ml-5 gl-mt-n3">{{
+ s__('ProjectSettings|Prevents direct linking to potentially sensitive media files')
+ }}</span>
+ </label>
+ </div>
</project-setting-row>
</div>
<div
@@ -587,15 +672,11 @@ export default {
</p>
</project-setting-row>
<project-setting-row
- v-if="packagesAvailable"
+ v-if="packagesAvailable && !packageRegistryAccessLevelEnabled"
ref="package-settings"
:help-path="packagesHelpPath"
:label="$options.i18n.packagesLabel"
- :help-text="
- s__(
- 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.',
- )
- "
+ :help-text="$options.i18n.packagesHelpText"
>
<gl-toggle
v-model="packagesEnabled"
@@ -710,6 +791,20 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ v-if="packageRegistryAccessLevelEnabled && packagesAvailable"
+ :help-path="packagesHelpPath"
+ :label="$options.i18n.packageRegistryLabel"
+ :help-text="$options.i18n.packageRegistryHelpText"
+ data-testid="package-registry-access-level"
+ >
+ <project-feature-setting
+ v-model="packageRegistryAccessLevel"
+ :label="$options.i18n.packageRegistryLabel"
+ :options="packageRegistryFeatureAccessLevelOptions"
+ name="project[project_feature_attributes][package_registry_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
ref="pages-settings"
:help-path="pagesHelpPath"
diff --git a/app/assets/javascripts/pages/projects/shared/save_project_loader.js b/app/assets/javascripts/pages/projects/shared/save_project_loader.js
index aa3589ac88d..7fd9e24549f 100644
--- a/app/assets/javascripts/pages/projects/shared/save_project_loader.js
+++ b/app/assets/javascripts/pages/projects/shared/save_project_loader.js
@@ -1,12 +1,14 @@
-import $ from 'jquery';
-
export default function initProjectLoadingSpinner() {
- const $formContainer = $('.project-edit-container');
- const $loadingSpinner = $('.save-project-loader');
+ const formContainer = document.querySelector('.project-edit-container');
+ if (formContainer == null) {
+ return;
+ }
+
+ const loadingSpinner = document.querySelector('.save-project-loader');
// show loading spinner when saving
- $formContainer.on('ajax:before', () => {
- $formContainer.hide();
- $loadingSpinner.show();
+ formContainer.addEventListener('ajax:before', () => {
+ formContainer.style.display = 'none';
+ loadingSpinner.style.display = 'block';
});
}
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index e2b1a702560..eff39a744ad 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -2,6 +2,7 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
+import initClustersDeprecationAlert from '~/projects/clusters_deprecation_alert';
import leaveByUrl from '~/namespaces/leave_by_url';
import initVueNotificationsDropdown from '~/notifications';
import Star from '~/projects/star';
@@ -50,6 +51,7 @@ new ShortcutsNavigation(); // eslint-disable-line no-new
initUploadFileTrigger();
initInviteMembersModal();
initInviteMembersTrigger();
+initClustersDeprecationAlert();
initReadMore();
new Star(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/static_site_editor/show/index.js b/app/assets/javascripts/pages/projects/static_site_editor/show/index.js
deleted file mode 100644
index d9d265e4e4a..00000000000
--- a/app/assets/javascripts/pages/projects/static_site_editor/show/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import initStaticSiteEditor from '~/static_site_editor';
-
-initStaticSiteEditor(document.querySelector('#static-site-editor'));
diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js
index 9e48dd9e463..e62bdc7a8c0 100644
--- a/app/assets/javascripts/pages/projects/tags/index/index.js
+++ b/app/assets/javascripts/pages/projects/tags/index/index.js
@@ -1,9 +1,5 @@
import TagSortDropdown from '~/tags';
-import { initRemoveTag } from '../remove_tag';
+import initDeleteTagModal from '~/tags/init_delete_tag_modal';
-initRemoveTag({
- onDelete: (path) => {
- document.querySelector(`[data-path="${path}"]`).closest('.js-tag-list').remove();
- },
-});
+initDeleteTagModal();
TagSortDropdown();
diff --git a/app/assets/javascripts/pages/projects/tags/remove_tag.js b/app/assets/javascripts/pages/projects/tags/remove_tag.js
deleted file mode 100644
index 7b95560df7b..00000000000
--- a/app/assets/javascripts/pages/projects/tags/remove_tag.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import initConfirmModal from '~/confirm_modal';
-import createFlash from '~/flash';
-import axios from '~/lib/utils/axios_utils';
-
-export const initRemoveTag = ({ onDelete = () => {} }) => {
- return initConfirmModal({
- handleSubmit: (path = '') =>
- axios
- .delete(path)
- .then(() => onDelete(path))
- .catch(({ response: { data } }) => {
- const { message } = data;
- createFlash({ message });
- }),
- });
-};
diff --git a/app/assets/javascripts/pages/projects/tags/show/index.js b/app/assets/javascripts/pages/projects/tags/show/index.js
index 6f5406f554f..0967540f42c 100644
--- a/app/assets/javascripts/pages/projects/tags/show/index.js
+++ b/app/assets/javascripts/pages/projects/tags/show/index.js
@@ -1,8 +1,3 @@
-import { redirectTo, getBaseURL, stripFinalUrlSegment } from '~/lib/utils/url_utility';
-import { initRemoveTag } from '../remove_tag';
+import initDeleteTagModal from '~/tags/init_delete_tag_modal';
-initRemoveTag({
- onDelete: (path = '') => {
- redirectTo(stripFinalUrlSegment([getBaseURL(), path].join('')));
- },
-});
+initDeleteTagModal();
diff --git a/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js b/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js
index 79ce1a37d21..47aae36ecbb 100644
--- a/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js
+++ b/app/assets/javascripts/pages/shared/nav/sidebar_tracking.js
@@ -1,6 +1,6 @@
function onSidebarLinkClick() {
const setDataTrackAction = (element, action) => {
- element.setAttribute('data-track-action', action);
+ element.dataset.trackAction = action;
};
const setDataTrackExtra = (element, value) => {
@@ -12,10 +12,10 @@ function onSidebarLinkClick() {
? SIDEBAR_COLLAPSED
: SIDEBAR_EXPANDED;
- element.setAttribute(
- 'data-track-extra',
- JSON.stringify({ sidebar_display: sidebarCollapsed, menu_display: value }),
- );
+ element.dataset.trackExtra = JSON.stringify({
+ sidebar_display: sidebarCollapsed,
+ menu_display: value,
+ });
};
const EXPANDED = 'Expanded';
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 996e12bc105..94506d33b33 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -298,7 +298,7 @@ export default class ActivityCalendar {
.querySelector(this.activitiesContainer)
.querySelectorAll('.js-localtime')
.forEach((el) => {
- el.setAttribute('title', formatDate(el.getAttribute('data-datetime')));
+ el.setAttribute('title', formatDate(el.dataset.datetime));
});
})
.catch(() =>
diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue
index d48a5acb85c..9ac6b0e6403 100644
--- a/app/assets/javascripts/performance_bar/components/add_request.vue
+++ b/app/assets/javascripts/performance_bar/components/add_request.vue
@@ -1,7 +1,12 @@
-import { __ } from '~/locale';
-
<script>
+import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+
export default {
+ components: {
+ GlForm,
+ GlButton,
+ GlFormInput,
+ },
data() {
return {
inputEnabled: false,
@@ -24,25 +29,26 @@ export default {
};
</script>
<template>
- <div id="peek-view-add-request" class="view">
- <form class="form-inline" @submit.prevent>
- <button
- class="btn-blank btn-link bold gl-text-blue-300"
- type="button"
- :title="__(`Add request manually`)"
+ <div id="peek-view-add-request" class="view gl-display-flex">
+ <gl-form class="gl-display-flex gl-align-items-center" @submit.prevent>
+ <gl-button
+ class="gl-text-blue-300! gl-mr-2"
+ category="tertiary"
+ variant="link"
+ icon="plus"
+ size="small"
+ :title="__('Add request manually')"
@click="toggleInput"
- >
- +
- </button>
- <input
+ />
+ <gl-form-input
v-if="inputEnabled"
v-model="urlOrRequestId"
type="text"
:placeholder="__(`URL or request ID`)"
- class="form-control form-control-sm d-inline-block ml-1"
+ class="gl-ml-2"
@keyup.enter="addRequest"
@keyup.esc="clearForm"
/>
- </form>
+ </gl-form>
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 0f744e858f2..1da4a8fea73 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -121,7 +121,7 @@ export default {
return window.URL.createObjectURL(blob);
},
downloadName() {
- const fileName = this.requests[0].truncatedUrl;
+ const fileName = this.requests[0].displayName;
return `${fileName}_perf_bar_${Date.now()}.json`;
},
memoryReportPath() {
@@ -150,7 +150,7 @@ export default {
<div id="js-peek" :class="env">
<div
v-if="currentRequest"
- class="d-flex container-fluid container-limited justify-content-center"
+ class="d-flex container-fluid container-limited justify-content-center gl-align-items-center"
data-qa-selector="performance_bar"
>
<div id="peek-view-host" class="view">
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index ffc22c2113d..f2177e102ec 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -31,7 +31,7 @@ export default {
:value="request.id"
data-qa-selector="request_dropdown_option"
>
- {{ request.truncatedUrl }}
+ {{ request.displayName }}
</option>
</select>
</div>
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index e7f84eacdca..84fe14fe056 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -56,12 +56,12 @@ const initPerformanceBar = (el) => {
this.addRequest(urlOrRequestId, urlOrRequestId);
}
},
- addRequest(requestId, requestUrl) {
+ addRequest(requestId, requestUrl, operationName) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
}
- this.store.addRequest(requestId, requestUrl);
+ this.store.addRequest(requestId, requestUrl, operationName);
},
loadRequestDetails(requestId) {
const request = this.store.findRequest(requestId);
diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js
index aad99e2604e..62ca568adc5 100644
--- a/app/assets/javascripts/performance_bar/performance_bar_log.js
+++ b/app/assets/javascripts/performance_bar/performance_bar_log.js
@@ -10,7 +10,7 @@ const initVitalsLog = () => {
console.log(
`${String.fromCodePoint(
0x1f4d1,
- )} To get the final web vital numbers reported you maybe need to switch away and back to the tab`,
+ )} To get the final web vital numbers report you may need to switch away and back to the tab`,
);
getCLS(reportVital);
getFID(reportVital);
diff --git a/app/assets/javascripts/performance_bar/services/performance_bar_service.js b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
index 4c0293f5b78..e67143f3ede 100644
--- a/app/assets/javascripts/performance_bar/services/performance_bar_service.js
+++ b/app/assets/javascripts/performance_bar/services/performance_bar_service.js
@@ -10,13 +10,15 @@ export default class PerformanceBarService {
static registerInterceptor(peekUrl, callback) {
PerformanceBarService.interceptor = (response) => {
- const [fireCallback, requestId, requestUrl] = PerformanceBarService.callbackParams(
- response,
- peekUrl,
- );
+ const [
+ fireCallback,
+ requestId,
+ requestUrl,
+ operationName,
+ ] = PerformanceBarService.callbackParams(response, peekUrl);
if (fireCallback) {
- callback(requestId, requestUrl);
+ callback(requestId, requestUrl, operationName);
}
return response;
@@ -36,7 +38,8 @@ export default class PerformanceBarService {
const cachedResponse =
response.headers && parseBoolean(response.headers['x-gitlab-from-cache']);
const fireCallback = requestUrl !== peekUrl && Boolean(requestId) && !cachedResponse;
+ const operationName = response.config?.operationName;
- return [fireCallback, requestId, requestUrl];
+ return [fireCallback, requestId, requestUrl, operationName];
}
}
diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
index 5a69960e4d9..2011604534c 100644
--- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
+++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js
@@ -3,15 +3,19 @@ export default class PerformanceBarStore {
this.requests = [];
}
- addRequest(requestId, requestUrl) {
+ addRequest(requestId, requestUrl, operationName) {
if (!this.findRequest(requestId)) {
- const shortUrl = PerformanceBarStore.truncateUrl(requestUrl);
+ let displayName = PerformanceBarStore.truncateUrl(requestUrl);
+
+ if (operationName) {
+ displayName += ` (${operationName})`;
+ }
this.requests.push({
id: requestId,
url: requestUrl,
- truncatedUrl: shortUrl,
details: {},
+ displayName,
});
}
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 100ffc0664b..f836921f5e5 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -10,10 +10,12 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
'.js-security-newsletter-callout',
- '.js-approaching-seats-count-threshold',
+ '.js-approaching-seat-count-threshold',
'.js-storage-enforcement-banner',
'.js-user-over-limit-free-plan-alert',
'.js-minute-limit-banner',
+ '.js-submit-license-usage-data-banner',
+ '.js-project-usage-limitations-callout',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
index d9da238358f..4775836fcc6 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -146,12 +146,10 @@ export default {
</gl-sprintf>
</gl-form-checkbox>
</gl-form-group>
- <div
- class="gl-display-flex gl-justify-content-space-between gl-p-5 gl-bg-gray-10 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1"
- >
+ <div class="gl-display-flex gl-py-5 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1">
<gl-button
type="submit"
- class="js-no-auto-disable"
+ class="js-no-auto-disable gl-mr-3"
category="primary"
variant="confirm"
data-qa-selector="commit_changes_button"
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
index 897bd2dcccf..1f74e89f90c 100644
--- 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
@@ -1,6 +1,8 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions } from '../../../constants';
export default {
i18n: {
@@ -25,7 +27,14 @@ export default {
GlLink,
GlSprintf,
},
+ mixins: [Tracking.mixin()],
inject: ['runnerHelpPagePath'],
+ methods: {
+ trackHelpPageClick() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.helpDrawerLinks.runners, { label });
+ },
+ },
};
</script>
<template>
@@ -38,7 +47,7 @@ export default {
<p class="gl-mb-0">
<gl-sprintf :message="$options.i18n.note">
<template #link="{ content }">
- <gl-link :href="runnerHelpPagePath" target="_blank">
+ <gl-link :href="runnerHelpPagePath" target="_blank" @click="trackHelpPageClick()">
{{ content }}
</gl-link>
</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
index 04140434af2..bc9203b9c5b 100644
--- 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
@@ -1,8 +1,20 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ CI_EXAMPLES_LINK,
+ CI_HELP_LINK,
+ CI_NEEDS_LINK,
+ CI_YAML_LINK,
+ pipelineEditorTrackingOptions,
+} from '../../../constants';
export default {
+ CI_EXAMPLES_LINK,
+ CI_HELP_LINK,
+ CI_NEEDS_LINK,
+ CI_YAML_LINK,
i18n: {
title: s__('PipelineEditorTutorial|āš™ļø Pipeline configuration reference'),
firstParagraph: s__('PipelineEditorTutorial|Resources to help with your CI/CD configuration:'),
@@ -23,7 +35,14 @@ export default {
GlLink,
GlSprintf,
},
+ mixins: [Tracking.mixin()],
inject: ['ciExamplesHelpPagePath', 'ciHelpPagePath', 'needsHelpPagePath', 'ymlHelpPagePath'],
+ methods: {
+ trackHelpPageClick(key) {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.helpDrawerLinks[key], { label });
+ },
+ },
};
</script>
<template>
@@ -34,7 +53,11 @@ export default {
<li>
<gl-sprintf :message="$options.i18n.browseExamples">
<template #link="{ content }">
- <gl-link :href="ciExamplesHelpPagePath" target="_blank">
+ <gl-link
+ :href="ciExamplesHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_EXAMPLES_LINK)"
+ >
{{ content }}
</gl-link>
</template>
@@ -43,7 +66,11 @@ export default {
<li>
<gl-sprintf :message="$options.i18n.viewSyntaxRef">
<template #link="{ content }">
- <gl-link :href="ymlHelpPagePath" target="_blank">
+ <gl-link
+ :href="ymlHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_YAML_LINK)"
+ >
{{ content }}
</gl-link>
</template>
@@ -52,7 +79,11 @@ export default {
<li>
<gl-sprintf :message="$options.i18n.learnMore">
<template #link="{ content }">
- <gl-link :href="ciHelpPagePath" target="_blank">
+ <gl-link
+ :href="ciHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_HELP_LINK)"
+ >
{{ content }}
</gl-link>
</template>
@@ -61,7 +92,11 @@ export default {
<li>
<gl-sprintf :message="$options.i18n.needs">
<template #link="{ content }">
- <gl-link :href="needsHelpPagePath" target="_blank">
+ <gl-link
+ :href="needsHelpPagePath"
+ target="_blank"
+ @click="trackHelpPageClick($options.CI_NEEDS_LINK)"
+ >
{{ content }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
index 9765d669fc1..65a2a6b56e4 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue
@@ -22,12 +22,21 @@ export default {
},
methods: {
toggleDrawer() {
- this.$emit(this.showDrawer ? 'close-drawer' : 'open-drawer');
+ if (this.showDrawer) {
+ this.$emit('close-drawer');
+ } else {
+ this.$emit('open-drawer');
+ this.trackHelpDrawerClick();
+ }
+ },
+ trackHelpDrawerClick() {
+ const { label, actions } = pipelineEditorTrackingOptions;
+ this.track(actions.openHelpDrawer, { label });
},
trackTemplateBrowsing() {
const { label, actions } = pipelineEditorTrackingOptions;
- this.track(actions.browse_templates, { label });
+ this.track(actions.browseTemplates, { label });
},
},
};
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 ead2076ec3b..4398ba67d47 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
@@ -246,7 +246,7 @@ export default {
</template>
<template #default>
<gl-dropdown-item v-if="isBranchesLoading" key="loading">
- <gl-loading-icon size="md" />
+ <gl-loading-icon size="lg" />
</gl-dropdown-item>
</template>
</gl-infinite-scroll>
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
index 58df98d0fb7..8e95fad1e48 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -1,7 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING } from '../../constants';
import FileTreePopover from '../popovers/file_tree_popover.vue';
import BranchSwitcher from './branch_switcher.vue';
@@ -12,7 +11,6 @@ export default {
FileTreePopover,
GlButton,
},
- mixins: [glFeatureFlagMixin()],
props: {
hasUnsavedChanges: {
type: Boolean,
@@ -43,11 +41,7 @@ export default {
return this.appStatus === EDITOR_APP_STATUS_LOADING;
},
showFileTreeToggle() {
- return (
- this.glFeatures.pipelineEditorFileTree &&
- !this.isNewCiConfigFile &&
- this.appStatus !== EDITOR_APP_STATUS_EMPTY
- );
+ return !this.isNewCiConfigFile && this.appStatus !== EDITOR_APP_STATUS_EMPTY;
},
},
methods: {
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
index 13e254f138a..9a789ccab4d 100644
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
@@ -17,7 +17,7 @@ export default {
text: __('Syntax is incorrect.'),
},
includesText: __(
- 'CI configuration validated, including all configuration added with the %{codeStart}includes%{codeEnd} keyword. %{link}',
+ 'CI configuration validated, including all configuration added with the %{codeStart}include%{codeEnd} keyword. %{link}',
),
warningTitle: __('The form contains the following warning:'),
fields: [
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 da31fc62d09..08d246a9a00 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -15,13 +15,15 @@ import {
MERGED_TAB,
TAB_QUERY_PARAM,
TABS_INDEX,
+ VALIDATE_TAB,
VISUALIZE_TAB,
} from '../constants';
import getAppStatus from '../graphql/queries/client/app_status.query.graphql';
import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue';
import CiEditorHeader from './editor/ci_editor_header.vue';
-import TextEditor from './editor/text_editor.vue';
import CiLint from './lint/ci_lint.vue';
+import CiValidate from './validate/ci_validate.vue';
+import TextEditor from './editor/text_editor.vue';
import EditorTab from './ui/editor_tab.vue';
import WalkthroughPopover from './popovers/walkthrough_popover.vue';
@@ -31,6 +33,7 @@ export default {
tabGraph: s__('Pipelines|Visualize'),
tabLint: s__('Pipelines|Lint'),
tabMergedYaml: s__('Pipelines|View merged YAML'),
+ tabValidate: s__('Pipelines|Validate'),
empty: {
visualization: s__(
'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.',
@@ -53,12 +56,14 @@ export default {
CREATE_TAB,
LINT_TAB,
MERGED_TAB,
+ VALIDATE_TAB,
VISUALIZE_TAB,
},
components: {
CiConfigMergedPreview,
CiEditorHeader,
CiLint,
+ CiValidate,
EditorTab,
GlAlert,
GlLoadingIcon,
@@ -121,9 +126,10 @@ export default {
},
created() {
const [tabQueryParam] = getParameterValues(TAB_QUERY_PARAM);
+ const tabName = Object.keys(TABS_INDEX)[tabQueryParam];
- if (tabQueryParam && TABS_INDEX[tabQueryParam]) {
- this.setDefaultTab(tabQueryParam);
+ if (tabName) {
+ this.setDefaultTab(tabName);
}
},
methods: {
@@ -180,6 +186,17 @@ export default {
<pipeline-graph v-else :pipeline-data="ciConfigData" />
</editor-tab>
<editor-tab
+ v-if="glFeatures.simulatePipeline"
+ class="gl-mb-3"
+ data-testid="validate-tab"
+ :title="$options.i18n.tabValidate"
+ @click="setCurrentTab($options.tabConstants.VALIDATE_TAB)"
+ >
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
+ <ci-validate v-else />
+ </editor-tab>
+ <editor-tab
+ v-else
class="gl-mb-3"
:empty-message="$options.i18n.empty.lint"
:is-empty="isEmpty"
diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue
index 6270429535d..efa6a54c638 100644
--- a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue
+++ b/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue
@@ -26,11 +26,8 @@ export default {
this.showPopover = localStorage.getItem(FILE_TREE_POPOVER_DISMISSED_KEY) !== 'true';
},
methods: {
- closePopover() {
- this.showPopover = false;
- },
dismissPermanently() {
- this.closePopover();
+ this.showPopover = false;
localStorage.setItem(FILE_TREE_POPOVER_DISMISSED_KEY, 'true');
},
},
@@ -48,7 +45,7 @@ export default {
data-qa-selector="file_tree_popover"
@close-button-clicked="dismissPermanently"
>
- <div v-outside="closePopover" class="gl-font-base gl-mb-3">
+ <div v-outside="dismissPermanently" class="gl-font-base gl-mb-3">
<gl-sprintf :message="$options.i18n.description">
<template #link="{ content }">
<gl-link :href="includesHelpPagePath" target="_blank">{{ content }}</gl-link>
diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
new file mode 100644
index 00000000000..5f26318497b
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlButton, GlDropdown, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+
+export const i18n = {
+ help: __('Help'),
+ pipelineSource: s__('PipelineEditor|Pipeline Source'),
+ pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'),
+ pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'),
+ title: s__('PipelineEditor|Validate pipeline under selected conditions'),
+ contentNote: s__(
+ 'PipelineEditor|Current content in the Edit tab will be used for the simulation.',
+ ),
+ simulationNote: s__(
+ 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.',
+ ),
+ cta: s__('PipelineEditor|Validate pipeline'),
+};
+
+export default {
+ name: 'CiValidateTab',
+ components: {
+ GlButton,
+ GlDropdown,
+ GlSprintf,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['validateTabIllustrationPath'],
+ i18n,
+};
+</script>
+
+<template>
+ <div>
+ <div class="gl-mt-3">
+ <label>{{ $options.i18n.pipelineSource }}</label>
+ <gl-dropdown
+ v-gl-tooltip.hover
+ :title="$options.i18n.pipelineSourceTooltip"
+ :text="$options.i18n.pipelineSourceDefault"
+ disabled
+ data-testid="pipeline-source"
+ />
+ </div>
+ <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
+ <img :src="validateTabIllustrationPath" />
+ <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1>
+ <ul>
+ <li class="gl-mb-3">{{ $options.i18n.contentNote }}</li>
+ <li class="gl-mb-3">
+ <gl-sprintf :message="$options.i18n.simulationNote">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ <gl-button variant="confirm" class="gl-mt-3" data-qa-selector="simulate_pipeline">
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index ff7c742f588..8f688e6ba76 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -32,13 +32,15 @@ export const PIPELINE_FAILURE = 'PIPELINE_FAILURE';
export const CREATE_TAB = 'CREATE_TAB';
export const LINT_TAB = 'LINT_TAB';
export const MERGED_TAB = 'MERGED_TAB';
+export const VALIDATE_TAB = 'VALIDATE_TAB';
export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_INDEX = {
[CREATE_TAB]: '0',
[VISUALIZE_TAB]: '1',
[LINT_TAB]: '2',
- [MERGED_TAB]: '3',
+ [VALIDATE_TAB]: '3',
+ [MERGED_TAB]: '4',
};
export const TAB_QUERY_PARAM = 'tab';
@@ -55,10 +57,25 @@ export const FILE_TREE_TIP_DISMISSED_KEY = 'pipeline_editor_file_tree_tip_dismis
export const STARTER_TEMPLATE_NAME = 'Getting-Started';
+export const CI_EXAMPLES_LINK = 'CI_EXAMPLES_LINK';
+export const CI_HELP_LINK = 'CI_HELP_LINK';
+export const CI_NEEDS_LINK = 'CI_NEEDS_LINK';
+export const CI_RUNNERS_LINK = 'CI_RUNNERS_LINK';
+export const CI_YAML_LINK = 'CI_YAML_LINK';
+
export const pipelineEditorTrackingOptions = {
label: 'pipeline_editor',
actions: {
- browse_templates: 'browse_templates',
+ browseTemplates: 'browse_templates',
+ closeHelpDrawer: 'close_help_drawer',
+ helpDrawerLinks: {
+ [CI_EXAMPLES_LINK]: 'visit_help_drawer_link_ci_examples',
+ [CI_HELP_LINK]: 'visit_help_drawer_link_ci_help',
+ [CI_NEEDS_LINK]: 'visit_help_drawer_link_needs',
+ [CI_RUNNERS_LINK]: 'visit_help_drawer_link_runners',
+ [CI_YAML_LINK]: 'visit_help_drawer_link_yaml',
+ },
+ openHelpDrawer: 'open_help_drawer',
},
};
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index e13d9cf9df0..4caa253b85e 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -41,6 +41,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectNamespace,
runnerHelpPagePath,
totalBranches,
+ validateTabIllustrationPath,
ymlHelpPagePath,
} = el.dataset;
@@ -130,6 +131,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectNamespace,
runnerHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
+ validateTabIllustrationPath,
ymlHelpPagePath,
},
render(h) {
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index 59022a91322..f26cdd8b017 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -1,7 +1,6 @@
<script>
import { GlModal } from '@gitlab/ui';
import { __ } from '~/locale';
-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';
@@ -34,7 +33,6 @@ export default {
PipelineEditorHeader,
PipelineEditorTabs,
},
- mixins: [glFeatureFlagMixin()],
props: {
ciConfigData: {
type: Object,
@@ -76,9 +74,6 @@ export default {
includesFiles() {
return this.ciConfigData?.includes || [];
},
- isFileTreeVisible() {
- return this.showFileTree && this.glFeatures.pipelineEditorFileTree;
- },
},
mounted() {
this.showFileTree = JSON.parse(localStorage.getItem(FILE_TREE_DISPLAY_KEY)) || false;
@@ -140,7 +135,7 @@ export default {
/>
<div class="gl-display-flex gl-w-full gl-sm-flex-direction-column">
<pipeline-editor-file-tree
- v-if="isFileTreeVisible"
+ v-if="showFileTree"
class="gl-flex-shrink-0"
:includes="includesFiles"
/>
diff --git a/app/assets/javascripts/pipeline_wizard/components/input.vue b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
index 5efae2471e5..5efae2471e5 100644
--- a/app/assets/javascripts/pipeline_wizard/components/input.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/input_wrapper.vue
diff --git a/app/assets/javascripts/pipeline_wizard/components/step.vue b/app/assets/javascripts/pipeline_wizard/components/step.vue
index c6f793e4cc5..220b068f747 100644
--- a/app/assets/javascripts/pipeline_wizard/components/step.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/step.vue
@@ -4,7 +4,7 @@ import { isNode, isDocument, parseDocument, Document } from 'yaml';
import { merge } from '~/lib/utils/yaml';
import { s__ } from '~/locale';
import { logError } from '~/lib/logger';
-import InputWrapper from './input.vue';
+import InputWrapper from './input_wrapper.vue';
import StepNav from './step_nav.vue';
export default {
diff --git a/app/assets/javascripts/pipeline_wizard/templates/.gitkeep b/app/assets/javascripts/pipeline_wizard/templates/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/app/assets/javascripts/pipeline_wizard/templates/.gitkeep
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 9f76d4cec50..225706265c3 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -13,7 +13,6 @@ import { __, sprintf } from '~/locale';
import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PIPELINE_GRAPHQL_TYPE } from '../../constants';
import { reportToSentry } from '../../utils';
import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants';
@@ -35,7 +34,6 @@ export default {
flatLeftBorder: ['gl-rounded-bottom-left-none!', 'gl-rounded-top-left-none!'],
flatRightBorder: ['gl-rounded-bottom-right-none!', 'gl-rounded-top-right-none!'],
},
- mixins: [glFeatureFlagMixin()],
props: {
columnTitle: {
type: String,
@@ -62,11 +60,12 @@ export default {
return {
hasActionTooltip: false,
isActionLoading: false,
+ isExpandBtnFocus: false,
};
},
computed: {
action() {
- if (this.glFeatures?.downstreamRetryAction && this.isDownstream) {
+ if (this.isDownstream) {
if (this.isCancelable) {
return {
icon: 'cancel',
@@ -89,6 +88,9 @@ export default {
? ['gl-border-r-0!', ...this.$options.styles.flatRightBorder]
: ['gl-border-l-0!', ...this.$options.styles.flatLeftBorder];
},
+ buttonShadowClass() {
+ return this.isExpandBtnFocus ? '' : 'gl-shadow-none!';
+ },
buttonId() {
return `js-linked-pipeline-${this.pipeline.id}`;
},
@@ -99,9 +101,12 @@ export default {
},
expandedIcon() {
if (this.isUpstream) {
- return this.expanded ? 'angle-right' : 'angle-left';
+ return this.expanded ? 'chevron-lg-right' : 'chevron-lg-left';
}
- return this.expanded ? 'angle-left' : 'angle-right';
+ return this.expanded ? 'chevron-lg-left' : 'chevron-lg-right';
+ },
+ expandBtnText() {
+ return this.expanded ? __('Collapse jobs') : __('Expand jobs');
},
childPipeline() {
return this.isDownstream && this.isSameProject;
@@ -157,7 +162,7 @@ export default {
return Boolean(this.action?.method && this.action?.icon && this.action?.ariaLabel);
},
showCardTooltip() {
- return !this.hasActionTooltip;
+ return !this.hasActionTooltip && !this.isExpandBtnFocus;
},
sourceJobName() {
return this.pipeline.sourceJob?.name ?? '';
@@ -214,6 +219,9 @@ export default {
setActionTooltip(flag) {
this.hasActionTooltip = flag;
},
+ setExpandBtnActiveState(flag) {
+ this.isExpandBtnFocus = flag;
+ },
},
};
</script>
@@ -221,7 +229,7 @@ export default {
<template>
<div
ref="linkedPipeline"
- class="gl-h-full gl-display-flex!"
+ class="gl-h-full gl-display-flex! gl-px-2"
:class="flexDirection"
data-qa-selector="linked_pipeline_container"
@mouseover="onDownstreamHovered"
@@ -237,7 +245,11 @@ export default {
<div
class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal"
>
- <span class="gl-text-truncate" data-testid="downstream-title">
+ <span
+ class="gl-text-truncate"
+ data-testid="downstream-title"
+ data-qa-selector="downstream_title_content"
+ >
{{ downstreamTitle }}
</span>
<div class="gl-text-truncate">
@@ -273,12 +285,18 @@ export default {
<div class="gl-display-flex">
<gl-button
:id="buttonId"
- class="gl-border! gl-shadow-none! gl-rounded-lg!"
- :class="[`js-pipeline-expand-${pipeline.id}`, buttonBorderClasses]"
+ v-gl-tooltip
+ :title="expandBtnText"
+ class="gl-border! gl-rounded-lg!"
+ :class="[`js-pipeline-expand-${pipeline.id}`, buttonBorderClasses, buttonShadowClass]"
:icon="expandedIcon"
- :aria-label="__('Expand pipeline')"
+ :aria-label="expandBtnText"
data-testid="expand-pipeline-button"
data-qa-selector="expand_linked_pipeline_button"
+ @mouseover="setExpandBtnActiveState(true)"
+ @mouseout="setExpandBtnActiveState(false)"
+ @focus="setExpandBtnActiveState(true)"
+ @blur="setExpandBtnActiveState(false)"
@click="onClickLinkedPipeline"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
index 1c646bdf3d6..070c5ee59de 100644
--- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue
@@ -56,7 +56,13 @@ export default {
</script>
<template>
- <gl-table-lite :items="failedJobs" :fields="$options.fields" stacked="lg" fixed>
+ <gl-table-lite
+ :items="failedJobs"
+ :fields="$options.fields"
+ stacked="lg"
+ fixed
+ data-testId="tab-failures"
+ >
<template #table-colgroup="{ fields }">
<col v-for="field in fields" :key="field.key" :class="field.columnClass" />
</template>
diff --git a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
index b45f3e4f32c..18e9ffa23cf 100644
--- a/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
+++ b/app/assets/javascripts/pipelines/components/jobs/jobs_app.vue
@@ -127,7 +127,7 @@ export default {
<jobs-table v-else :jobs="jobs" :table-fields="$options.fields" data-testid="jobs-tab-table" />
<gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs">
- <gl-loading-icon v-if="showLoadingSpinner" size="md" />
+ <gl-loading-icon v-if="showLoadingSpinner" size="lg" />
</gl-intersection-observer>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue b/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue
deleted file mode 100644
index b8f9f84c217..00000000000
--- a/app/assets/javascripts/pipelines/components/notification/deprecated_type_keyword_notification.vue
+++ /dev/null
@@ -1,102 +0,0 @@
-<script>
-import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
-import getPipelineWarnings from '../../graphql/queries/get_pipeline_warnings.query.graphql';
-
-export default {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- expectedMessage: 'will be removed in',
- i18n: {
- title: __('Found warning in your .gitlab-ci.yml'),
- rootTypesWarning: __(
- '%{codeStart}types%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stages%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}',
- ),
- typeWarning: __(
- '%{codeStart}type%{codeEnd} is deprecated and will be removed in 15.0. Use %{codeStart}stage%{codeEnd} instead. %{linkStart}Learn More %{linkEnd}',
- ),
- },
- components: {
- GlAlert,
- GlLink,
- GlSprintf,
- },
- inject: ['deprecatedKeywordsDocPath', 'fullPath', 'pipelineIid'],
- apollo: {
- warnings: {
- query: getPipelineWarnings,
- variables() {
- return {
- fullPath: this.fullPath,
- iid: this.pipelineIid,
- };
- },
- update(data) {
- return data?.project?.pipeline?.warningMessages || [];
- },
- error() {
- this.hasError = true;
- },
- },
- },
- data() {
- return {
- warnings: [],
- hasError: false,
- };
- },
- computed: {
- deprecationWarnings() {
- return this.warnings.filter(({ content }) => {
- return content.includes(this.$options.expectedMessage);
- });
- },
- formattedWarnings() {
- // The API doesn't have a mechanism currently to return a
- // type instead of just the error message. To work around this,
- // we check if the deprecation message is found within the warnings
- // and show a FE version of that message with the link to the documentation
- // and translated. We can have only 2 types of warnings: root types and individual
- // type. If the word `root` is present, then we know it's the root type deprecation
- // and if not, it's the normal type. This has to be deleted in 15.0.
- // Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/350810
- return this.deprecationWarnings.map(({ content }) => {
- if (content.includes('root')) {
- return this.$options.i18n.rootTypesWarning;
- }
- return this.$options.i18n.typeWarning;
- });
- },
- hasDeprecationWarning() {
- return this.formattedWarnings.length > 0;
- },
- showWarning() {
- return (
- !this.$apollo.queries.warnings?.loading && !this.hasError && this.hasDeprecationWarning
- );
- },
- },
-};
-</script>
-<template>
- <div>
- <gl-alert
- v-if="showWarning"
- :title="$options.i18n.title"
- variant="warning"
- :dismissible="false"
- >
- <ul class="gl-mb-0">
- <li v-for="warning in formattedWarnings" :key="warning">
- <gl-sprintf :message="warning">
- <template #code="{ content }">
- <code> {{ content }}</code>
- </template>
- <template #link="{ content }">
- <gl-link :href="deprecatedKeywordsDocPath" target="_blank"> {{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </li>
- </ul>
- </gl-alert>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index 66d30c10362..e1745969649 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -1,9 +1,10 @@
<script>
-import { GlTabs, GlTab } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab } from '@gitlab/ui';
import { __ } from '~/locale';
import { failedJobsTabName, jobsTabName, needsTabName, testReportTabName } from '../constants';
import PipelineGraphWrapper from './graph/graph_component_wrapper.vue';
import Dag from './dag/dag.vue';
+import FailedJobsApp from './jobs/failed_jobs_app.vue';
import JobsApp from './jobs/jobs_app.vue';
import TestReports from './test_reports/test_reports.vue';
@@ -25,14 +26,20 @@ export default {
},
components: {
Dag,
+ GlBadge,
GlTab,
GlTabs,
JobsApp,
- FailedJobsApp: JobsApp,
+ FailedJobsApp,
PipelineGraphWrapper,
TestReports,
},
- inject: ['defaultTabValue'],
+ inject: ['defaultTabValue', 'failedJobsCount', 'failedJobsSummary', 'totalJobCount'],
+ computed: {
+ showFailedJobsTab() {
+ return this.failedJobsCount > 0;
+ },
+ },
methods: {
isActive(tabName) {
return tabName === this.defaultTabValue;
@@ -54,19 +61,25 @@ export default {
>
<dag />
</gl-tab>
- <gl-tab
- :title="$options.i18n.tabs.jobsTitle"
- :active="isActive($options.tabNames.jobs)"
- data-testid="jobs-tab"
- >
+ <gl-tab :active="isActive($options.tabNames.jobs)" data-testid="jobs-tab" lazy>
+ <template #title>
+ <span class="gl-mr-2">{{ $options.i18n.tabs.jobsTitle }}</span>
+ <gl-badge size="sm" data-testid="builds-counter">{{ totalJobCount }}</gl-badge>
+ </template>
<jobs-app />
</gl-tab>
<gl-tab
+ v-if="showFailedJobsTab"
:title="$options.i18n.tabs.failedJobsTitle"
:active="isActive($options.tabNames.failures)"
data-testid="failed-jobs-tab"
+ lazy
>
- <failed-jobs-app />
+ <template #title>
+ <span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span>
+ <gl-badge size="sm" data-testid="failed-builds-counter">{{ failedJobsCount }}</gl-badge>
+ </template>
+ <failed-jobs-app :failed-jobs-summary="failedJobsSummary" />
</gl-tab>
<gl-tab
:title="$options.i18n.tabs.testsTitle"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
index e35fccf2d7e..05cb2ebb769 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
@@ -36,12 +36,12 @@ export default {
};
</script>
<template>
- <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle gl-my-1">
+ <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle">
<div
v-for="stage in stages"
:key="stage.name"
:class="stagesClass"
- class="stage-container dropdown"
+ class="dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle stage-container"
>
<pipeline-stage
:stage="stage"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
index 53e21d4ce8b..d7e55d36ff6 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -21,6 +21,13 @@ import eventHub from '../../event_hub';
import JobItem from './job_item.vue';
export default {
+ i18n: {
+ stage: __('Stage:'),
+ loadingText: __('Loading, please wait.'),
+ },
+ dropdownPopperOpts: {
+ placement: 'bottom',
+ },
components: {
CiIcon,
GlLoadingIcon,
@@ -48,20 +55,26 @@ export default {
},
data() {
return {
+ isDropdownOpen: false,
isLoading: false,
dropdownContent: [],
+ stageName: '',
};
},
watch: {
updateDropdown() {
- if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) {
+ if (this.updateDropdown && this.isDropdownOpen && !this.isLoading) {
this.fetchJobs();
}
},
},
methods: {
+ onHideDropdown() {
+ this.isDropdownOpen = false;
+ },
onShowDropdown() {
eventHub.$emit('clickedDropdown');
+ this.isDropdownOpen = true;
this.isLoading = true;
this.fetchJobs();
},
@@ -70,6 +83,7 @@ export default {
.get(this.stage.dropdown_path)
.then(({ data }) => {
this.dropdownContent = data.latest_statuses;
+ this.stageName = data.name;
this.isLoading = false;
})
.catch(() => {
@@ -81,9 +95,6 @@ export default {
});
});
},
- isDropdownOpen() {
- return this.$el.classList.contains('show');
- },
pipelineActionRequestComplete() {
// close the dropdown in MR widget
this.$refs.dropdown.hide();
@@ -107,28 +118,42 @@ export default {
variant="link"
:aria-label="stageAriaLabel(stage.title)"
:lazy="true"
- :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- placement: 'bottom',
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :popper-opts="$options.dropdownPopperOpts"
:toggle-class="['gl-rounded-full!']"
menu-class="mini-pipeline-graph-dropdown-menu"
+ @hide="onHideDropdown"
@show="onShowDropdown"
>
<template #button-content>
<ci-icon
+ is-borderless
is-interactive
css-classes="gl-rounded-full"
+ :is-active="isDropdownOpen"
:size="24"
:status="stage.status"
- class="gl-align-items-center gl-display-inline-flex"
+ class="gl-align-items-center gl-border gl-display-inline-flex gl-z-index-1"
/>
</template>
- <gl-loading-icon v-if="isLoading" size="sm" />
+ <div
+ v-if="isLoading"
+ class="gl-display-flex gl-justify-content-center gl-p-2"
+ data-testid="pipeline-stage-loading-state"
+ >
+ <gl-loading-icon size="sm" class="gl-mr-3" />
+ <p class="gl-mb-0">{{ $options.i18n.loadingText }}</p>
+ </div>
<ul
v-else
class="js-builds-dropdown-list scrollable-menu"
data-testid="mini-pipeline-graph-dropdown-menu-list"
>
+ <div
+ class="gl-align-items-center gl-border-b gl-display-flex gl-font-weight-bold gl-justify-content-center gl-pb-3"
+ >
+ <span class="gl-mr-1">{{ $options.i18n.stage }}</span>
+ <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span>
+ </div>
<li v-for="job in dropdownContent" :key="job.id">
<job-item
:dropdown-length="dropdownContent.length"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 63c492c8bcd..09d588aaafd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -137,9 +137,8 @@ export default {
class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3"
data-testid="pipeline-url-link"
data-qa-selector="pipeline_url_link"
+ >#{{ pipeline[pipelineKey] }}</gl-link
>
- #{{ pipeline[pipelineKey] }}
- </gl-link>
<!--Commit row-->
<div class="icon-container gl-display-inline-block gl-mr-1">
<gl-icon
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 47e5bb0bde8..76ee6ab613b 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
@@ -19,7 +19,10 @@ export default {
},
testCase: {
type: Object,
- required: true,
+ required: false,
+ default: () => {
+ return {};
+ },
},
},
computed: {
@@ -49,6 +52,7 @@ export default {
},
text: {
name: __('Name'),
+ file: __('File'),
duration: __('Execution time'),
history: __('History'),
trace: __('System output'),
@@ -56,7 +60,7 @@ export default {
},
modalCloseButton: {
text: __('Close'),
- attributes: [{ variant: 'info' }],
+ attributes: [{ variant: 'confirm' }],
},
};
</script>
@@ -74,11 +78,24 @@ export default {
</div>
</div>
+ <div v-if="testCase.file" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3">
+ <strong class="gl-text-right col-sm-3">{{ $options.text.file }}</strong>
+ <div class="col-sm-9" data-testid="test-case-file">
+ <gl-link v-if="testCase.filePath" :href="testCase.filePath">
+ {{ testCase.file }}
+ </gl-link>
+ <span v-else>{{ testCase.file }}</span>
+ </div>
+ </div>
+
<div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3">
<strong class="gl-text-right col-sm-3">{{ $options.text.duration }}</strong>
- <div class="col-sm-9" data-testid="test-case-duration">
+ <div v-if="testCase.formattedTime" class="col-sm-9" data-testid="test-case-duration">
{{ testCase.formattedTime }}
</div>
+ <div v-else-if="testCase.execution_time" class="col-sm-9" data-testid="test-case-duration">
+ {{ sprintf('%{value} s', { value: testCase.execution_time }) }}
+ </div>
</div>
<div v-if="testCase.recent_failures" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3">
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 9b0e6560c53..1e481d37017 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -7,11 +7,21 @@ import {
GlLink,
GlButton,
GlPagination,
+ GlEmptyState,
+ GlSprintf,
} from '@gitlab/ui';
import { mapState, mapGetters, mapActions } from 'vuex';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import TestCaseDetails from './test_case_details.vue';
+export const i18n = {
+ expiredArtifactsTitle: s__('TestReports|Job artifacts are expired'),
+ expiredArtifactsDescription: s__(
+ 'TestReports|Test reports require job artifacts but all artifacts are expired. %{linkStart}Learn more%{linkEnd}',
+ ),
+};
+
export default {
name: 'TestsSuiteTable',
components: {
@@ -20,12 +30,19 @@ export default {
GlLink,
GlButton,
GlPagination,
+ GlEmptyState,
+ GlSprintf,
TestCaseDetails,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModalDirective,
},
+ inject: {
+ artifactsExpiredImagePath: {
+ default: '',
+ },
+ },
props: {
heading: {
type: String,
@@ -44,18 +61,21 @@ export default {
...mapActions(['setPage']),
},
wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'],
+ i18n,
+ learnMorePath: helpPagePath('ci/unit_test_reports', {
+ anchor: 'viewing-unit-test-reports-on-gitlab',
+ }),
};
</script>
<template>
<div>
- <div class="row gl-mt-3">
- <div class="col-12">
- <h4>{{ heading }}</h4>
- </div>
- </div>
-
<div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-cases-table">
+ <div class="row gl-mt-3">
+ <div class="col-12">
+ <h4>{{ heading }}</h4>
+ </div>
+ </div>
<div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray">
<div role="rowheader" class="table-section section-20">
{{ __('Suite') }}
@@ -158,16 +178,24 @@ export default {
</div>
<div v-else>
- <p data-testid="no-test-cases">
+ <gl-empty-state
+ v-if="getSuiteArtifactsExpired"
+ :title="$options.i18n.expiredArtifactsTitle"
+ :svg-path="artifactsExpiredImagePath"
+ :svg-height="100"
+ data-testid="artifacts-expired"
+ >
+ <template #description>
+ <gl-sprintf :message="$options.i18n.expiredArtifactsDescription">
+ <template #link="{ content }">
+ <gl-link :href="$options.learnMorePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+ <p v-else data-testid="no-test-cases">
{{ s__('TestReports|There are no test cases to display.') }}
</p>
- <p v-if="getSuiteArtifactsExpired" data-testid="artifacts-expired">
- {{
- s__(
- 'TestReports|Test details are populated by job artifacts. The job artifacts from this pipeline are expired.',
- )
- }}
- </p>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 79b1b6af38b..2f5301715c3 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -71,7 +71,7 @@ export default {
v-if="showBack"
size="small"
class="gl-mr-3 js-back-button"
- icon="angle-left"
+ icon="chevron-lg-left"
:aria-label="__('Go back')"
@click="onBackClick"
/>
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql
deleted file mode 100644
index cd1d2b62a3d..00000000000
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_warnings.query.graphql
+++ /dev/null
@@ -1,12 +0,0 @@
-query getPipelineWarnings($fullPath: ID!, $iid: ID!) {
- project(fullPath: $fullPath) {
- id
- pipeline(iid: $iid) {
- id
- warningMessages {
- content
- id
- }
- }
- }
-}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index fd869014570..8bdf18da348 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,7 +3,6 @@ import { __, s__ } from '~/locale';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
-import { createPipelineNotificationApp } from './pipeline_details_notification';
import { createPipelineJobsApp } from './pipeline_details_jobs';
import { createPipelineFailedJobsApp } from './pipeline_details_failed_jobs';
import { apolloProvider } from './pipeline_shared_client';
@@ -13,7 +12,6 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
- PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TABS: '#js-pipeline-tabs',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
PIPELINE_JOBS: '#js-pipeline-jobs-vue',
@@ -31,19 +29,13 @@ export default async function initPipelineDetailsBundle() {
});
}
- try {
- createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
- } catch {
- createFlash({
- message: __('An error occurred while loading a section of this page.'),
- });
- }
-
if (gon.features?.pipelineTabsVue) {
+ const { createAppOptions } = await import('ee_else_ce/pipelines/pipeline_tabs');
const { createPipelineTabs } = await import('./pipeline_tabs');
try {
- createPipelineTabs(SELECTORS.PIPELINE_TABS, apolloProvider);
+ const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider);
+ createPipelineTabs(appOptions);
} catch {
createFlash({
message: __('An error occurred while loading a section of this page.'),
@@ -82,14 +74,12 @@ export default async function initPipelineDetailsBundle() {
});
}
- if (gon.features?.failedJobsTabVue) {
- try {
- createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS);
- } catch {
- createFlash({
- message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'),
- });
- }
+ try {
+ createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS);
+ } catch {
+ createFlash({
+ message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'),
+ });
}
}
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
deleted file mode 100644
index b480fc7c713..00000000000
--- a/app/assets/javascripts/pipelines/pipeline_details_notification.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import DeprecatedKeywordNotification from './components/notification/deprecated_type_keyword_notification.vue';
-
-Vue.use(VueApollo);
-
-export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
- const el = document.querySelector(elSelector);
-
- if (!el) {
- return;
- }
-
- const { deprecatedKeywordsDocPath, fullPath, pipelineIid } = el.dataset;
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- DeprecatedKeywordNotification,
- },
- provide: {
- deprecatedKeywordsDocPath,
- fullPath,
- pipelineIid,
- },
- apolloProvider,
- render(createElement) {
- return createElement('deprecated-keyword-notification');
- },
- });
-};
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 530917f0402..e7c00d89a10 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -8,34 +8,31 @@ import { getPipelineDefaultTab, reportToSentry } from './utils';
Vue.use(VueApollo);
-const createPipelineTabs = (selector, apolloProvider) => {
+export const createAppOptions = (selector, apolloProvider) => {
const el = document.querySelector(selector);
- if (!el) return;
+ if (!el) return null;
- const { dataset } = document.querySelector(selector);
+ const { dataset } = el;
const {
canGenerateCodequalityReports,
codequalityReportDownloadPath,
downloadablePathForReportType,
exposeSecurityDashboard,
exposeLicenseScanningData,
+ failedJobsCount,
+ failedJobsSummary,
+ fullPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
+ totalJobCount,
} = dataset;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
- updateHistory({
- url: removeParams([TAB_QUERY_PARAM]),
- title: document.title,
- replace: true,
- });
-
- // eslint-disable-next-line no-new
- new Vue({
- el: selector,
+ return {
+ el,
components: {
PipelineTabs,
},
@@ -47,9 +44,13 @@ const createPipelineTabs = (selector, apolloProvider) => {
downloadablePathForReportType,
exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard),
exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData),
+ failedJobsCount,
+ failedJobsSummary: JSON.parse(failedJobsSummary),
+ fullPath,
graphqlResourceEtag,
pipelineIid,
pipelineProjectPath,
+ totalJobCount,
},
errorCaptured(err, _vm, info) {
reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`);
@@ -57,7 +58,18 @@ const createPipelineTabs = (selector, apolloProvider) => {
render(createElement) {
return createElement(PipelineTabs);
},
- });
+ };
};
-export { createPipelineTabs };
+export const createPipelineTabs = (options) => {
+ if (!options) return;
+
+ updateHistory({
+ url: removeParams([TAB_QUERY_PARAM]),
+ title: document.title,
+ replace: true,
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue(options);
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js
index 46c7ec07d03..27ab2418440 100644
--- a/app/assets/javascripts/pipelines/pipeline_test_details.js
+++ b/app/assets/javascripts/pipelines/pipeline_test_details.js
@@ -8,8 +8,14 @@ Vue.use(Translate);
export const createTestDetails = (selector) => {
const el = document.querySelector(selector);
- const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } =
- el?.dataset || {};
+ const {
+ blobPath,
+ emptyStateImagePath,
+ hasTestReport,
+ summaryEndpoint,
+ suiteEndpoint,
+ artifactsExpiredImagePath,
+ } = el?.dataset || {};
const testReportsStore = createTestReportsStore({
blobPath,
summaryEndpoint,
@@ -24,6 +30,7 @@ export const createTestDetails = (selector) => {
},
provide: {
emptyStateImagePath,
+ artifactsExpiredImagePath,
hasTestReport: parseBoolean(hasTestReport),
},
store: testReportsStore,
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index 45a6130826d..c99133fd251 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -60,7 +60,7 @@ Please update your Git repository remotes as soon as possible.`),
return {
text: __('Update username'),
attributes: [
- { variant: 'warning' },
+ { variant: 'confirm' },
{ category: 'primary' },
{ disabled: this.isRequestPending },
],
@@ -127,8 +127,7 @@ Please update your Git repository remotes as soon as possible.`),
v-gl-modal-directive="$options.modalId"
:disabled="newUsername === username"
:loading="isRequestPending"
- category="primary"
- variant="warning"
+ variant="confirm"
data-testid="username-change-confirmation-modal"
>{{ $options.buttonText }}</gl-button
>
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 7222c2bd908..09acf98001c 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,120 +1,122 @@
/* eslint-disable func-names */
import $ from 'jquery';
+import createFlash from '~/flash';
import Api from './api';
import { loadCSSFile } from './lib/utils/css_utils';
import { s__ } from './locale';
import ProjectSelectComboButton from './project_select_combo_button';
-const projectSelect = () => {
- loadCSSFile(gon.select2_css_path)
- .then(() => {
- $('.ajax-project-select').each(function (i, select) {
- let placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- const isInstantiated = $(select).data('select2');
- this.groupId = $(select).data('groupId');
- this.userId = $(select).data('userId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
+const projectSelect = async () => {
+ await loadCSSFile(gon.select2_css_path);
- placeholder = s__('ProjectSelect|Search for project');
- if (this.includeGroups) {
- placeholder += s__('ProjectSelect| or group');
- }
+ $('.ajax-project-select').each(function (i, select) {
+ let placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ const isInstantiated = $(select).data('select2');
+ this.groupId = $(select).data('groupId');
+ this.userId = $(select).data('userId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+ this.withShared =
+ $(select).data('withShared') === undefined ? true : $(select).data('withShared');
+ this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
+ this.allowClear = $(select).data('allowClear') || false;
+
+ placeholder = s__('ProjectSelect|Search for project');
+ if (this.includeGroups) {
+ placeholder += s__('ProjectSelect| or group');
+ }
- $(select).select2({
- placeholder,
- minimumInputLength: 0,
- query: (query) => {
- let projectsCallback;
- const finalCallback = function (projects) {
- const data = {
- results: projects,
- };
- return query.callback(data);
+ $(select).select2({
+ placeholder,
+ minimumInputLength: 0,
+ query: (query) => {
+ let projectsCallback;
+ const finalCallback = function (projects) {
+ const data = {
+ results: projects,
+ };
+ return query.callback(data);
+ };
+ if (this.includeGroups) {
+ projectsCallback = function (projects) {
+ const groupsCallback = function (groups) {
+ const data = groups.concat(projects);
+ return finalCallback(data);
};
- if (this.includeGroups) {
- projectsCallback = function (projects) {
- const groupsCallback = function (groups) {
- const data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (this.groupId) {
- return Api.groupProjects(
- this.groupId,
- query.term,
- {
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- with_shared: this.withShared,
- include_subgroups: this.includeProjectsInSubgroups,
- order_by: 'similarity',
- simple: true,
- },
- projectsCallback,
- );
- } else if (this.userId) {
- return Api.userProjects(
- this.userId,
- query.term,
- {
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- with_shared: this.withShared,
- include_subgroups: this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- }
- return Api.projects(
- query.term,
- {
- order_by: this.orderBy,
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- membership: !this.allProjects,
- },
- projectsCallback,
- );
- },
- id(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
+ return Api.groups(query.term, {}, groupsCallback);
+ };
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (this.groupId) {
+ return Api.groupProjects(
+ this.groupId,
+ query.term,
+ {
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ with_shared: this.withShared,
+ include_subgroups: this.includeProjectsInSubgroups,
+ order_by: 'similarity',
+ simple: true,
+ },
+ projectsCallback,
+ ).catch(() => {
+ createFlash({
+ message: s__('ProjectSelect|Something went wrong while fetching projects'),
});
+ });
+ } else if (this.userId) {
+ return Api.userProjects(
+ this.userId,
+ query.term,
+ {
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ with_shared: this.withShared,
+ include_subgroups: this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ }
+ return Api.projects(
+ query.term,
+ {
+ order_by: this.orderBy,
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ membership: !this.allProjects,
},
- text(project) {
- return project.name_with_namespace || project.name;
- },
+ projectsCallback,
+ );
+ },
+ id(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
+ },
+ text(project) {
+ return project.name_with_namespace || project.name;
+ },
- initSelection(el, callback) {
- // eslint-disable-next-line promise/no-nesting
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
+ initSelection(el, callback) {
+ return Api.project(el.val()).then(({ data }) => callback(data));
+ },
- allowClear: this.allowClear,
+ allowClear: this.allowClear,
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (isInstantiated || simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
- })
- .catch(() => {});
+ dropdownCssClass: 'ajax-project-dropdown',
+ });
+ if (isInstantiated || simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
};
export default () => {
diff --git a/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue b/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue
new file mode 100644
index 00000000000..e026b3e1060
--- /dev/null
+++ b/app/assets/javascripts/projects/clusters_deprecation_alert/components/clusters_deprecation_alert.vue
@@ -0,0 +1,23 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export default {
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ inject: ['message'],
+ docsLink: helpPagePath('user/infrastructure/clusters/migrate_to_gitlab_agent.md'),
+};
+</script>
+<template>
+ <gl-alert :dismissible="false" variant="warning" class="gl-mt-5">
+ <gl-sprintf :message="message">
+ <template #link="{ content }">
+ <gl-link :href="$options.docsLink">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/projects/clusters_deprecation_alert/index.js b/app/assets/javascripts/projects/clusters_deprecation_alert/index.js
new file mode 100644
index 00000000000..e17c1900dc1
--- /dev/null
+++ b/app/assets/javascripts/projects/clusters_deprecation_alert/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import ClustersDeprecationAlert from './components/clusters_deprecation_alert.vue';
+
+export default () => {
+ const el = document.querySelector('.js-clusters-deprecation-alert');
+
+ if (!el) {
+ return false;
+ }
+
+ const { message } = el.dataset;
+
+ return new Vue({
+ el,
+ name: 'ClustersDeprecationAlertRoot',
+ provide: {
+ message,
+ },
+ render: (createElement) => createElement(ClustersDeprecationAlert),
+ });
+};
diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue
index f9dd72119d1..d9aaa574fec 100644
--- a/app/assets/javascripts/projects/commit/components/form_modal.vue
+++ b/app/assets/javascripts/projects/commit/components/form_modal.vue
@@ -53,7 +53,7 @@ export default {
actionPrimary: {
text: this.i18n.actionPrimaryText,
attributes: [
- { variant: 'success' },
+ { variant: 'confirm' },
{ category: 'primary' },
{ 'data-testid': 'submit-commit' },
{ 'data-qa-selector': 'submit_commit_button' },
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index c8a0a3417f3..884ef732144 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -57,7 +57,7 @@ export default {
if (authorParam) {
commitsSearchInput.setAttribute('disabled', true);
- commitsSearchInput.setAttribute('data-toggle', 'tooltip');
+ commitsSearchInput.dataset.toggle = 'tooltip';
commitsSearchInput.setAttribute('title', tooltipMessage);
this.currentAuthor = authorParam;
}
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index f2c1c843878..3945bed9649 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -104,7 +104,7 @@ export default {
@selectRevision="onSelectRevision"
/>
<div
- class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0"
+ class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-align-self-end gl-my-4 gl-md-my-0"
data-testid="ellipsis"
>
...
@@ -121,7 +121,7 @@ export default {
@selectRevision="onSelectRevision"
/>
</div>
- <div class="gl-mt-4">
+ <div class="gl-mt-6">
<gl-button category="primary" variant="confirm" @click="onSubmit">
{{ s__('CompareRevisions|Compare') }}
</gl-button>
diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue
index 02a329221cc..d6ada24604d 100644
--- a/app/assets/javascripts/projects/compare/components/revision_card.vue
+++ b/app/assets/javascripts/projects/compare/components/revision_card.vue
@@ -1,5 +1,4 @@
<script>
-import { GlCard } from '@gitlab/ui';
import RepoDropdown from './repo_dropdown.vue';
import RevisionDropdown from './revision_dropdown.vue';
@@ -7,7 +6,6 @@ export default {
components: {
RepoDropdown,
RevisionDropdown,
- GlCard,
},
props: {
refsProjectPath: {
@@ -41,10 +39,10 @@ export default {
</script>
<template>
- <gl-card header-class="gl-py-2 gl-px-3 gl-font-weight-bold" body-class="gl-px-3">
- <template #header>
+ <div class="revision-card gl-flex-basis-half">
+ <h2 class="gl-font-size-h2">
{{ s__(`CompareRevisions|${revisionText}`) }}
- </template>
+ </h2>
<div class="gl-sm-display-flex gl-align-items-center">
<repo-dropdown
class="gl-sm-w-half"
@@ -61,5 +59,5 @@ export default {
v-on="$listeners"
/>
</div>
- </gl-card>
+ </div>
</template>
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index 6708b7bd9e2..3671b24b502 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -41,6 +41,10 @@ export default {
text: s__('ProjectTemplates|Pages/Hugo'),
icon: '.template-option .icon-hugo',
},
+ pelican: {
+ text: s__('ProjectTemplates|Pages/Pelican'),
+ icon: '.template-option .icon-pelican',
+ },
jekyll: {
text: s__('ProjectTemplates|Pages/Jekyll'),
icon: '.template-option .icon-jekyll',
@@ -105,4 +109,8 @@ export default {
text: s__('ProjectTemplates|Kotlin Native for Linux'),
icon: '.template-option .icon-gitlab_logo',
},
+ jsonnet: {
+ text: s__('ProjectTemplates|Jsonnet for Dynamic Child Pipelines'),
+ icon: '.template-option .icon-gitlab_logo',
+ },
};
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index d4b1f7e57d8..35e7554aee2 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -12,11 +12,14 @@ export default {
DeploymentFrequencyCharts: () =>
import('ee_component/dora/components/deployment_frequency_charts.vue'),
LeadTimeCharts: () => import('ee_component/dora/components/lead_time_charts.vue'),
+ TimeToRestoreServiceCharts: () =>
+ import('ee_component/dora/components/time_to_restore_service_charts.vue'),
ProjectQualitySummary: () => import('ee_component/project_quality_summary/app.vue'),
},
piplelinesTabEvent: 'p_analytics_ci_cd_pipelines',
deploymentFrequencyTabEvent: 'p_analytics_ci_cd_deployment_frequency',
leadTimeTabEvent: 'p_analytics_ci_cd_lead_time',
+ timeToRestoreServiceTabEvent: 'p_analytics_ci_cd_time_to_restore_service',
inject: {
shouldRenderDoraCharts: {
type: Boolean,
@@ -37,7 +40,7 @@ export default {
const chartsToShow = ['pipelines'];
if (this.shouldRenderDoraCharts) {
- chartsToShow.push('deployment-frequency', 'lead-time');
+ chartsToShow.push('deployment-frequency', 'lead-time', 'time-to-restore-service');
}
if (this.shouldRenderQualitySummary) {
@@ -95,6 +98,13 @@ export default {
>
<lead-time-charts />
</gl-tab>
+ <gl-tab
+ :title="s__('DORA4Metrics|Time to restore service')"
+ data-testid="time-to-restore-service-tab"
+ @click="trackTabClick($options.timeToRestoreServiceTabEvent)"
+ >
+ <time-to-restore-service-charts />
+ </gl-tab>
</template>
<gl-tab v-if="shouldRenderQualitySummary" :title="s__('QualitySummary|Project quality')">
<project-quality-summary />
diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js
index 4f222438500..0cbd4dbf2cf 100644
--- a/app/assets/javascripts/projects/project_import_gitlab_project.js
+++ b/app/assets/javascripts/projects/project_import_gitlab_project.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility';
import { getParameterValues } from '../lib/utils/url_utility';
import projectNew from './project_new';
@@ -22,24 +21,24 @@ const prepareParameters = () => {
export default () => {
let hasUserDefinedProjectName = false;
- const $projectName = $('.js-project-name');
- const $projectPath = $('.js-path-name');
+ const $projectName = document.querySelector('.js-project-name');
+ const $projectPath = document.querySelector('.js-path-name');
const { name, path } = prepareParameters();
// get the project name from the URL and set it as input value
- $projectName.val(name);
+ $projectName.value = name;
// get the path url and append it in the input
- $projectPath.val(path);
+ $projectPath.value = path;
// generate slug when project name changes
- $projectName.on('keyup', () => {
+ $projectName.addEventListener('keyup', () => {
projectNew.onProjectNameChange($projectName, $projectPath);
- hasUserDefinedProjectName = $projectName.val().trim().length > 0;
+ hasUserDefinedProjectName = $projectName.value.trim().length > 0;
});
// generate project name from the slug if one isn't set
- $projectPath.on('keyup', () =>
+ $projectPath.addEventListener('keyup', () =>
projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName),
);
};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 2bf13941f6f..2c2f957a75d 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -39,12 +39,18 @@ const validateImportCredentials = (url, user, password) => {
return importCredentialsValidationPromise;
};
-const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
+const onProjectNameChangeJq = ($projectNameInput, $projectPathInput) => {
const slug = slugify(convertUnicodeToAscii($projectNameInput.val()));
$projectPathInput.val(slug);
};
-const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingProjectName) => {
+const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
+ const slug = slugify(convertUnicodeToAscii($projectNameInput.value));
+ // eslint-disable-next-line no-param-reassign
+ $projectPathInput.value = slug;
+};
+
+const onProjectPathChangeJq = ($projectNameInput, $projectPathInput, hasExistingProjectName) => {
const slug = $projectPathInput.val();
if (!hasExistingProjectName) {
@@ -52,6 +58,15 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr
}
};
+const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingProjectName) => {
+ const slug = $projectPathInput.value;
+
+ if (!hasExistingProjectName) {
+ // eslint-disable-next-line no-param-reassign
+ $projectNameInput.value = convertToTitleCase(humanize(slug, '[-_]'));
+ }
+};
+
const selectedNamespaceId = () => document.querySelector('[name="project[selected_namespace_id]"]');
const dropdownButton = () => document.querySelector('.js-group-namespace-dropdown > button');
const namespaceButton = () => document.querySelector('.js-group-namespace-button');
@@ -73,24 +88,31 @@ const validateGroupNamespaceDropdown = (e) => {
const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
const specialRepo = document.querySelector('.js-user-readme-repo');
-
- // eslint-disable-next-line @gitlab/no-global-event-off
- $projectNameInput.off('keyup change').on('keyup change', () => {
+ const projectNameInputListener = () => {
onProjectNameChange($projectNameInput, $projectPathInput);
- hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0;
- hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
- });
+ hasUserDefinedProjectName = $projectNameInput.value.trim().length > 0;
+ hasUserDefinedProjectPath = $projectPathInput.value.trim().length > 0;
+ };
+
+ $projectNameInput.removeEventListener('keyup', projectNameInputListener);
+ $projectNameInput.addEventListener('keyup', projectNameInputListener);
+ $projectNameInput.removeEventListener('change', projectNameInputListener);
+ $projectNameInput.addEventListener('change', projectNameInputListener);
- // eslint-disable-next-line @gitlab/no-global-event-off
- $projectPathInput.off('keyup change').on('keyup change', () => {
+ const projectPathInputListener = () => {
onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName);
- hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
+ hasUserDefinedProjectPath = $projectPathInput.value.trim().length > 0;
specialRepo.classList.toggle(
'gl-display-none',
- $projectPathInput.val() !== $projectPathInput.data('username'),
+ $projectPathInput.value !== $projectPathInput.dataset.username,
);
- });
+ };
+
+ $projectPathInput.removeEventListener('keyup', projectPathInputListener);
+ $projectPathInput.addEventListener('keyup', projectPathInputListener);
+ $projectPathInput.removeEventListener('change', projectPathInputListener);
+ $projectPathInput.addEventListener('change', projectPathInputListener);
document.querySelector('.js-create-project-button').addEventListener('click', (e) => {
validateGroupNamespaceDropdown(e);
@@ -99,17 +121,17 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
const deriveProjectPathFromUrl = ($projectImportUrl) => {
const $currentProjectName = $projectImportUrl
- .parents('.toggle-import-form')
- .find('#project_name');
+ .closest('.toggle-import-form')
+ .querySelector('#project_name');
const $currentProjectPath = $projectImportUrl
- .parents('.toggle-import-form')
- .find('#project_path');
+ .closest('.toggle-import-form')
+ .querySelector('#project_path');
if (hasUserDefinedProjectPath || $currentProjectPath.length === 0) {
return;
}
- let importUrl = $projectImportUrl.val().trim();
+ let importUrl = $projectImportUrl.value.trim();
if (importUrl.length === 0) {
return;
}
@@ -125,7 +147,9 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
// extract everything after the last slash
const pathMatch = /\/([^/]+)$/.exec(importUrl);
if (pathMatch) {
- $currentProjectPath.val(pathMatch[1]);
+ // eslint-disable-next-line no-unused-vars
+ const [_, matchingString] = pathMatch;
+ $currentProjectPath.value = matchingString;
onProjectPathChange($currentProjectName, $currentProjectPath, false);
}
};
@@ -149,19 +173,20 @@ const bindHowToImport = () => {
const bindEvents = () => {
const $newProjectForm = $('#new_project');
- const $projectImportUrl = $('#project_import_url');
const $projectImportUrlUser = $('#project_import_url_user');
const $projectImportUrlPassword = $('#project_import_url_password');
const $projectImportUrlError = $('.js-import-url-error');
const $projectImportForm = $('form.js-project-import');
- const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
- const $projectFieldsForm = $('.project-fields-form');
- const $selectedTemplateText = $('.selected-template');
const $changeTemplateBtn = $('.change-template');
- const $selectedIcon = $('.selected-icon');
- const $projectTemplateButtons = $('.project-templates-buttons');
- const $projectName = $('.tab-pane.active #project_name');
+
+ const $projectImportUrl = document.querySelector('#project_import_url');
+ const $projectPath = document.querySelector('.tab-pane.active #project_path');
+ const $projectFieldsForm = document.querySelector('.project-fields-form');
+ const $selectedIcon = document.querySelector('.selected-icon');
+ const $selectedTemplateText = document.querySelector('.selected-template');
+ const $projectName = document.querySelector('.tab-pane.active #project_name');
+ const $projectTemplateButtons = document.querySelectorAll('.project-templates-buttons');
if ($newProjectForm.length !== 1 && $projectImportForm.length !== 1) {
return;
@@ -170,31 +195,38 @@ const bindEvents = () => {
bindHowToImport();
$('.btn_import_gitlab_project').on('click contextmenu', () => {
- const importHref = $('a.btn_import_gitlab_project').attr('data-href');
- $('.btn_import_gitlab_project').attr(
- 'href',
- `${importHref}?namespace_id=${$(
- '#project_namespace_id',
- ).val()}&name=${$projectName.val()}&path=${$projectPath.val()}`,
- );
+ const importGitlabProjectBtn = document.querySelector('.btn_import_gitlab_project');
+ const projectNamespaceId = document.querySelector('#project_namespace_id');
+
+ const { href: importHref } = importGitlabProjectBtn.dataset;
+ const newHref = `${importHref}?namespace_id=${projectNamespaceId.value}&name=${$projectName.value}&path=${$projectPath.value}`;
+ importGitlabProjectBtn.setAttribute('href', newHref);
});
+ const clearChildren = (el) => {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ };
+
function chooseTemplate() {
- $projectTemplateButtons.addClass('hidden');
- $projectFieldsForm.addClass('selected');
- $selectedIcon.empty();
+ $projectTemplateButtons.forEach((ptb) => ptb.classList.add('hidden'));
+ $projectFieldsForm.classList.add('selected');
- const $selectedTemplate = $(this);
- $selectedTemplate.prop('checked', true);
+ clearChildren($selectedIcon);
- const value = $selectedTemplate.val();
+ const $selectedTemplate = this;
+ $selectedTemplate.checked = true;
+ const { value } = $selectedTemplate;
const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value];
- $selectedTemplateText.text(selectedTemplate.text);
- $(selectedTemplate.icon).clone().addClass('d-block').appendTo($selectedIcon);
+ $selectedTemplateText.textContent = selectedTemplate.text;
+ const clone = document.querySelector(selectedTemplate.icon).cloneNode(true);
+ clone.classList.add('d-block');
+
+ $selectedIcon.append(clone);
+
+ const $activeTabProjectName = document.querySelector('.tab-pane.active #project_name');
+ const $activeTabProjectPath = document.querySelector('.tab-pane.active #project_path');
- const $activeTabProjectName = $('.tab-pane.active #project_name');
- const $activeTabProjectPath = $('.tab-pane.active #project_path');
$activeTabProjectName.focus();
setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath);
}
@@ -216,8 +248,8 @@ const bindEvents = () => {
$useTemplateBtn.on('keypress', chooseTemplateOnEnter);
$changeTemplateBtn.on('click', () => {
- $projectTemplateButtons.removeClass('hidden');
- $projectFieldsForm.removeClass('selected');
+ $projectTemplateButtons.forEach((ptb) => ptb.classList.remove('hidden'));
+ $projectFieldsForm.classList.remove('selected');
$useTemplateBtn.prop('checked', false);
});
@@ -227,7 +259,7 @@ const bindEvents = () => {
const updateUrlPathWarningVisibility = async () => {
const { success: isUrlValid, cancelled } = await validateImportCredentials(
- $projectImportUrl.val(),
+ $projectImportUrl.value,
$projectImportUrlUser.val(),
$projectImportUrlPassword.val(),
);
@@ -235,7 +267,7 @@ const bindEvents = () => {
return;
}
- $projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
+ $projectImportUrl.classList.toggle(invalidInputClass, !isUrlValid);
$projectImportUrlError.toggleClass('hide', isUrlValid);
};
const debouncedUpdateUrlPathWarningVisibility = debounce(
@@ -244,20 +276,28 @@ const bindEvents = () => {
);
let isProjectImportUrlDirty = false;
- $projectImportUrl.on('blur', () => {
+ $projectImportUrl.addEventListener('blur', () => {
isProjectImportUrlDirty = true;
debouncedUpdateUrlPathWarningVisibility();
});
- $projectImportUrl.on('keyup', () => {
+ $projectImportUrl.addEventListener('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
});
[$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
- $f.on('input', () => {
- if (isProjectImportUrlDirty) {
- debouncedUpdateUrlPathWarningVisibility();
- }
- });
+ if ($f?.on) {
+ $f.on('input', () => {
+ if (isProjectImportUrlDirty) {
+ debouncedUpdateUrlPathWarningVisibility();
+ }
+ });
+ } else {
+ $f.addEventListener('input', () => {
+ if (isProjectImportUrlDirty) {
+ debouncedUpdateUrlPathWarningVisibility();
+ }
+ });
+ }
});
$projectImportForm.on('submit', async (e) => {
@@ -287,8 +327,8 @@ const bindEvents = () => {
$('.js-import-git-toggle-button').on('click', () => {
setProjectNamePathHandlers(
- $('.tab-pane.active #project_name'),
- $('.tab-pane.active #project_path'),
+ document.querySelector('.tab-pane.active #project_name'),
+ document.querySelector('.tab-pane.active #project_path'),
);
});
@@ -300,6 +340,8 @@ export default {
deriveProjectPathFromUrl,
onProjectNameChange,
onProjectPathChange,
+ onProjectNameChangeJq,
+ onProjectPathChangeJq,
};
export { bindHowToImport };
diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js
index c962554c9f4..d299e106b14 100644
--- a/app/assets/javascripts/projects/project_visibility.js
+++ b/app/assets/javascripts/projects/project_visibility.js
@@ -1,4 +1,3 @@
-import $ from 'jquery';
import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import eventHub from '~/projects/new/event_hub';
@@ -63,9 +62,8 @@ export default function initProjectVisibilitySelector() {
const namespaceSelector = document.querySelector('select.js-select-namespace');
if (namespaceSelector) {
- $('.select2.js-select-namespace').on('change', () =>
- handleSelect2DropdownChange(namespaceSelector),
- );
+ const el = document.querySelector('.select2.js-select-namespace');
+ el.addEventListener('change', () => handleSelect2DropdownChange(namespaceSelector));
handleSelect2DropdownChange(namespaceSelector);
}
}
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
new file mode 100644
index 00000000000..6bbe0ab7d5f
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { __, sprintf } from '~/locale';
+import branchesQuery from '../queries/branches.query.graphql';
+
+export const i18n = {
+ fetchBranchesError: __('An error occurred while fetching branches.'),
+ noMatch: __('No matching results'),
+};
+
+export default {
+ i18n,
+ name: 'BranchDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ },
+ apollo: {
+ branchNames: {
+ query: branchesQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ searchPattern: `*${this.searchTerm}*`,
+ };
+ },
+ update({ project: { repository = {} } } = {}) {
+ return repository.branchNames || [];
+ },
+ error(e) {
+ createAlert({
+ message: this.$options.i18n.fetchBranchesError,
+ captureError: true,
+ error: e,
+ });
+ },
+ },
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ searchTerm: '',
+ branchNames: [],
+ };
+ },
+ computed: {
+ createButtonLabel() {
+ return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
+ },
+ shouldRenderCreateButton() {
+ return this.searchTerm && !this.branchNames.includes(this.searchTerm);
+ },
+ isLoading() {
+ return this.$apollo.queries.branchNames.loading;
+ },
+ },
+ methods: {
+ selectBranch(selected) {
+ this.$emit('input', selected);
+ },
+ createWildcard() {
+ this.$emit('createWildcard', this.searchTerm);
+ },
+ isSelected(branch) {
+ return this.value === branch;
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown :text="value || branchNames[0]">
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ data-testid="branch-search"
+ debounce="250"
+ :is-loading="isLoading"
+ />
+ <gl-dropdown-item
+ v-for="branch in branchNames"
+ :key="branch"
+ :is-checked="isSelected(branch)"
+ is-check-item
+ @click="selectBranch(branch)"
+ >
+ {{ branch }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{
+ $options.i18n.noMatch
+ }}</gl-dropdown-item>
+ <template v-if="shouldRenderCreateButton">
+ <gl-dropdown-divider />
+ <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard">
+ {{ createButtonLabel }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
new file mode 100644
index 00000000000..c2e7f4e9b1b
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import BranchDropdown from './branch_dropdown.vue';
+
+export default {
+ name: 'RuleEdit',
+ i18n: {
+ branch: __('Branch'),
+ },
+ components: { BranchDropdown, GlFormGroup },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ branch: getParameterByName('branch'),
+ };
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.branch">
+ <branch-dropdown
+ id="branches"
+ v-model="branch"
+ class="gl-w-half"
+ :project-path="projectPath"
+ @createWildcard="branch = $event"
+ />
+ </gl-form-group>
+ <!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
new file mode 100644
index 00000000000..8452542540e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import RuleEdit from './components/rule_edit.vue';
+
+export default function mountBranchRules(el) {
+ if (!el) {
+ return null;
+ }
+
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ const { projectPath } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(RuleEdit, { props: { projectPath } });
+ },
+ });
+}
diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql
new file mode 100644
index 00000000000..a532b544757
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branches.query.graphql
@@ -0,0 +1,8 @@
+query getBranches($projectPath: ID!, $searchPattern: String!) {
+ project(fullPath: $projectPath) {
+ id
+ repository {
+ branchNames(searchPattern: $searchPattern, limit: 100, offset: 0)
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
new file mode 100644
index 00000000000..ada951f6867
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -0,0 +1,16 @@
+<script>
+import { __ } from '~/locale';
+
+export default {
+ name: 'BranchRules',
+ i18n: { heading: __('Branch') },
+};
+</script>
+
+<template>
+ <div>
+ <strong>{{ $options.i18n.heading }}</strong>
+
+ <!-- TODO - List branch rules (https://gitlab.com/gitlab-org/gitlab/-/issues/362217) -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
new file mode 100644
index 00000000000..abe0b93081e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import BranchRulesApp from '~/projects/settings/repository/branch_rules/app.vue';
+
+export default function mountBranchRules(el) {
+ if (!el) return null;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(BranchRulesApp);
+ },
+ });
+}
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index f911468d8f1..3516836952f 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -2,6 +2,7 @@ import { __, sprintf } from '~/locale';
export const issuableTypesMap = {
ISSUE: 'issue',
+ INCIDENT: 'incident',
EPIC: 'epic',
MERGE_REQUEST: 'merge_request',
};
@@ -25,6 +26,11 @@ export const autoCompleteTextMap = {
{ emphasisStart: '<', emphasisEnd: '>' },
false,
),
+ [issuableTypesMap.INCIDENT]: sprintf(
+ __(' or %{emphasisStart}#id%{emphasisEnd}'),
+ { emphasisStart: '<', emphasisEnd: '>' },
+ false,
+ ),
[issuableTypesMap.EPIC]: sprintf(
__(' or %{emphasisStart}&epic id%{emphasisEnd}'),
{ emphasisStart: '<', emphasisEnd: '>' },
@@ -45,6 +51,7 @@ export const autoCompleteTextMap = {
export const inputPlaceholderTextMap = {
[issuableTypesMap.ISSUE]: __('Paste issue link'),
+ [issuableTypesMap.INCIDENT]: __('Paste link'),
[issuableTypesMap.EPIC]: __('Paste epic link'),
[issuableTypesMap.MERGE_REQUEST]: __('Enter merge request URLs'),
};
@@ -88,6 +95,7 @@ export const addRelatedItemErrorMap = {
*/
export const issuableIconMap = {
[issuableTypesMap.ISSUE]: 'issues',
+ [issuableTypesMap.INCIDENT]: 'issues',
[issuableTypesMap.EPIC]: 'epic',
};
@@ -107,6 +115,7 @@ export const PathIdSeparator = {
export const issuablesBlockHeaderTextMap = {
[issuableTypesMap.ISSUE]: __('Linked issues'),
+ [issuableTypesMap.INCIDENT]: __('Related incidents or issues'),
[issuableTypesMap.EPIC]: __('Linked epics'),
};
@@ -122,10 +131,12 @@ export const issuablesBlockAddButtonTextMap = {
export const issuablesFormCategoryHeaderTextMap = {
[issuableTypesMap.ISSUE]: __('The current issue'),
+ [issuableTypesMap.INCIDENT]: __('The current incident'),
[issuableTypesMap.EPIC]: __('The current epic'),
};
export const issuablesFormInputTextMap = {
[issuableTypesMap.ISSUE]: __('the following issue(s)'),
+ [issuableTypesMap.INCIDENT]: __('the following incident(s) or issue(s)'),
[issuableTypesMap.EPIC]: __('the following epic(s)'),
};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index b61f1cf2470..655ec57bc3d 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedIssuesRoot from './components/related_issues_root.vue';
-export default function initRelatedIssues() {
+export default function initRelatedIssues(issueType = 'issue') {
const relatedIssuesRootElement = document.querySelector('.js-related-issues-root');
if (relatedIssuesRootElement) {
// eslint-disable-next-line no-new
@@ -21,6 +21,7 @@ export default function initRelatedIssues() {
showCategorizedIssues: parseBoolean(
relatedIssuesRootElement.dataset.showCategorizedIssues,
),
+ issuableType: issueType,
autoCompleteEpics: false,
},
}),
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 59fa2fca736..a949a9d1318 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -229,7 +229,7 @@ export default {
};
</script>
<template>
- <div class="flex flex-column mt-2">
+ <div class="gl-display-flex gl-flex-direction-column gl-mt-3">
<div class="gl-align-self-end gl-mb-3">
<releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" />
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 89bc314db89..def38780545 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -72,7 +72,7 @@ export default {
category="primary"
variant="default"
icon="pencil"
- class="gl-mr-3 js-edit-button ml-2 pb-2"
+ class="gl-mr-3 js-edit-button gl-ml-3 gl-pb-3"
:title="$options.i18n.editButton"
:aria-label="$options.i18n.editButton"
:href="editLink"
diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index 8a5613c75d2..e0de6d12b13 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -1,5 +1,6 @@
fragment Release on Release {
__typename
+ id
name
tagName
tagPath
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
index 1823a327350..236d266a40a 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql
@@ -1,4 +1,5 @@
fragment ReleaseForEditing on Release {
+ id
name
tagName
description
diff --git a/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql
index 56bfe7c23d6..7344772adb9 100644
--- a/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql
+++ b/app/assets/javascripts/releases/graphql/mutations/create_release.mutation.graphql
@@ -1,6 +1,7 @@
mutation createRelease($input: ReleaseCreateInput!) {
releaseCreate(input: $input) {
release {
+ id
links {
selfUrl
}
diff --git a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
index bda7ac52a47..61a06f268bd 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -13,6 +13,7 @@ query allReleases(
__typename
nodes {
__typename
+ id
name
tagName
tagPath
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 7419b5b59d6..92d0783749e 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -71,7 +71,7 @@ export default {
<gl-loading-icon
v-if="statusIcon === 'loading'"
css-class="report-block-list-loading-icon"
- size="md"
+ size="lg"
/>
<ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" />
</div>
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 3729bd4c601..280455c3fed 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -6,9 +6,9 @@ import BlobHeader from '~/blob/components/blob_header.vue';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import { isLoggedIn } from '~/lib/utils/common_utils';
+import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import { redirectTo, getLocationHash } from '~/lib/utils/url_utility';
+import { redirectTo } from '~/lib/utils/url_utility';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import CodeIntelligence from '~/code_navigation/components/app.vue';
@@ -183,7 +183,7 @@ export default {
this.isLoadingLegacyViewer = true;
axios
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
- .then(({ data: { html, binary } }) => {
+ .then(async ({ data: { html, binary } }) => {
if (type === SIMPLE_BLOB_VIEWER) {
this.isRenderingLegacyTextViewer = true;
@@ -197,20 +197,14 @@ export default {
this.legacyRichViewer = html;
}
- this.scrollToHash();
this.isBinary = binary;
this.isLoadingLegacyViewer = false;
+
+ await this.$nextTick();
+ handleLocationHash(); // Ensures that we scroll to the hash when async content is loaded
})
.catch(() => this.displayError());
},
- scrollToHash() {
- const hash = getLocationHash();
- if (hash) {
- // Ensures the browser's native scroll to hash is triggered for async content
- window.location.hash = '';
- window.location.hash = hash;
- }
- },
displayError() {
createFlash({ message: __('An error occurred while loading the file. Please try again.') });
},
@@ -233,6 +227,9 @@ export default {
setForkTarget(target) {
this.forkTarget = target;
},
+ onCopy() {
+ navigator.clipboard.writeText(this.blobInfo.rawTextBlob);
+ },
},
};
</script>
@@ -248,7 +245,9 @@ export default {
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
:show-path="false"
+ :override-copy="glFeatures.highlightJs"
@viewer-changed="switchViewer"
+ @copy="onCopy"
>
<template #actions>
<web-ide-link
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 81d2168e2ce..3e6d2e675ed 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -9,6 +9,7 @@ const viewers = {
lfs: () => import('./lfs_viewer.vue'),
audio: () => import('./audio_viewer.vue'),
svg: () => import('./image_viewer.vue'),
+ sketch: () => import('./sketch_viewer.vue'),
};
export const loadViewer = (type, isUsingLfs) => {
diff --git a/app/assets/javascripts/repository/components/blob_viewers/sketch_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/sketch_viewer.vue
new file mode 100644
index 00000000000..b48af02e541
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/sketch_viewer.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import SketchLoader from '~/blob/sketch';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ url: this.blob.rawPath,
+ };
+ },
+ mounted() {
+ // eslint-disable-next-line no-new
+ new SketchLoader(this.$refs.viewer);
+ },
+};
+</script>
+
+<template>
+ <div ref="viewer" class="file-content" :data-endpoint="url" data-testid="sketch">
+ <gl-loading-icon class="my-4 js-loading-icon" size="lg" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 03dd7c6fada..d24d7648f1b 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -114,7 +114,7 @@ export default {
<template>
<div class="well-segment commit gl-p-5 gl-w-full gl-display-flex">
- <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" />
+ <gl-loading-icon v-if="isLoading" size="lg" color="dark" class="m-auto" />
<template v-else-if="commit">
<user-avatar-link
v-if="commit.author"
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index dc5a031c9f3..4935b8029f9 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -64,7 +64,7 @@ export default {
</div>
</div>
<div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about">
- <gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" />
+ <gl-loading-icon v-if="loading > 0" size="lg" color="dark" class="my-4 mx-auto" />
<div
v-else-if="readme"
ref="readme"
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index c2323d6b286..41f7a4b147f 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui';
+import { GlSkeletonLoader, GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '~/locale';
import getRefMixin from '../../mixins/get_ref';
@@ -10,7 +10,7 @@ import TableRow from './row.vue';
export default {
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
TableHeader,
TableRow,
ParentRow,
@@ -158,11 +158,15 @@ export default {
</template>
<template v-if="isLoading">
<tr v-for="i in 5" :key="i" aria-hidden="true">
- <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
+ <td><gl-skeleton-loader :lines="1" /></td>
<td class="gl-display-none gl-sm-display-block">
- <gl-skeleton-loading :lines="1" class="h-auto" />
+ <gl-skeleton-loader :lines="1" />
+ </td>
+ <td>
+ <div class="gl-display-flex gl-lg-justify-content-end">
+ <gl-skeleton-loader :equal-width-lines="true" :lines="1" />
+ </div>
</td>
- <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td>
</tr>
</template>
<template v-if="hasMore">
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 7aac35e7613..2b910109f7d 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -2,7 +2,7 @@
import {
GlBadge,
GlLink,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlSkeletonLoader,
GlTooltipDirective,
GlLoadingIcon,
GlIcon,
@@ -25,7 +25,7 @@ export default {
components: {
GlBadge,
GlLink,
- GlSkeletonLoading,
+ GlSkeletonLoader,
GlLoadingIcon,
GlIcon,
TimeagoTooltip,
@@ -277,12 +277,12 @@ export default {
class="str-truncated-100 tree-commit-link"
/>
<gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared">
- <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="h-auto" />
+ <gl-skeleton-loader v-if="showSkeletonLoader" :lines="1" />
</gl-intersection-observer>
</td>
<td class="tree-time-ago text-right cursor-default">
<timeago-tooltip v-if="commitData" :time="commitData.committedDate" />
- <gl-skeleton-loading v-if="showSkeletonLoader" :lines="1" class="ml-auto h-auto w-50" />
+ <gl-skeleton-loader v-if="showSkeletonLoader" :lines="1" />
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 0f8e6945bf9..0b6c5063129 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -5,6 +5,7 @@ export * from './api/markdown_api';
export * from './api/bulk_imports_api';
export * from './api/namespaces_api';
export * from './api/tags_api';
+export * from './api/alert_management_alerts_api';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index a3abc8b8e90..9de67015094 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -8,7 +8,7 @@ import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
const updateSidebarClasses = (layoutPage, rightSidebar) => {
- if (window.innerWidth >= 768) {
+ if (window.innerWidth >= 992) {
layoutPage.classList.remove('right-sidebar-expanded', 'right-sidebar-collapsed');
rightSidebar.classList.remove('right-sidebar-collapsed');
rightSidebar.classList.add('right-sidebar-expanded');
diff --git a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
index c3f317b40b0..06a8eb790fc 100644
--- a/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
+++ b/app/assets/javascripts/runner/admin_runner_show/admin_runner_show_app.vue
@@ -1,14 +1,16 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
import { createAlert, VARIANT_SUCCESS } from '~/flash';
import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { redirectTo } from '~/lib/utils/url_utility';
+import { formatJobCount } from '../utils';
import RunnerDeleteButton from '../components/runner_delete_button.vue';
import RunnerEditButton from '../components/runner_edit_button.vue';
import RunnerPauseButton from '../components/runner_pause_button.vue';
import RunnerHeader from '../components/runner_header.vue';
import RunnerDetails from '../components/runner_details.vue';
+import RunnerJobs from '../components/runner_jobs.vue';
import { I18N_FETCH_ERROR } from '../constants';
import runnerQuery from '../graphql/show/runner.query.graphql';
import { captureException } from '../sentry_utils';
@@ -17,11 +19,14 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo
export default {
name: 'AdminRunnerShowApp',
components: {
+ GlBadge,
+ GlTab,
RunnerDeleteButton,
RunnerEditButton,
RunnerPauseButton,
RunnerHeader,
RunnerDetails,
+ RunnerJobs,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -63,6 +68,9 @@ export default {
canDelete() {
return this.runner.userPermissions?.deleteRunner;
},
+ jobCount() {
+ return formatJobCount(this.runner?.jobCount);
+ },
},
errorCaptured(error) {
this.reportToSentry(error);
@@ -88,6 +96,24 @@ export default {
</template>
</runner-header>
- <runner-details :runner="runner" />
+ <runner-details :runner="runner">
+ <template #jobs-tab>
+ <gl-tab>
+ <template #title>
+ {{ s__('Runners|Jobs') }}
+ <gl-badge
+ v-if="jobCount"
+ data-testid="job-count-badge"
+ class="gl-tab-counter-badge"
+ size="sm"
+ >
+ {{ jobCount }}
+ </gl-badge>
+ </template>
+
+ <runner-jobs v-if="runner" :runner="runner" />
+ </gl-tab>
+ </template>
+ </runner-details>
</div>
</template>
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index c2bb635e056..a90ef2d3530 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -10,6 +10,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
import RunnerList from '../components/runner_list.vue';
+import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
@@ -35,6 +36,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
+ isSearchFiltered,
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
@@ -91,6 +93,7 @@ export default {
RunnerFilteredSearchBar,
RunnerBulkDelete,
RunnerList,
+ RunnerListEmptyState,
RunnerName,
RunnerStats,
RunnerPagination,
@@ -98,7 +101,7 @@ export default {
RunnerActionsCell,
},
mixins: [glFeatureFlagMixin()],
- inject: ['localMutations'],
+ inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath', 'localMutations'],
props: {
registrationToken: {
type: String,
@@ -190,6 +193,9 @@ export default {
// Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981
return this.glFeatures.adminRunnersBulkDelete;
},
+ isSearchFiltered() {
+ return isSearchFiltered(this.search);
+ },
},
watch: {
search: {
@@ -298,9 +304,13 @@ export default {
:stale-runners-count="staleRunnersTotal"
/>
- <div v-if="noRunnersFound" class="gl-text-center gl-p-5">
- {{ __('No runners found') }}
- </div>
+ <runner-list-empty-state
+ v-if="noRunnersFound"
+ :registration-token="registrationToken"
+ :is-search-filtered="isSearchFiltered"
+ :svg-path="emptyStateSvgPath"
+ :filtered-svg-path="emptyStateFilteredSvgPath"
+ />
<template v-else>
<runner-bulk-delete v-if="isBulkDeleteEnabled" />
<runner-list
diff --git a/app/assets/javascripts/runner/admin_runners/index.js b/app/assets/javascripts/runner/admin_runners/index.js
index b1d8442bb32..7bb6cd5689e 100644
--- a/app/assets/javascripts/runner/admin_runners/index.js
+++ b/app/assets/javascripts/runner/admin_runners/index.js
@@ -34,6 +34,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
registrationToken,
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ emptyStateSvgPath,
+ emptyStateFilteredSvgPath,
} = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
@@ -50,6 +52,8 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
localMutations,
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ emptyStateSvgPath,
+ emptyStateFilteredSvgPath,
},
render(h) {
return h(AdminRunnersApp, {
diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
index 93f86ae2a2c..a48db9f8ac8 100644
--- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
@@ -7,6 +7,8 @@ import RunnerPausedBadge from '../runner_paused_badge.vue';
export default {
components: {
RunnerStatusBadge,
+ RunnerUpgradeStatusBadge: () =>
+ import('ee_component/runner/components/runner_upgrade_status_badge.vue'),
RunnerPausedBadge,
},
directives: {
@@ -33,6 +35,11 @@ export default {
size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
+ <runner-upgrade-status-badge
+ :runner="runner"
+ size="sm"
+ class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ />
<runner-paused-badge
v-if="paused"
size="sm"
diff --git a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue
index bb2a8ddf151..212ad5fa5a0 100644
--- a/app/assets/javascripts/runner/components/registration/registration_dropdown.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_dropdown.vue
@@ -1,11 +1,5 @@
<script>
-import {
- GlFormGroup,
- GlDropdown,
- GlDropdownForm,
- GlDropdownItem,
- GlDropdownDivider,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownForm, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { s__ } from '~/locale';
import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
@@ -17,10 +11,8 @@ export default {
showInstallationInstructions: s__(
'Runners|Show runner installation and registration instructions',
),
- registrationToken: s__('Runners|Registration token'),
},
components: {
- GlFormGroup,
GlDropdown,
GlDropdownForm,
GlDropdownItem,
@@ -45,7 +37,6 @@ export default {
data() {
return {
currentRegistrationToken: this.registrationToken,
- instructionsModalOpened: false,
};
},
computed: {
@@ -64,15 +55,7 @@ export default {
},
methods: {
onShowInstructionsClick() {
- // Rendering the modal on demand, to avoid
- // loading instructions prematurely from API.
- this.instructionsModalOpened = true;
-
- this.$nextTick(() => {
- // $refs.runnerInstructionsModal is defined in
- // the tick after the modal is rendered
- this.$refs.runnerInstructionsModal.show();
- });
+ this.$refs.runnerInstructionsModal.show();
},
onTokenReset(token) {
this.currentRegistrationToken = token;
@@ -94,7 +77,6 @@ export default {
<gl-dropdown-item @click.capture.native.stop="onShowInstructionsClick">
{{ $options.i18n.showInstallationInstructions }}
<runner-instructions-modal
- v-if="instructionsModalOpened"
ref="runnerInstructionsModal"
:registration-token="currentRegistrationToken"
data-testid="runner-instructions-modal"
@@ -102,9 +84,7 @@ export default {
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-form class="gl-p-4!">
- <gl-form-group class="gl-mb-0" :label="$options.i18n.registrationToken">
- <registration-token :value="currentRegistrationToken" />
- </gl-form-group>
+ <registration-token input-id="token-value" :value="currentRegistrationToken" />
</gl-dropdown-form>
<gl-dropdown-divider />
<registration-token-reset-dropdown-item :type="type" @tokenReset="onTokenReset" />
diff --git a/app/assets/javascripts/runner/components/registration/registration_token.vue b/app/assets/javascripts/runner/components/registration/registration_token.vue
index 68c6429a056..6b4e6a929b7 100644
--- a/app/assets/javascripts/runner/components/registration/registration_token.vue
+++ b/app/assets/javascripts/runner/components/registration/registration_token.vue
@@ -6,13 +6,27 @@ export default {
components: {
InputCopyToggleVisibility,
},
+ i18n: {
+ registrationToken: s__('Runners|Registration token'),
+ },
props: {
+ inputId: {
+ type: String,
+ required: true,
+ },
value: {
type: String,
required: false,
default: '',
},
},
+ computed: {
+ formInputGroupProps() {
+ return {
+ id: this.inputId,
+ };
+ },
+ },
methods: {
onCopy() {
// value already in the clipboard, simply notify the user
@@ -26,8 +40,10 @@ export default {
<input-copy-toggle-visibility
class="gl-m-0"
:value="value"
- data-testid="token-value"
+ :label="$options.i18n.registrationToken"
+ :label-for="inputId"
:copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
+ :form-input-group-props="formInputGroupProps"
@copy="onCopy"
/>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index 3734f436034..75ddec6c716 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -1,26 +1,24 @@
<script>
-import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
+import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
-import { formatJobCount } from '../utils';
import RunnerDetail from './runner_detail.vue';
import RunnerGroups from './runner_groups.vue';
import RunnerProjects from './runner_projects.vue';
-import RunnerJobs from './runner_jobs.vue';
import RunnerTags from './runner_tags.vue';
export default {
components: {
- GlBadge,
GlTabs,
GlTab,
GlIntersperse,
RunnerDetail,
+ RunnerMaintenanceNoteDetail: () =>
+ import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
RunnerGroups,
RunnerProjects,
- RunnerJobs,
RunnerTags,
TimeAgo,
},
@@ -57,9 +55,6 @@ export default {
isProjectRunner() {
return this.runner?.runnerType === PROJECT_TYPE;
},
- jobCount() {
- return formatJobCount(this.runner?.jobCount);
- },
},
ACCESS_LEVEL_REF_PROTECTED,
};
@@ -106,6 +101,11 @@ export default {
/>
</template>
</runner-detail>
+
+ <runner-maintenance-note-detail
+ class="gl-pt-4 gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid"
+ :value="runner.maintenanceNoteHtml"
+ />
</dl>
</div>
@@ -113,15 +113,6 @@ export default {
<runner-projects v-if="isProjectRunner" :runner="runner" />
</template>
</gl-tab>
- <gl-tab>
- <template #title>
- {{ s__('Runners|Jobs') }}
- <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
- {{ jobCount }}
- </gl-badge>
- </template>
-
- <runner-jobs v-if="runner" :runner="runner" />
- </gl-tab>
+ <slot name="jobs-tab"></slot>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
index 4eb1312b204..57afdc4b9be 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { createAlert } from '~/flash';
import runnerJobsQuery from '../graphql/show/runner_jobs.query.graphql';
import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
@@ -11,7 +11,7 @@ import RunnerPagination from './runner_pagination.vue';
export default {
name: 'RunnerJobs',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
RunnerJobsTable,
RunnerPagination,
},
@@ -68,7 +68,9 @@ export default {
<template>
<div class="gl-pt-3">
- <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+ <div v-if="loading" class="gl-py-5">
+ <gl-skeleton-loader />
+ </div>
<runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
<p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index dcfd4b84dd2..f1f99c728c5 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -12,7 +12,7 @@ import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
const defaultFields = [
- tableField({ key: 'status', label: s__('Runners|Status') }),
+ tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }),
tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'jobCount', label: __('Jobs') }),
diff --git a/app/assets/javascripts/runner/components/runner_list_empty_state.vue b/app/assets/javascripts/runner/components/runner_list_empty_state.vue
new file mode 100644
index 00000000000..ab9cde6a401
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_list_empty_state.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlEmptyState, GlLink, GlSprintf, GlModalDirective } from '@gitlab/ui';
+import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ RunnerInstructionsModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ isSearchFiltered: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ svgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ filteredSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ registrationToken: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ modalId: 'runners-empty-state-instructions-modal',
+ svgHeight: 145,
+};
+</script>
+
+<template>
+ <gl-empty-state
+ v-if="isSearchFiltered"
+ :title="s__('Runners|No results found')"
+ :svg-path="filteredSvgPath"
+ :svg-height="$options.svgHeight"
+ :description="s__('Runners|Edit your search and try again')"
+ />
+ <gl-empty-state
+ v-else
+ :title="s__('Runners|Get started with runners')"
+ :svg-path="svgPath"
+ :svg-height="$options.svgHeight"
+ >
+ <template #description>
+ <gl-sprintf
+ :message="
+ s__(
+ 'Runners|Runners are the agents that run your CI/CD jobs. Follow the %{linkStart}installation and registration instructions%{linkEnd} to set up a runner.',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link v-gl-modal="$options.modalId">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+
+ <runner-instructions-modal
+ :modal-id="$options.modalId"
+ :registration-token="registrationToken"
+ />
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index daca718e2b5..c0c0c14e91e 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql';
@@ -17,7 +17,7 @@ import RunnerPagination from './runner_pagination.vue';
export default {
name: 'RunnerProjects',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
RunnerAssignedItem,
RunnerPagination,
},
@@ -86,7 +86,9 @@ export default {
{{ heading }}
</h3>
- <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+ <div v-if="loading" class="gl-py-5">
+ <gl-skeleton-loader />
+ </div>
<template v-else-if="projects.items.length">
<runner-assigned-item
v-for="(project, i) in projects.items"
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
index 56c9007a781..c613e2d2467 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -31,6 +31,8 @@ export default {
GlFormGroup,
GlFormInputGroup,
GlSkeletonLoader,
+ RunnerMaintenanceNoteField: () =>
+ import('ee_component/runner/components/runner_maintenance_note_field.vue'),
RunnerUpdateCostFactorFields: () =>
import('ee_component/runner/components/runner_update_cost_factor_fields.vue'),
},
@@ -115,9 +117,13 @@ export default {
<h4 class="gl-font-lg gl-my-5">{{ s__('Runners|Details') }}</h4>
<gl-skeleton-loader v-if="loading" />
- <gl-form-group v-else :label="__('Description')" data-testid="runner-field-description">
- <gl-form-input-group v-model="model.description" />
- </gl-form-group>
+
+ <template v-else>
+ <gl-form-group :label="__('Description')" data-testid="runner-field-description">
+ <gl-form-input-group v-model="model.description" />
+ </gl-form-group>
+ <runner-maintenance-note-field v-model="model.maintenanceNote" />
+ </template>
<hr />
diff --git a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
index 5d0450e7418..61bfe03bf6e 100644
--- a/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/admin_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/list/list_item.fragment.graphql"
+#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getRunners(
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
index b4f2b5cd8c8..8755636a7ad 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -1,4 +1,4 @@
-#import "~/runner/graphql/list/list_item.fragment.graphql"
+#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query getGroupRunners(
diff --git a/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
index 620c18c5bc0..19a5a48ea75 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item.fragment.graphql
@@ -1,20 +1,5 @@
+#import "./list_item_shared.fragment.graphql"
+
fragment ListItem on CiRunner {
- __typename
- id
- description
- runnerType
- shortSha
- version
- revision
- ipAddress
- active
- locked
- jobCount
- tagList
- contactedAt
- status(legacyMode: null)
- userPermissions {
- updateRunner
- deleteRunner
- }
+ ...ListItemShared
}
diff --git a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
new file mode 100644
index 00000000000..cf925359ffb
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
@@ -0,0 +1,20 @@
+fragment ListItemShared on CiRunner {
+ __typename
+ id
+ description
+ runnerType
+ shortSha
+ version
+ revision
+ ipAddress
+ active
+ locked
+ jobCount
+ tagList
+ contactedAt
+ status(legacyMode: null)
+ userPermissions {
+ updateRunner
+ deleteRunner
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/show/runner.query.graphql b/app/assets/javascripts/runner/graphql/show/runner.query.graphql
index 178816b58bd..dec434b43a5 100644
--- a/app/assets/javascripts/runner/graphql/show/runner.query.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner.query.graphql
@@ -1,41 +1,7 @@
+#import "ee_else_ce/runner/graphql/show/runner_details.fragment.graphql"
+
query getRunner($id: CiRunnerID!) {
runner(id: $id) {
- __typename
- id
- shortSha
- runnerType
- active
- accessLevel
- runUntagged
- locked
- ipAddress
- executorName
- architectureName
- platformName
- description
- maximumTimeout
- jobCount
- tagList
- createdAt
- status(legacyMode: null)
- contactedAt
- version
- editAdminUrl
- userPermissions {
- updateRunner
- deleteRunner
- }
- groups {
- # Only a single group can be loaded here, while projects
- # are loaded separately using the query with pagination
- # parameters `runner_projects.query.graphql`.
- nodes {
- id
- avatarUrl
- name
- fullName
- webUrl
- }
- }
+ ...RunnerDetails
}
}
diff --git a/app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql
new file mode 100644
index 00000000000..2449ee0fc0f
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/show/runner_details.fragment.graphql
@@ -0,0 +1,5 @@
+#import "./runner_details_shared.fragment.graphql"
+
+fragment RunnerDetails on CiRunner {
+ ...RunnerDetailsShared
+}
diff --git a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
new file mode 100644
index 00000000000..b79ad4d9280
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -0,0 +1,39 @@
+fragment RunnerDetailsShared on CiRunner {
+ __typename
+ id
+ shortSha
+ runnerType
+ active
+ accessLevel
+ runUntagged
+ locked
+ ipAddress
+ executorName
+ architectureName
+ platformName
+ description
+ maximumTimeout
+ jobCount
+ tagList
+ createdAt
+ status(legacyMode: null)
+ contactedAt
+ version
+ editAdminUrl
+ userPermissions {
+ updateRunner
+ deleteRunner
+ }
+ groups {
+ # Only a single group can be loaded here, while projects
+ # are loaded separately using the query with pagination
+ # parameters `runner_projects.query.graphql`.
+ nodes {
+ id
+ avatarUrl
+ name
+ fullName
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
new file mode 100644
index 00000000000..c336e091fdf
--- /dev/null
+++ b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue
@@ -0,0 +1,114 @@
+<script>
+import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui';
+import { createAlert, VARIANT_SUCCESS } from '~/flash';
+import { TYPE_CI_RUNNER } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { formatJobCount } from '../utils';
+import RunnerDeleteButton from '../components/runner_delete_button.vue';
+import RunnerEditButton from '../components/runner_edit_button.vue';
+import RunnerPauseButton from '../components/runner_pause_button.vue';
+import RunnerHeader from '../components/runner_header.vue';
+import RunnerDetails from '../components/runner_details.vue';
+import RunnerJobs from '../components/runner_jobs.vue';
+import { I18N_FETCH_ERROR } from '../constants';
+import runnerQuery from '../graphql/show/runner.query.graphql';
+import { captureException } from '../sentry_utils';
+import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage';
+
+export default {
+ name: 'GroupRunnerShowApp',
+ components: {
+ GlBadge,
+ GlTab,
+ RunnerDeleteButton,
+ RunnerEditButton,
+ RunnerPauseButton,
+ RunnerHeader,
+ RunnerDetails,
+ RunnerJobs,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runnerId: {
+ type: String,
+ required: true,
+ },
+ runnersPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ runner: null,
+ };
+ },
+ apollo: {
+ runner: {
+ query: runnerQuery,
+ variables() {
+ return {
+ id: convertToGraphQLId(TYPE_CI_RUNNER, this.runnerId),
+ };
+ },
+ error(error) {
+ createAlert({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ canUpdate() {
+ return this.runner.userPermissions?.updateRunner;
+ },
+ canDelete() {
+ return this.runner.userPermissions?.deleteRunner;
+ },
+ jobCount() {
+ return formatJobCount(this.runner?.jobCount);
+ },
+ },
+ errorCaptured(error) {
+ this.reportToSentry(error);
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
+ onDeleted({ message }) {
+ saveAlertToLocalStorage({ message, variant: VARIANT_SUCCESS });
+ redirectTo(this.runnersPath);
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <runner-header v-if="runner" :runner="runner">
+ <template #actions>
+ <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
+ <runner-pause-button v-if="canUpdate" :runner="runner" />
+ <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" />
+ </template>
+ </runner-header>
+
+ <runner-details :runner="runner">
+ <template #jobs-tab>
+ <gl-tab>
+ <template #title>
+ {{ s__('Runners|Jobs') }}
+ <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
+ {{ jobCount }}
+ </gl-badge>
+ </template>
+
+ <runner-jobs v-if="runner" :runner="runner" />
+ </gl-tab>
+ </template>
+ </runner-details>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js
new file mode 100644
index 00000000000..d1b87c8e427
--- /dev/null
+++ b/app/assets/javascripts/runner/group_runner_show/index.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
+import GroupRunnerShowApp from './group_runner_show_app.vue';
+
+Vue.use(VueApollo);
+
+export const initAdminRunnerShow = (selector = '#js-group-runner-show') => {
+ showAlertFromLocalStorage();
+
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const { runnerId, runnersPath } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(h) {
+ return h(GroupRunnerShowApp, {
+ props: {
+ runnerId,
+ runnersPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index b5bd4b111fd..641b3a8f560 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -8,6 +8,7 @@ import { fetchPolicies } from '~/lib/graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
+import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
@@ -31,6 +32,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
+ isSearchFiltered,
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
@@ -86,12 +88,14 @@ export default {
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerList,
+ RunnerListEmptyState,
RunnerName,
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
RunnerActionsCell,
},
+ inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'],
props: {
registrationToken: {
type: String,
@@ -196,6 +200,9 @@ export default {
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
},
+ isSearchFiltered() {
+ return isSearchFiltered(this.search);
+ },
},
watch: {
search: {
@@ -299,9 +306,13 @@ export default {
:stale-runners-count="staleRunnersTotal"
/>
- <div v-if="noRunnersFound" class="gl-text-center gl-p-5">
- {{ __('No runners found') }}
- </div>
+ <runner-list-empty-state
+ v-if="noRunnersFound"
+ :registration-token="registrationToken"
+ :is-search-filtered="isSearchFiltered"
+ :svg-path="emptyStateSvgPath"
+ :filtered-svg-path="emptyStateFilteredSvgPath"
+ />
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
<template #runner-name="{ runner }">
diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js
index 0dade30f820..feed6b0ceb7 100644
--- a/app/assets/javascripts/runner/group_runners/index.js
+++ b/app/assets/javascripts/runner/group_runners/index.js
@@ -22,6 +22,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupRunnersLimitedCount,
onlineContactTimeoutSecs,
staleTimeoutSecs,
+ emptyStateSvgPath,
+ emptyStateFilteredSvgPath,
} = el.dataset;
const apolloProvider = new VueApollo({
@@ -36,6 +38,8 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
groupId,
onlineContactTimeoutSecs: parseInt(onlineContactTimeoutSecs, 10),
staleTimeoutSecs: parseInt(staleTimeoutSecs, 10),
+ emptyStateSvgPath,
+ emptyStateFilteredSvgPath,
},
render(h) {
return h(GroupRunnersApp, {
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index 0d688ed65ef..e01878f355a 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -236,3 +236,17 @@ export const fromSearchToVariables = ({
...paginationVariables,
};
};
+
+/**
+ * Decides whether or not a search object is the "default" or empty.
+ *
+ * A search is filtered if the user has entered filtering criteria.
+ *
+ * @param {Object} search
+ * @returns true if this search is filtered, false otherwise
+ */
+export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => {
+ return Boolean(
+ runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1),
+ );
+};
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 40513a7f363..dc8b6201953 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -18,7 +18,7 @@ export const fetchGroups = ({ commit }, search) => {
});
};
-export const fetchProjects = ({ commit, state }, search, emptyCallback = () => {}) => {
+export const fetchProjects = ({ commit, state }, search) => {
commit(types.REQUEST_PROJECTS);
const groupId = state.query?.group_id;
@@ -31,17 +31,11 @@ export const fetchProjects = ({ commit, state }, search, emptyCallback = () => {
};
if (groupId) {
- Api.groupProjects(
- groupId,
- search,
- {
- order_by: 'similarity',
- with_shared: false,
- include_subgroups: true,
- },
- emptyCallback,
- true,
- )
+ Api.groupProjects(groupId, search, {
+ order_by: 'similarity',
+ with_shared: false,
+ include_subgroups: true,
+ })
.then(handleSuccess)
.catch(handleCatch);
} else {
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index b2bf913fe45..94244eeb12e 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -477,7 +477,7 @@ export class SearchAutocomplete {
}
getAvatar(item) {
- if (!Object.hasOwnProperty.call(item, 'avatar_url')) {
+ if (!Object.prototype.hasOwnProperty.call(item, 'avatar_url')) {
return false;
}
diff --git a/app/assets/javascripts/search_settings/components/search_settings.vue b/app/assets/javascripts/search_settings/components/search_settings.vue
index f6439c6f4c4..e9cc9616fd0 100644
--- a/app/assets/javascripts/search_settings/components/search_settings.vue
+++ b/app/assets/javascripts/search_settings/components/search_settings.vue
@@ -184,7 +184,7 @@ export default {
<gl-search-box-by-type
:value="searchTerm"
:debounce="$options.TYPING_DELAY"
- :placeholder="__('Search settings')"
+ :placeholder="__('Search page')"
@input="search"
/>
</template>
diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue
index d0c4ad3646c..34910781247 100644
--- a/app/assets/javascripts/security_configuration/components/app.vue
+++ b/app/assets/javascripts/security_configuration/components/app.vue
@@ -4,10 +4,9 @@ import { __, s__ } from '~/locale';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue';
-import currentLicenseQuery from '~/security_configuration/graphql/current_license.query.graphql';
import AutoDevOpsAlert from './auto_dev_ops_alert.vue';
import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue';
-import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, LICENSE_ULTIMATE } from './constants';
+import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from './constants';
import FeatureCard from './feature_card.vue';
import TrainingProviderList from './training_provider_list.vue';
import UpgradeBanner from './upgrade_banner.vue';
@@ -51,17 +50,6 @@ export default {
TrainingProviderList,
},
inject: ['projectFullPath', 'vulnerabilityTrainingDocsPath'],
- apollo: {
- currentLicensePlan: {
- query: currentLicenseQuery,
- update({ currentLicense }) {
- return currentLicense?.plan;
- },
- error() {
- this.hasCurrentLicenseFetchError = true;
- },
- },
- },
props: {
augmentedSecurityFeatures: {
type: Array,
@@ -96,13 +84,15 @@ export default {
required: false,
default: '',
},
+ securityTrainingEnabled: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
autoDevopsEnabledAlertDismissedProjects: [],
errorMessage: '',
- currentLicensePlan: '',
- hasCurrentLicenseFetchError: false,
};
},
computed: {
@@ -123,12 +113,6 @@ export default {
!this.autoDevopsEnabledAlertDismissedProjects.includes(this.projectFullPath)
);
},
- shouldShowVulnerabilityManagementTab() {
- // if the query fails (if the plan is `null` also means an error has occurred) we still want to show the feature
- const hasQueryError = this.hasCurrentLicenseFetchError || this.currentLicensePlan === null;
-
- return hasQueryError || this.currentLicensePlan === LICENSE_ULTIMATE;
- },
},
methods: {
dismissAutoDevopsEnabledAlert() {
@@ -270,7 +254,7 @@ export default {
</section-layout>
</gl-tab>
<gl-tab
- v-if="shouldShowVulnerabilityManagementTab"
+ v-if="securityTrainingEnabled"
data-testid="vulnerability-management-tab"
:title="$options.i18n.vulnerabilityManagement"
query-param-value="vulnerability-management"
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 5b04ad6f9ba..e4d2bd08f50 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -310,7 +310,3 @@ export const TEMP_PROVIDER_URLS = {
Kontra: 'https://application.security/',
[__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/',
};
-
-export const LICENSE_ULTIMATE = 'ultimate';
-export const LICENSE_FREE = 'free';
-export const LICENSE_PREMIUM = 'premium';
diff --git a/app/assets/javascripts/security_configuration/graphql/current_license.query.graphql b/app/assets/javascripts/security_configuration/graphql/current_license.query.graphql
deleted file mode 100644
index 9ab4f4d4347..00000000000
--- a/app/assets/javascripts/security_configuration/graphql/current_license.query.graphql
+++ /dev/null
@@ -1,6 +0,0 @@
-query getCurrentLicensePlan {
- currentLicense {
- id
- plan
- }
-}
diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js
index dcc41a38067..637d510e684 100644
--- a/app/assets/javascripts/security_configuration/index.js
+++ b/app/assets/javascripts/security_configuration/index.js
@@ -56,6 +56,7 @@ export const initSecurityConfiguration = (el) => {
'gitlabCiPresent',
'autoDevopsEnabled',
'canEnableAutoDevops',
+ 'securityTrainingEnabled',
]),
},
});
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
index ef40de82d01..c20dd3b677d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -1,6 +1,7 @@
<script>
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
+import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import AssigneeAvatar from './assignee_avatar.vue';
@@ -94,6 +95,9 @@ export default {
assigneeUrl() {
return this.user.web_url || this.user.webUrl;
},
+ assigneeId() {
+ return isGid(this.user.id) ? getIdFromGraphQLId(this.user.id) : this.user.id;
+ },
},
};
</script>
@@ -103,7 +107,7 @@ export default {
<gl-link
:href="assigneeUrl"
:title="tooltipTitle"
- :data-user-id="user.id"
+ :data-user-id="assigneeId"
data-placement="left"
class="gl-display-inline-block js-user-link"
>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index bdd014163a0..3602b5ec4f6 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -55,7 +55,12 @@ export default {
{{ __('None') }}
<template v-if="editable">
-
- <button type="button" class="btn-link" data-testid="assign-yourself" @click="assignSelf">
+ <button
+ type="button"
+ class="gl-button btn-link gl-reset-color!"
+ data-testid="assign-yourself"
+ @click="assignSelf"
+ >
{{ __('assign yourself') }}
</button>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
index af4227fa48d..46bda26c327 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
@@ -26,7 +26,7 @@ export default {
};
</script>
<template>
- <button type="button" class="btn-link">
+ <button type="button" class="gl-button btn-link">
<assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
<user-name-with-status
:name="user.name"
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index 50b1955abcc..f894ef0c42d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -123,7 +123,7 @@ export default {
:user="user"
:issuable-type="issuableType"
/>
- <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button">
+ <button v-if="hasMoreThanTwoAssignees" class="btn-link gl-button" type="button">
<span
class="avatar-counter sidebar-avatar-counter gl-display-flex gl-align-items-center gl-pl-3"
>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index 01d29da5486..b6260418837 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -120,7 +120,7 @@ export default {
<div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800">
<button
type="button"
- class="btn-link"
+ class="btn-link gl-button gl-reset-color!"
data-qa-selector="more_assignees_link"
@click="toggleShowLess"
>
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
index 031de669489..974ad189f32 100644
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
@@ -49,14 +49,14 @@ export default {
},
request() {
const state = {
- variant: 'default',
+ selected: false,
icon: 'attention',
direction: 'add',
};
if (this.user.attention_requested) {
Object.assign(state, {
- variant: 'warning',
+ selected: true,
icon: 'attention-solid',
direction: 'remove',
});
@@ -92,7 +92,7 @@ export default {
>
<gl-button
:loading="loading"
- :variant="request.variant"
+ :selected="request.selected"
:icon="request.icon"
:aria-label="tooltipTitle"
:class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
index 71e40fde77d..c44ce8b0057 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -8,7 +8,7 @@ import { confidentialityQueries } from '~/sidebar/constants';
export default {
i18n: {
confidentialityOnWarning: __(
- 'You are going to turn on confidentiality. Only %{context} members with %{strongStart}at least Reporter role%{strongEnd} can view or be notified about this %{issuableType}.',
+ 'You are going to turn on confidentiality. Only %{context} members with %{strongStart}%{permissions}%{strongEnd} can view or be notified about this %{issuableType}.',
),
confidentialityOffWarning: __(
'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.',
@@ -65,6 +65,11 @@ export default {
groupPath: this.fullPath,
};
},
+ permissions() {
+ return this.issuableType === IssuableType.Issue
+ ? __('at least the Reporter role, the author, and assignees')
+ : __('at least the Reporter role');
+ },
},
methods: {
submitForm() {
@@ -120,7 +125,11 @@ export default {
<p data-testid="warning-message">
<gl-sprintf :message="warningMessage">
<template #strong="{ content }">
- <strong>{{ content }}</strong>
+ <strong>
+ <gl-sprintf :message="content">
+ <template #permissions>{{ permissions }}</template>
+ </gl-sprintf>
+ </strong>
</template>
<template #context>{{ context }}</template>
<template #issuableType>{{ issuableType }}</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
index be7a89c2869..ef99d540c86 100644
--- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
+++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue
@@ -274,7 +274,7 @@ export default {
<template #collapsed>
<div v-gl-tooltip.viewport.left :title="dateLabel" class="sidebar-collapsed-icon">
<gl-icon :size="16" name="calendar" />
- <span class="collapse-truncated-title">{{ formattedDate }}</span>
+ <span class="gl-pt-2 gl-px-3 gl-font-sm">{{ formattedDate }}</span>
</div>
<sidebar-inherit-date
v-if="canInherit && !initialLoading"
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
index 2ab46a7a655..8145506f32c 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue
@@ -74,7 +74,7 @@ export default {
<gl-button
data-testid="lock-toggle"
category="secondary"
- variant="warning"
+ variant="confirm"
:disabled="isLoading"
:loading="isLoading"
@click.prevent="submitForm"
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 77e41648e9b..b8804de653f 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -99,7 +99,9 @@ export default {
>
<gl-icon name="users" />
<gl-loading-icon v-if="loading" size="sm" />
- <span v-else data-testid="collapsed-count"> {{ participantCount }} </span>
+ <span v-else data-testid="collapsed-count" class="gl-pt-2 gl-px-3 gl-font-sm">
+ {{ participantCount }}
+ </span>
</div>
<div
v-if="showParticipantLabel"
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
index 6de926e0ff9..2ea7c125a85 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer.vue
@@ -17,7 +17,7 @@ export default {
</script>
<template>
- <button type="button" class="btn-link">
+ <button type="button" class="btn-link gl-button">
<reviewer-avatar :user="user" :img-size="24" />
<span class="author"> {{ user.name }} </span>
</button>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
index e09b5d913f7..9502b2e78b3 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -95,7 +95,7 @@ export default {
>
<gl-icon v-if="hasNoUsers" name="user" :aria-label="__('None')" />
<collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" />
- <button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button">
+ <button v-if="hasMoreThanTwoReviewers" class="btn-link gl-button" type="button">
<span
class="avatar-counter sidebar-avatar-counter gl-display-flex gl-align-items-center gl-pl-3"
>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 897cab45fe4..3d8a2cd847c 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -322,7 +322,7 @@ export default {
class="sidebar-collapsed-icon"
>
<gl-icon :aria-label="attributeTypeTitle" :name="attributeTypeIcon" />
- <span class="collapse-truncated-title">
+ <span class="collapse-truncated-title gl-pt-2 gl-px-3 gl-font-sm">
{{ attributeTitle }}
</span>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
index 7b67c34ded6..465f971717f 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -58,7 +58,7 @@ export default {
} else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
return 'bold';
} else if (this.showNoTimeTrackingState) {
- return 'no-value';
+ return 'no-value collapse-truncated-title gl-pt-2 gl-px-3 gl-font-sm';
}
return '';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js b/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js
new file mode 100644
index 00000000000..70177d84b1b
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js
@@ -0,0 +1,20 @@
+import produce from 'immer';
+
+export function removeTimelogFromStore(store, deletedTimelogId, query, variables) {
+ const sourceData = store.readQuery({
+ query,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ draftData.issuable.timelogs.nodes = draftData.issuable.timelogs.nodes.filter(
+ ({ id }) => id !== deletedTimelogId,
+ );
+ });
+
+ store.writeQuery({
+ query,
+ variables,
+ data,
+ });
+}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
new file mode 100644
index 00000000000..17bbad1acb1
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
@@ -0,0 +1,5 @@
+mutation deleteTimelog($input: TimelogDeleteInput!) {
+ timelogDelete(input: $input) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index d9797961d40..79ef5a32474 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -1,11 +1,13 @@
<script>
-import { GlLoadingIcon, GlTableLite } from '@gitlab/ui';
+import { GlLoadingIcon, GlTableLite, GlButton, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import { timelogQueries } from '~/sidebar/constants';
+import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql';
+import { removeTimelogFromStore } from './graphql/cache_update';
const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
@@ -13,6 +15,10 @@ export default {
components: {
GlLoadingIcon,
GlTableLite,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
inject: ['issuableType'],
props: {
@@ -27,7 +33,7 @@ export default {
},
},
data() {
- return { report: [], isLoading: true };
+ return { report: [], isLoading: true, removingIds: [] };
},
apollo: {
report: {
@@ -35,9 +41,7 @@ export default {
return timelogQueries[this.issuableType].query;
},
variables() {
- return {
- id: convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId),
- };
+ return this.getQueryVariables();
},
update(data) {
this.isLoading = false;
@@ -48,10 +52,23 @@ export default {
},
},
},
+ computed: {
+ deleteButtonTooltip() {
+ return s__('TimeTracking|Delete time spent');
+ },
+ },
methods: {
+ isDeletingTimelog(timelogId) {
+ return this.removingIds.includes(timelogId);
+ },
isIssue() {
return this.issuableType === 'issue';
},
+ getQueryVariables() {
+ return {
+ id: convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId),
+ };
+ },
getGraphQLEntityType() {
return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST;
},
@@ -76,19 +93,51 @@ export default {
stringifyTime(parseSeconds(seconds, { limitToHours: this.limitToHours }))
);
},
+ deleteTimelog(timelogId) {
+ this.removingIds.push(timelogId);
+ this.$apollo
+ .mutate({
+ mutation: deleteTimelogMutation,
+ variables: { input: { id: timelogId } },
+ update: (store) => {
+ removeTimelogFromStore(
+ store,
+ timelogId,
+ timelogQueries[this.issuableType].query,
+ this.getQueryVariables(),
+ );
+ },
+ })
+ .then(({ data }) => {
+ if (data.timelogDelete?.errors?.length) {
+ throw new Error(data.timelogDelete.errors[0]);
+ }
+ })
+ .catch((error) => {
+ createFlash({
+ message: s__('TimeTracking|An error occurred while removing the timelog.'),
+ captureError: true,
+ error,
+ });
+ })
+ .finally(() => {
+ this.removingIds.splice(this.removingIds.indexOf(timelogId), 1);
+ });
+ },
},
fields: [
- { key: 'spentAt', label: __('Spent At'), sortable: true, tdClass: 'gl-w-quarter' },
+ { key: 'spentAt', label: __('Spent at'), sortable: true, tdClass: 'gl-w-quarter' },
{ key: 'user', label: __('User'), sortable: true },
- { key: 'timeSpent', label: __('Time Spent'), sortable: true, tdClass: 'gl-w-15' },
- { key: 'summary', label: __('Summary / Note'), sortable: true },
+ { key: 'timeSpent', label: __('Time spent'), sortable: true, tdClass: 'gl-w-15' },
+ { key: 'summary', label: __('Summary / note'), sortable: true },
+ { key: 'actions', label: '', tdClass: 'gl-w-10' },
],
};
</script>
<template>
<div>
- <div v-if="isLoading"><gl-loading-icon size="md" /></div>
+ <div v-if="isLoading"><gl-loading-icon size="lg" /></div>
<gl-table-lite v-else :items="report" :fields="$options.fields" foot-clone>
<template #cell(spentAt)="{ item: { spentAt } }">
<div>{{ formatDate(spentAt) }}</div>
@@ -110,7 +159,28 @@ export default {
<template #cell(summary)="{ item: { summary, note } }">
<div>{{ getSummary(summary, note) }}</div>
</template>
- <template #foot(note)>&nbsp;</template>
+ <template #foot(summary)>&nbsp;</template>
+
+ <template
+ #cell(actions)="{
+ item: {
+ id,
+ userPermissions: { adminTimelog },
+ },
+ }"
+ >
+ <div v-if="adminTimelog">
+ <gl-button
+ v-gl-tooltip="{ title: deleteButtonTooltip }"
+ category="secondary"
+ icon="remove"
+ data-testid="deleteButton"
+ :loading="isDeletingTimelog(id)"
+ @click="deleteTimelog(id)"
+ />
+ </div>
+ </template>
+ <template #foot(actions)>&nbsp;</template>
</gl-table-lite>
</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 057bb9f0100..e39d9f9fb49 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -252,6 +252,7 @@ export default {
size="lg"
:title="__('Time tracking report')"
:hide-footer="true"
+ @hide="refresh"
>
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
</gl-modal>
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
index 034bdc71122..ff3fb4aae6b 100644
--- a/app/assets/javascripts/sidebar/graphql.js
+++ b/app/assets/javascripts/sidebar/graphql.js
@@ -2,6 +2,7 @@ import produce from 'immer';
import VueApollo from 'vue-apollo';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
+import { temporaryConfig } from '~/work_items/graphql/provider';
const resolvers = {
Mutation: {
@@ -15,7 +16,12 @@ const resolvers = {
},
};
-export const defaultClient = createDefaultClient(resolvers);
+export const defaultClient = createDefaultClient(
+ resolvers,
+ // should be removed with the rollout of work item assignees FF
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/363030
+ temporaryConfig,
+);
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 351bb50d941..bb40ac14438 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -119,7 +119,7 @@ function mountAssigneesComponentDeprecated(mediator) {
issuableIid: String(iid),
projectPath: fullPath,
field: el.dataset.field,
- signedIn: el.hasAttribute('data-signed-in'),
+ signedIn: Object.prototype.hasOwnProperty.call(el.dataset, 'signedIn'),
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
@@ -149,7 +149,10 @@ function mountAssigneesComponent() {
},
provide: {
canUpdate: editable,
- directlyInviteMembers: el.hasAttribute('data-directly-invite-members'),
+ directlyInviteMembers: Object.prototype.hasOwnProperty.call(
+ el.dataset,
+ 'directlyInviteMembers',
+ ),
},
render: (createElement) =>
createElement('sidebar-assignees-widget', {
diff --git a/app/assets/javascripts/sidebar/queries/escalation_status.fragment.graphql b/app/assets/javascripts/sidebar/queries/escalation_status.fragment.graphql
new file mode 100644
index 00000000000..6021570557e
--- /dev/null
+++ b/app/assets/javascripts/sidebar/queries/escalation_status.fragment.graphql
@@ -0,0 +1,4 @@
+fragment EscalationStatusFragment on Issue {
+ id
+ escalationStatus
+}
diff --git a/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
index a4aff7968df..d271ae5ff3e 100644
--- a/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql
@@ -1,10 +1,12 @@
+#import "ee_else_ce/sidebar/queries/escalation_status.fragment.graphql"
+
mutation updateEscalationStatus($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) {
issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) {
errors
clientMutationId
issue {
id
- escalationStatus
+ ...EscalationStatusFragment
}
}
}
diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js
deleted file mode 100644
index 20cd4ce9d99..00000000000
--- a/app/assets/javascripts/sidebar/utils.js
+++ /dev/null
@@ -1 +0,0 @@
-export const toLabelGid = (id) => `gid://gitlab/Label/${id}`;
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b7159fd6835..26838682fc8 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -17,7 +17,7 @@ const ERROR_HTML = `<div class="nothing-here-block">${spriteIcon(
's16',
)} Could not load diff</div>`;
const COLLAPSED_HTML =
- '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <button class="click-to-expand btn btn-link">Click to expand it.</button></div>';
+ '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <button class="click-to-expand btn btn-link gl-button">Click to expand it.</button></div>';
export default class SingleFileDiff {
constructor(file) {
diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue
deleted file mode 100644
index 365fc7ce6e9..00000000000
--- a/app/assets/javascripts/static_site_editor/components/app.vue
+++ /dev/null
@@ -1,13 +0,0 @@
-<script>
-export default {
- props: {
- mergeRequestsIllustrationPath: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-<template>
- <router-view :merge-requests-illustration-path="mergeRequestsIllustrationPath" />
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
deleted file mode 100644
index 2f2efe290ec..00000000000
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ /dev/null
@@ -1,190 +0,0 @@
-<script>
-import { EDITOR_TYPES } from '~/static_site_editor/rich_content_editor/constants';
-import RichContentEditor from '~/static_site_editor/rich_content_editor/rich_content_editor.vue';
-import parseSourceFile from '~/static_site_editor/services/parse_source_file';
-import imageRepository from '../image_repository';
-import formatter from '../services/formatter';
-import renderImage from '../services/renderers/render_image';
-import templater from '../services/templater';
-import EditDrawer from './edit_drawer.vue';
-import EditHeader from './edit_header.vue';
-import PublishToolbar from './publish_toolbar.vue';
-import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
-
-export default {
- components: {
- RichContentEditor,
- PublishToolbar,
- EditHeader,
- EditDrawer,
- UnsavedChangesConfirmDialog,
- },
- props: {
- title: {
- type: String,
- required: true,
- },
- content: {
- type: String,
- required: true,
- },
- savingChanges: {
- type: Boolean,
- required: true,
- },
- returnUrl: {
- type: String,
- required: false,
- default: '',
- },
- branch: {
- type: String,
- required: true,
- },
- baseUrl: {
- type: String,
- required: true,
- },
- mounts: {
- type: Array,
- required: true,
- },
- project: {
- type: String,
- required: true,
- },
- imageRoot: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- formattedMarkdown: null,
- parsedSource: parseSourceFile(this.preProcess(true, this.content)),
- editorMode: EDITOR_TYPES.wysiwyg,
- hasMatter: false,
- isDrawerOpen: false,
- isModified: false,
- isSaveable: false,
- };
- },
- imageRepository: imageRepository(),
- computed: {
- editableContent() {
- return this.parsedSource.content(this.isWysiwygMode);
- },
- editableMatter() {
- return this.isDrawerOpen ? this.parsedSource.matter() : {};
- },
- hasSettings() {
- return this.hasMatter && this.isWysiwygMode;
- },
- isWysiwygMode() {
- return this.editorMode === EDITOR_TYPES.wysiwyg;
- },
- customRenderers() {
- const imageRenderer = renderImage.build(
- this.mounts,
- this.project,
- this.branch,
- this.baseUrl,
- this.$options.imageRepository,
- );
- return {
- image: [imageRenderer],
- };
- },
- },
- created() {
- this.refreshEditHelpers();
- },
- methods: {
- preProcess(isWrap, value) {
- const formattedContent = formatter(value);
- const templatedContent = isWrap
- ? templater.wrap(formattedContent)
- : templater.unwrap(formattedContent);
- return templatedContent;
- },
- refreshEditHelpers() {
- const { isModified, hasMatter, isMatterValid } = this.parsedSource;
- this.isModified = isModified();
- this.hasMatter = hasMatter();
- const hasValidMatter = this.hasMatter ? isMatterValid() : true;
- this.isSaveable = this.isModified && hasValidMatter;
- },
- onDrawerOpen() {
- this.isDrawerOpen = true;
- this.refreshEditHelpers();
- },
- onDrawerClose() {
- this.isDrawerOpen = false;
- this.refreshEditHelpers();
- },
- onInputChange(newVal) {
- this.parsedSource.syncContent(newVal, this.isWysiwygMode);
- this.refreshEditHelpers();
- },
- onModeChange(mode) {
- this.editorMode = mode;
-
- const preProcessedContent = this.preProcess(this.isWysiwygMode, this.editableContent);
- this.$refs.editor.resetInitialValue(preProcessedContent);
- },
- onUpdateSettings(settings) {
- this.parsedSource.syncMatter(settings);
- },
- onUploadImage({ file, imageUrl }) {
- this.$options.imageRepository.add(file, imageUrl);
- },
- onSubmit() {
- const preProcessedContent = this.preProcess(false, this.parsedSource.content());
- this.$emit('submit', {
- formattedMarkdown: this.formattedMarkdown,
- content: preProcessedContent,
- images: this.$options.imageRepository.getAll(),
- });
- },
- onEditorLoad({ formattedMarkdown }) {
- this.formattedMarkdown = formattedMarkdown;
- },
- },
-};
-</script>
-<template>
- <div class="d-flex flex-grow-1 flex-column h-100">
- <edit-header class="py-2" :title="title" />
- <edit-drawer
- v-if="hasMatter"
- :is-open="isDrawerOpen"
- :settings="editableMatter"
- @close="onDrawerClose"
- @updateSettings="onUpdateSettings"
- />
- <rich-content-editor
- ref="editor"
- :content="editableContent"
- :initial-edit-type="editorMode"
- :image-root="imageRoot"
- :options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- customRenderers,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- class="mb-9 pb-6 h-100"
- @modeChange="onModeChange"
- @input="onInputChange"
- @uploadImage="onUploadImage"
- @load="onEditorLoad"
- />
- <unsaved-changes-confirm-dialog :modified="isSaveable" />
- <publish-toolbar
- class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
- :has-settings="hasSettings"
- :return-url="returnUrl"
- :saveable="isSaveable"
- :saving-changes="savingChanges"
- @editSettings="onDrawerOpen"
- @submit="onSubmit"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue b/app/assets/javascripts/static_site_editor/components/edit_drawer.vue
deleted file mode 100644
index 781e23cd6c8..00000000000
--- a/app/assets/javascripts/static_site_editor/components/edit_drawer.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<script>
-import { GlDrawer } from '@gitlab/ui';
-import FrontMatterControls from './front_matter_controls.vue';
-
-export default {
- components: {
- GlDrawer,
- FrontMatterControls,
- },
- props: {
- isOpen: {
- type: Boolean,
- required: true,
- },
- settings: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-<template>
- <gl-drawer class="gl-pt-8" :open="isOpen" @close="$emit('close')">
- <template #title>{{ __('Page settings') }}</template>
- <front-matter-controls :settings="settings" @updateSettings="$emit('updateSettings', $event)" />
- </gl-drawer>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_header.vue b/app/assets/javascripts/static_site_editor/components/edit_header.vue
deleted file mode 100644
index 5660bfbe5ae..00000000000
--- a/app/assets/javascripts/static_site_editor/components/edit_header.vue
+++ /dev/null
@@ -1,23 +0,0 @@
-<script>
-import { DEFAULT_HEADING } from '../constants';
-
-export default {
- props: {
- title: {
- type: String,
- required: false,
- default: '',
- },
- },
- computed: {
- heading() {
- return this.title || DEFAULT_HEADING;
- },
- },
-};
-</script>
-<template>
- <div>
- <h3 ref="sseHeading">{{ heading }}</h3>
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue
deleted file mode 100644
index c6247632b6e..00000000000
--- a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue
+++ /dev/null
@@ -1,130 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlForm,
- GlFormGroup,
- GlFormInput,
- GlFormTextarea,
-} from '@gitlab/ui';
-
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownDivider,
- GlDropdownItem,
- GlForm,
- GlFormGroup,
- GlFormInput,
- GlFormTextarea,
- },
- props: {
- title: {
- type: String,
- required: true,
- },
- description: {
- type: String,
- required: true,
- },
- templates: {
- type: Array,
- required: false,
- default: null,
- },
- currentTemplate: {
- type: Object,
- required: false,
- default: null,
- },
- },
- computed: {
- dropdownLabel() {
- return this.currentTemplate ? this.currentTemplate.name : __('None');
- },
- hasTemplates() {
- return this.templates?.length > 0;
- },
- },
- mounted() {
- this.preSelect();
- },
- methods: {
- getId(type, key) {
- return `sse-merge-request-meta-${type}-${key}`;
- },
- preSelect() {
- this.$nextTick(() => {
- this.$refs.title.$el.select();
- });
- },
- onChangeTemplate(template) {
- this.$emit('changeTemplate', template || null);
- },
- onUpdate(field, value) {
- const payload = {
- title: this.title,
- description: this.description,
- [field]: value,
- };
- this.$emit('updateSettings', payload);
- },
- },
-};
-</script>
-
-<template>
- <gl-form>
- <gl-form-group
- key="title"
- :label="__('Brief title about the change')"
- :label-for="getId('control', 'title')"
- >
- <gl-form-input
- :id="getId('control', 'title')"
- ref="title"
- :value="title"
- type="text"
- @input="onUpdate('title', $event)"
- />
- </gl-form-group>
-
- <gl-form-group
- v-if="hasTemplates"
- key="template"
- :label="__('Description template')"
- :label-for="getId('control', 'template')"
- >
- <gl-dropdown :text="dropdownLabel">
- <gl-dropdown-item key="none" @click="onChangeTemplate(null)">
- {{ __('None') }}
- </gl-dropdown-item>
-
- <gl-dropdown-divider />
-
- <gl-dropdown-item
- v-for="template in templates"
- :key="template.key"
- @click="onChangeTemplate(template)"
- >
- {{ template.name }}
- </gl-dropdown-item>
- </gl-dropdown>
- </gl-form-group>
-
- <gl-form-group
- key="description"
- :label="__('Goal of the changes and what reviewers should be aware of')"
- :label-for="getId('control', 'description')"
- >
- <gl-form-textarea
- :id="getId('control', 'description')"
- :value="description"
- @input="onUpdate('description', $event)"
- />
- </gl-form-group>
- </gl-form>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
deleted file mode 100644
index e69a6b8cd69..00000000000
--- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue
+++ /dev/null
@@ -1,126 +0,0 @@
-<script>
-import { GlModal } from '@gitlab/ui';
-import Api from '~/api';
-import { __, s__, sprintf } from '~/locale';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-
-import { ISSUABLE_TYPE, MR_META_LOCAL_STORAGE_KEY } from '../constants';
-import EditMetaControls from './edit_meta_controls.vue';
-
-export default {
- components: {
- GlModal,
- EditMetaControls,
- LocalStorageSync,
- },
- props: {
- sourcePath: {
- type: String,
- required: true,
- },
- namespace: {
- type: String,
- required: true,
- },
- project: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- clearStorage: false,
- currentTemplate: null,
- mergeRequestTemplates: null,
- mergeRequestMeta: {
- title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), {
- sourcePath: this.sourcePath,
- }),
- description: s__('StaticSiteEditor|Copy update'),
- },
- };
- },
- computed: {
- disabled() {
- return this.mergeRequestMeta.title === '';
- },
- primaryProps() {
- return {
- text: __('Submit changes'),
- attributes: [{ variant: 'success' }, { disabled: this.disabled }],
- };
- },
- secondaryProps() {
- return {
- text: __('Keep editing'),
- attributes: [{ variant: 'default' }],
- };
- },
- },
- mounted() {
- this.initTemplates();
- },
- methods: {
- hide() {
- this.$refs.modal.hide();
- },
- initTemplates() {
- const { namespace, project } = this;
- Api.issueTemplates(namespace, project, ISSUABLE_TYPE, (err, templates) => {
- if (err) return; // Error handled by global AJAX error handler
- this.mergeRequestTemplates = templates;
- });
- },
- show() {
- this.$refs.modal.show();
- },
- onPrimary() {
- this.$emit('primary', this.mergeRequestMeta);
- this.clearStorage = true;
- },
- onSecondary() {
- this.hide();
- },
- onChangeTemplate(template) {
- this.currentTemplate = template;
-
- const description = this.currentTemplate ? this.currentTemplate.content : '';
- const mergeRequestMeta = { ...this.mergeRequestMeta, description };
- this.onUpdateSettings(mergeRequestMeta);
- },
- onUpdateSettings(mergeRequestMeta) {
- this.mergeRequestMeta = { ...mergeRequestMeta };
- },
- },
- storageKey: MR_META_LOCAL_STORAGE_KEY,
-};
-</script>
-
-<template>
- <gl-modal
- ref="modal"
- modal-id="edit-meta-modal"
- :title="__('Submit your changes')"
- :action-primary="primaryProps"
- :action-secondary="secondaryProps"
- size="sm"
- @primary="onPrimary"
- @secondary="onSecondary"
- @hide="() => $emit('hide')"
- >
- <local-storage-sync
- v-model="mergeRequestMeta"
- :storage-key="$options.storageKey"
- :clear="clearStorage"
- />
- <edit-meta-controls
- ref="editMetaControls"
- :title="mergeRequestMeta.title"
- :description="mergeRequestMeta.description"
- :templates="mergeRequestTemplates"
- :current-template="currentTemplate"
- @updateSettings="onUpdateSettings"
- @changeTemplate="onChangeTemplate"
- />
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/front_matter_controls.vue b/app/assets/javascripts/static_site_editor/components/front_matter_controls.vue
deleted file mode 100644
index dad3907c3ff..00000000000
--- a/app/assets/javascripts/static_site_editor/components/front_matter_controls.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui';
-import { humanize } from '~/lib/utils/text_utility';
-
-export default {
- components: {
- GlForm,
- GlFormInput,
- GlFormGroup,
- },
- props: {
- settings: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- editableSettings: { ...this.settings },
- };
- },
- methods: {
- getId(type, key) {
- return `sse-front-matter-${type}-${key}`;
- },
- getIsSupported(val) {
- return ['string', 'number'].includes(typeof val);
- },
- getLabel(str) {
- return humanize(str);
- },
- onUpdate() {
- this.$emit('updateSettings', { ...this.editableSettings });
- },
- },
-};
-</script>
-<template>
- <gl-form>
- <template v-for="(value, key) of editableSettings">
- <gl-form-group
- v-if="getIsSupported(value)"
- :id="getId('form-group', key)"
- :key="key"
- :label="getLabel(key)"
- :label-for="getId('control', key)"
- >
- <gl-form-input
- :id="getId('control', key)"
- v-model.lazy="editableSettings[key]"
- type="text"
- @input="onUpdate"
- />
- </gl-form-group>
- </template>
- </gl-form>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue
deleted file mode 100644
index fef87057307..00000000000
--- a/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue
+++ /dev/null
@@ -1,29 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-
-export default {
- components: {
- GlButton,
- },
-};
-</script>
-
-<template>
- <div>
- <h3>{{ s__('StaticSiteEditor|Incompatible file content') }}</h3>
- <p>
- {{
- s__(
- 'StaticSiteEditor|The Static Site Editor is currently configured to only edit Markdown content on pages generated from Middleman. Visit the documentation to learn more about configuring your site to use the Static Site Editor.',
- )
- }}
- </p>
- <div>
- <gl-button
- ref="documentationButton"
- href="https://gitlab.com/gitlab-org/project-templates/static-site-editor-middleman"
- >{{ s__('StaticSiteEditor|View documentation') }}</gl-button
- >
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
deleted file mode 100644
index 3bb5a0b8fd5..00000000000
--- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
+++ /dev/null
@@ -1,57 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-
-export default {
- components: {
- GlButton,
- },
- props: {
- hasSettings: {
- type: Boolean,
- required: false,
- default: false,
- },
- returnUrl: {
- type: String,
- required: false,
- default: '',
- },
- saveable: {
- type: Boolean,
- required: false,
- default: false,
- },
- savingChanges: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
-};
-</script>
-<template>
- <div class="d-flex bg-light border-top justify-content-end align-items-center py-3 px-4">
- <div>
- <gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{
- s__('StaticSiteEditor|Return to site')
- }}</gl-button>
- <gl-button
- v-if="hasSettings"
- ref="settings"
- :disabled="savingChanges"
- @click="$emit('editSettings')"
- >
- {{ __('Page settings') }}
- </gl-button>
- <gl-button
- ref="submit"
- variant="success"
- :disabled="!saveable"
- :loading="savingChanges"
- @click="$emit('submit')"
- >
- {{ __('Submit changes...') }}
- </gl-button>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue
deleted file mode 100644
index 1b6179883aa..00000000000
--- a/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue
+++ /dev/null
@@ -1,19 +0,0 @@
-<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
-
-export default {
- components: {
- GlSkeletonLoader,
- },
-};
-</script>
-<template>
- <gl-skeleton-loader :width="500" :height="102">
- <rect width="500" height="16" rx="4" />
- <rect y="20" width="375" height="16" rx="4" />
- <rect x="380" y="20" width="120" height="16" rx="4" />
- <rect y="40" width="250" height="16" rx="4" />
- <rect x="255" y="40" width="150" height="16" rx="4" />
- <rect x="410" y="40" width="90" height="16" rx="4" />
- </gl-skeleton-loader>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue
deleted file mode 100644
index c5b6c685124..00000000000
--- a/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<script>
-import { GlAlert, GlButton } from '@gitlab/ui';
-
-export default {
- components: {
- GlAlert,
- GlButton,
- },
- props: {
- error: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-<template>
- <gl-alert variant="danger" dismissible @dismiss="$emit('dismiss')">
- {{ s__('StaticSiteEditor|An error occurred while submitting your changes.') }} {{ error }}
- <template #actions>
- <gl-button variant="danger" @click="$emit('retry')">{{ __('Retry') }}</gl-button>
- </template>
- </gl-alert>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue b/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue
deleted file mode 100644
index 255f029bd27..00000000000
--- a/app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<script>
-export default {
- props: {
- modified: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- created() {
- window.addEventListener('beforeunload', this.requestConfirmation);
- },
- destroyed() {
- window.removeEventListener('beforeunload', this.requestConfirmation);
- },
- methods: {
- requestConfirmation(e) {
- if (this.modified) {
- e.preventDefault();
- // eslint-disable-next-line no-param-reassign
- e.returnValue = '';
- }
- },
- },
- render: () => null,
-};
-</script>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
deleted file mode 100644
index ab7fd0542bf..00000000000
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { s__, __ } from '~/locale';
-
-export const BRANCH_SUFFIX_COUNT = 8;
-export const ISSUABLE_TYPE = 'merge_request';
-
-export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.');
-export const SUBMIT_CHANGES_COMMIT_ERROR = s__(
- 'StaticSiteEditor|Could not commit the content changes.',
-);
-export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__(
- 'StaticSiteEditor|Could not create merge request.',
-);
-export const LOAD_CONTENT_ERROR = __(
- 'An error occurred while loading your content. Please try again.',
-);
-
-export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__(
- 'StaticSiteEditor|Automatic formatting changes',
-);
-
-export const DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION = s__(
- 'StaticSiteEditor|Markdown formatting preferences introduced by the Static Site Editor',
-);
-
-export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
-
-export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
-export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
-export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
-
-export const SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT = 'static_site_editor_commits';
-export const SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST =
- 'static_site_editor_merge_requests';
-
-export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key';
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
deleted file mode 100644
index 53572e680e5..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/index.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import appDataQuery from './queries/app_data.query.graphql';
-import fileResolver from './resolvers/file';
-import hasSubmittedChangesResolver from './resolvers/has_submitted_changes';
-import submitContentChangesResolver from './resolvers/submit_content_changes';
-import typeDefs from './typedefs.graphql';
-
-Vue.use(VueApollo);
-
-const createApolloProvider = (appData) => {
- const defaultClient = createDefaultClient(
- {
- Project: {
- file: fileResolver,
- },
- Mutation: {
- submitContentChanges: submitContentChangesResolver,
- hasSubmittedChanges: hasSubmittedChangesResolver,
- },
- },
- {
- typeDefs,
- },
- );
-
- // eslint-disable-next-line @gitlab/require-i18n-strings
- const mounts = appData.mounts.map((mount) => ({ __typename: 'Mount', ...mount }));
-
- defaultClient.cache.writeQuery({
- query: appDataQuery,
- data: {
- appData: {
- __typename: 'AppData',
- ...appData,
- mounts,
- },
- },
- });
-
- return new VueApollo({
- defaultClient,
- });
-};
-
-export default createApolloProvider;
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql
deleted file mode 100644
index 1f47929556a..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/mutations/has_submitted_changes.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation hasSubmittedChanges($input: HasSubmittedChangesInput) {
- hasSubmittedChanges(input: $input) @client {
- hasSubmittedChanges
- }
-}
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
deleted file mode 100644
index cd130aa7dbb..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-mutation submitContentChanges($input: SubmitContentChangesInput) {
- submitContentChanges(input: $input) @client {
- branch
- commit
- mergeRequest
- }
-}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
deleted file mode 100644
index e422a4b6036..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
+++ /dev/null
@@ -1,17 +0,0 @@
-query appData {
- appData @client {
- isSupportedContent
- hasSubmittedChanges
- project
- sourcePath
- username
- returnUrl
- branch
- baseUrl
- mounts {
- source
- target
- }
- imageUploadPath
- }
-}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql
deleted file mode 100644
index c29b6f93b81..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query savedContentMeta {
- savedContentMeta @client
-}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
deleted file mode 100644
index c8c4195e1cd..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-query sourceContent($project: ID!, $sourcePath: String!) {
- project(fullPath: $project) {
- id
- fullPath
- file(path: $sourcePath) @client {
- title
- content
- }
- }
-}
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js
deleted file mode 100644
index fc3cac52e2a..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import loadSourceContent from '../../services/load_source_content';
-
-const fileResolver = ({ fullPath: projectId }, { path: sourcePath }) => {
- return loadSourceContent({ projectId, sourcePath }).then((sourceContent) => ({
- // eslint-disable-next-line @gitlab/require-i18n-strings
- __typename: 'File',
- ...sourceContent,
- }));
-};
-
-export default fileResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js
deleted file mode 100644
index 35ecf6d698c..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/has_submitted_changes.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { produce } from 'immer';
-import query from '../queries/app_data.query.graphql';
-
-const hasSubmittedChangesResolver = (_, { input: { hasSubmittedChanges } }, { cache }) => {
- const oldData = cache.readQuery({ query });
-
- const data = produce(oldData, (draftState) => {
- // punctually modifying draftState as per immer docs upsets our linters
- return {
- ...draftState,
- appData: {
- __typename: 'AppData',
- ...draftState.appData,
- hasSubmittedChanges,
- },
- };
- });
-
- cache.writeQuery({
- query,
- data,
- });
-};
-
-export default hasSubmittedChangesResolver;
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
deleted file mode 100644
index e9f1828bff8..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { produce } from 'immer';
-import submitContentChanges from '../../services/submit_content_changes';
-import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
-
-const submitContentChangesResolver = (
- _,
- {
- input: {
- project: projectId,
- username,
- sourcePath,
- targetBranch,
- content,
- images,
- mergeRequestMeta,
- formattedMarkdown,
- },
- },
- { cache },
-) => {
- return submitContentChanges({
- projectId,
- username,
- sourcePath,
- targetBranch,
- content,
- images,
- mergeRequestMeta,
- formattedMarkdown,
- }).then((savedContentMeta) => {
- const data = produce(savedContentMeta, (draftState) => {
- return {
- savedContentMeta: {
- __typename: 'SavedContentMeta',
- ...draftState,
- },
- };
- });
-
- cache.writeQuery({
- query: savedContentMetaQuery,
- data,
- });
- });
-};
-
-export default submitContentChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
deleted file mode 100644
index 00af6c10359..00000000000
--- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
+++ /dev/null
@@ -1,58 +0,0 @@
-type File {
- title: String
- content: String!
-}
-
-type SavedContentField {
- label: String!
- url: String!
-}
-
-type SavedContentMeta {
- mergeRequest: SavedContentField!
- commit: SavedContentField!
- branch: SavedContentField!
-}
-
-type Mount {
- source: String!
- target: String
-}
-
-type AppData {
- isSupportedContent: Boolean!
- hasSubmittedChanges: Boolean!
- project: String!
- returnUrl: String
- sourcePath: String!
- username: String!
- branch: String!
- baseUrl: String!
- mounts: [Mount]!
- imageUploadPath: String!
-}
-
-input HasSubmittedChangesInput {
- hasSubmittedChanges: Boolean!
-}
-
-input SubmitContentChangesInput {
- project: String!
- sourcePath: String!
- content: String!
- username: String!
-}
-
-extend type Project {
- file(path: ID!): File
-}
-
-extend type Query {
- appData: AppData!
- savedContentMeta: SavedContentMeta
-}
-
-extend type Mutation {
- submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta
- hasSubmittedChanges(input: HasSubmittedChangesInput!): AppData
-}
diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js
deleted file mode 100644
index 4ad2e2618ac..00000000000
--- a/app/assets/javascripts/static_site_editor/image_repository.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-import { getBinary } from './services/image_service';
-
-const imageRepository = () => {
- const images = new Map();
- const flash = (message) =>
- createFlash({
- message,
- });
-
- const add = (file, url) => {
- getBinary(file)
- .then((content) => images.set(url, content))
- .catch(() => flash(__('Something went wrong while inserting your image. Please try again.')));
- };
-
- const get = (path) => images.get(path);
-
- const getAll = () => images;
-
- return { add, get, getAll };
-};
-
-export default imageRepository;
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
deleted file mode 100644
index 985579f68e8..00000000000
--- a/app/assets/javascripts/static_site_editor/index.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import Vue from 'vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import App from './components/app.vue';
-import createApolloProvider from './graphql';
-import createRouter from './router';
-
-const initStaticSiteEditor = (el) => {
- const {
- isSupportedContent,
- path: sourcePath,
- baseUrl,
- branch,
- namespace,
- project,
- mergeRequestsIllustrationPath,
- // NOTE: The following variables are not yet used, but are supported by the config file,
- // so we are adding them here as a convenience for future use.
- // eslint-disable-next-line no-unused-vars
- staticSiteGenerator,
- imageUploadPath,
- mounts,
- } = el.dataset;
- const { current_username: username } = window.gon;
- const returnUrl = el.dataset.returnUrl || null;
- const router = createRouter(baseUrl);
- const apolloProvider = createApolloProvider({
- isSupportedContent: parseBoolean(isSupportedContent),
- hasSubmittedChanges: false,
- project: `${namespace}/${project}`,
- mounts: JSON.parse(mounts), // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object.
- branch,
- baseUrl,
- returnUrl,
- sourcePath,
- username,
- imageUploadPath,
- });
-
- return new Vue({
- el,
- router,
- apolloProvider,
- components: {
- App,
- },
- render(createElement) {
- return createElement('app', {
- props: {
- mergeRequestsIllustrationPath,
- },
- });
- },
- });
-};
-
-export default initStaticSiteEditor;
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
deleted file mode 100644
index beec1b515ad..00000000000
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ /dev/null
@@ -1,169 +0,0 @@
-<script>
-import createFlash from '~/flash';
-import Tracking from '~/tracking';
-
-import EditArea from '../components/edit_area.vue';
-import EditMetaModal from '../components/edit_meta_modal.vue';
-import InvalidContentMessage from '../components/invalid_content_message.vue';
-import SkeletonLoader from '../components/skeleton_loader.vue';
-import SubmitChangesError from '../components/submit_changes_error.vue';
-import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants';
-import hasSubmittedChangesMutation from '../graphql/mutations/has_submitted_changes.mutation.graphql';
-import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql';
-import appDataQuery from '../graphql/queries/app_data.query.graphql';
-import sourceContentQuery from '../graphql/queries/source_content.query.graphql';
-import { SUCCESS_ROUTE } from '../router/constants';
-
-export default {
- components: {
- SkeletonLoader,
- EditArea,
- EditMetaModal,
- InvalidContentMessage,
- SubmitChangesError,
- },
- apollo: {
- appData: {
- query: appDataQuery,
- },
- sourceContent: {
- query: sourceContentQuery,
- update: ({
- project: {
- file: { title, content },
- },
- }) => {
- return { title, content };
- },
- variables() {
- return {
- project: this.appData.project,
- sourcePath: this.appData.sourcePath,
- };
- },
- skip() {
- return !this.appData.isSupportedContent;
- },
- error() {
- createFlash({
- message: LOAD_CONTENT_ERROR,
- });
- },
- },
- },
- data() {
- return {
- content: null,
- images: null,
- formattedMarkdown: null,
- submitChangesError: null,
- isSavingChanges: false,
- };
- },
- computed: {
- isLoadingContent() {
- return this.$apollo.queries.sourceContent.loading;
- },
- isContentLoaded() {
- return Boolean(this.sourceContent);
- },
- projectSplit() {
- return this.appData.project.split('/'); // TODO: refactor so `namespace` and `project` remain distinct
- },
- },
- mounted() {
- Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR);
- },
- methods: {
- onHideModal() {
- this.isSavingChanges = false;
- this.$refs.editMetaModal.hide();
- },
- onDismissError() {
- this.submitChangesError = null;
- },
- onPrepareSubmit({ formattedMarkdown, content, images }) {
- this.content = content;
- this.images = images;
- this.formattedMarkdown = formattedMarkdown;
-
- this.isSavingChanges = true;
- this.$refs.editMetaModal.show();
- },
- onSubmit(mergeRequestMeta) {
- // eslint-disable-next-line promise/catch-or-return
- this.$apollo
- .mutate({
- mutation: hasSubmittedChangesMutation,
- variables: {
- input: {
- hasSubmittedChanges: true,
- },
- },
- })
- .finally(() => {
- this.$router.push(SUCCESS_ROUTE);
- });
-
- this.$apollo
- .mutate({
- mutation: submitContentChangesMutation,
- variables: {
- input: {
- 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,
- mergeRequestMeta,
- },
- },
- })
- .catch((e) => {
- this.submitChangesError = e.message;
- })
- .finally(() => {
- this.isSavingChanges = false;
- });
- },
- },
-};
-</script>
-<template>
- <div class="container d-flex gl-flex-direction-column pt-2 h-100">
- <template v-if="appData.isSupportedContent">
- <skeleton-loader v-if="isLoadingContent" class="w-75 gl-align-self-center gl-mt-5" />
- <submit-changes-error
- v-if="submitChangesError"
- :error="submitChangesError"
- @retry="onSubmit"
- @dismiss="onDismissError"
- />
- <edit-area
- v-if="isContentLoaded"
- :title="sourceContent.title"
- :content="sourceContent.content"
- :saving-changes="isSavingChanges"
- :return-url="appData.returnUrl"
- :mounts="appData.mounts"
- :branch="appData.branch"
- :base-url="appData.baseUrl"
- :project="appData.project"
- :image-root="appData.imageUploadPath"
- @submit="onPrepareSubmit"
- />
- <edit-meta-modal
- ref="editMetaModal"
- :source-path="appData.sourcePath"
- :namespace="projectSplit[0]"
- :project="projectSplit[1]"
- @primary="onSubmit"
- @hide="onHideModal"
- />
- </template>
-
- <invalid-content-message v-else class="w-75" />
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue
deleted file mode 100644
index eb03aa3cca3..00000000000
--- a/app/assets/javascripts/static_site_editor/pages/success.vue
+++ /dev/null
@@ -1,106 +0,0 @@
-<script>
-import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
-import { s__, __, sprintf } from '~/locale';
-
-import appDataQuery from '../graphql/queries/app_data.query.graphql';
-import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql';
-import { HOME_ROUTE } from '../router/constants';
-
-export default {
- components: {
- GlButton,
- GlEmptyState,
- GlLoadingIcon,
- },
- props: {
- mergeRequestsIllustrationPath: {
- type: String,
- required: true,
- },
- },
- apollo: {
- savedContentMeta: {
- query: savedContentMetaQuery,
- },
- appData: {
- query: appDataQuery,
- },
- },
- computed: {
- updatedFileDescription() {
- const { sourcePath } = this.appData;
-
- return sprintf(__('Update %{sourcePath} file'), { sourcePath });
- },
- },
- created() {
- if (!this.appData.hasSubmittedChanges) {
- this.$router.push(HOME_ROUTE);
- }
- },
- title: s__('StaticSiteEditor|Your merge request has been created'),
- primaryButtonText: __('View merge request'),
- returnToSiteBtnText: s__('StaticSiteEditor|Return to site'),
- mergeRequestInstructionsHeading: s__(
- 'StaticSiteEditor|To see your changes live you will need to do the following things:',
- ),
- addTitleInstruction: s__('StaticSiteEditor|1. Add a clear title to describe the change.'),
- addDescriptionInstruction: s__(
- 'StaticSiteEditor|2. Add a description to explain why the change is being made.',
- ),
- assignMergeRequestInstruction: s__(
- 'StaticSiteEditor|3. Assign a person to review and accept the merge request.',
- ),
- submittingTitle: s__('StaticSiteEditor|Creating your merge request'),
- submittingNotePrimary: s__(
- 'StaticSiteEditor|You can set an assignee to get your changes reviewed and deployed once your merge request is created.',
- ),
- submittingNoteSecondary: s__(
- 'StaticSiteEditor|A link to view the merge request will appear once ready.',
- ),
-};
-</script>
-<template>
- <div>
- <div class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100">
- <div class="container gl-py-4">
- <div class="gl-display-flex">
- <gl-button
- v-if="appData.returnUrl"
- ref="returnToSiteButton"
- class="gl-mr-5 gl-align-self-start"
- :href="appData.returnUrl"
- >{{ $options.returnToSiteBtnText }}</gl-button
- >
- <strong class="gl-mt-2">
- {{ updatedFileDescription }}
- </strong>
- </div>
- </div>
- </div>
- <div class="container">
- <gl-empty-state
- class="gl-my-7"
- :title="savedContentMeta ? $options.title : $options.submittingTitle"
- :primary-button-text="savedContentMeta && $options.primaryButtonText"
- :primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url"
- :svg-path="mergeRequestsIllustrationPath"
- :svg-height="146"
- >
- <template #description>
- <div v-if="savedContentMeta">
- <p>{{ $options.mergeRequestInstructionsHeading }}</p>
- <p>{{ $options.addTitleInstruction }}</p>
- <p>{{ $options.addDescriptionInstruction }}</p>
- <p>{{ $options.assignMergeRequestInstruction }}</p>
- </div>
- <div v-else>
- <p>{{ $options.submittingNotePrimary }}</p>
- <p>{{ $options.submittingNoteSecondary }}</p>
- <gl-loading-icon size="xl" />
- </div>
- </template>
- </gl-empty-state>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js b/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js
deleted file mode 100644
index cbb30baa488..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/constants.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import { __ } from '~/locale';
-
-export const CUSTOM_EVENTS = {
- openAddImageModal: 'gl_openAddImageModal',
- openInsertVideoModal: 'gl_openInsertVideoModal',
-};
-
-export const YOUTUBE_URL = 'https://www.youtube.com';
-
-export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
-
-export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
-
-/* eslint-disable @gitlab/require-i18n-strings */
-export const TOOLBAR_ITEM_CONFIGS = [
- { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
- { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') },
- { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') },
- { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') },
- { isDivider: true },
- { icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
- { icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
- { isDivider: true },
- { icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
- { icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
- { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') },
- { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') },
- { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') },
- { isDivider: true },
- { icon: 'dash', command: 'HR', tooltip: __('Add a line') },
- { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
- { icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
- { icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
- { isDivider: true },
- { icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
- { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
-];
-
-export const EDITOR_TYPES = {
- markdown: 'markdown',
- wysiwyg: 'wysiwyg',
-};
-
-export const EDITOR_HEIGHT = '100%';
-
-export const EDITOR_PREVIEW_STYLE = 'horizontal';
-
-export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
-
-export const MAX_FILE_SIZE = 2097152; // 2Mb
-
-export const VIDEO_ATTRIBUTES = {
- width: '560',
- height: '315',
- frameBorder: '0',
- allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
-};
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue
deleted file mode 100644
index 82060d2e4ad..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/add_image_modal.vue
+++ /dev/null
@@ -1,134 +0,0 @@
-<script>
-import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui';
-import { isSafeURL, joinPaths } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import { IMAGE_TABS } from '../../constants';
-import UploadImageTab from './upload_image_tab.vue';
-
-export default {
- components: {
- UploadImageTab,
- GlModal,
- GlFormGroup,
- GlFormInput,
- GlTabs,
- GlTab,
- },
- props: {
- imageRoot: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- file: null,
- urlError: null,
- imageUrl: null,
- description: null,
- tabIndex: IMAGE_TABS.UPLOAD_TAB,
- uploadImageTab: null,
- };
- },
- modalTitle: __('Image details'),
- okTitle: __('Insert image'),
- urlTabTitle: __('Link to an image'),
- urlLabel: __('Image URL'),
- descriptionLabel: __('Description'),
- uploadTabTitle: __('Upload an image'),
- computed: {
- altText() {
- return this.description;
- },
- },
- methods: {
- show() {
- this.file = null;
- this.urlError = null;
- this.imageUrl = null;
- this.description = null;
- this.tabIndex = IMAGE_TABS.UPLOAD_TAB;
-
- this.$refs.modal.show();
- },
- onOk(event) {
- if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) {
- this.submitFile(event);
- return;
- }
- this.submitURL(event);
- },
- setFile(file) {
- this.file = file;
- },
- submitFile(event) {
- const { file, altText } = this;
- const { uploadImageTab } = this.$refs;
-
- uploadImageTab.validateFile();
-
- if (uploadImageTab.fileError) {
- event.preventDefault();
- return;
- }
-
- const imageUrl = joinPaths(this.imageRoot, file.name);
-
- this.$emit('addImage', { imageUrl, file, altText: altText || file.name });
- },
- submitURL(event) {
- if (!this.validateUrl()) {
- event.preventDefault();
- return;
- }
-
- const { imageUrl, altText } = this;
-
- this.$emit('addImage', { imageUrl, altText: altText || imageUrl });
- },
- validateUrl() {
- if (!isSafeURL(this.imageUrl)) {
- this.urlError = __('Please provide a valid URL');
- this.$refs.urlInput.$el.focus();
- return false;
- }
-
- return true;
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ref="modal"
- modal-id="add-image-modal"
- :title="$options.modalTitle"
- :ok-title="$options.okTitle"
- @ok="onOk"
- >
- <gl-tabs v-model="tabIndex">
- <!-- Upload file Tab -->
- <gl-tab :title="$options.uploadTabTitle">
- <upload-image-tab ref="uploadImageTab" @input="setFile" />
- </gl-tab>
-
- <!-- By URL Tab -->
- <gl-tab :title="$options.urlTabTitle">
- <gl-form-group
- class="gl-mt-5 gl-mb-3"
- :label="$options.urlLabel"
- label-for="url-input"
- :state="!Boolean(urlError)"
- :invalid-feedback="urlError"
- >
- <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" />
- </gl-form-group>
- </gl-tab>
- </gl-tabs>
-
- <!-- Description Input -->
- <gl-form-group :label="$options.descriptionLabel" label-for="description-input">
- <gl-form-input id="description-input" ref="descriptionInput" v-model="description" />
- </gl-form-group>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue
deleted file mode 100644
index 9baa7f286d7..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/add_image/upload_image_tab.vue
+++ /dev/null
@@ -1,56 +0,0 @@
-<script>
-import { GlFormGroup } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { MAX_FILE_SIZE } from '../../constants';
-
-export default {
- components: {
- GlFormGroup,
- },
- data() {
- return {
- file: null,
- fileError: null,
- };
- },
- fileLabel: __('Select file'),
- methods: {
- onInput(event) {
- [this.file] = event.target.files;
-
- this.validateFile();
-
- if (!this.fileError) {
- this.$emit('input', this.file);
- }
- },
- validateFile() {
- this.fileError = null;
-
- if (!this.file) {
- this.fileError = __('Please choose a file');
- } else if (this.file.size > MAX_FILE_SIZE) {
- this.fileError = __('Maximum file size is 2MB. Please select a smaller file.');
- }
- },
- },
-};
-</script>
-<template>
- <gl-form-group
- class="gl-mt-5 gl-mb-3"
- :label="$options.fileLabel"
- label-for="file-input"
- :state="!Boolean(fileError)"
- :invalid-feedback="fileError"
- >
- <input
- id="file-input"
- ref="fileInput"
- class="gl-mt-3 gl-mb-2"
- type="file"
- accept="image/*"
- @input="onInput"
- />
- </gl-form-group>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
deleted file mode 100644
index 5ce2c17f8de..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/modals/insert_video_modal.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-<script>
-import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
-import { isSafeURL } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
-import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
-
-export default {
- components: {
- GlModal,
- GlFormGroup,
- GlFormInput,
- GlSprintf,
- },
- data() {
- return {
- url: null,
- urlError: null,
- description: __(
- 'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
- ),
- };
- },
- modalTitle: __('Insert a video'),
- okTitle: __('Insert video'),
- label: __('YouTube URL or ID'),
- methods: {
- show() {
- this.urlError = null;
- this.url = null;
-
- this.$refs.modal.show();
- },
- onPrimary(event) {
- this.submitURL(event);
- },
- submitURL(event) {
- const url = this.generateUrl();
-
- if (!url) {
- event.preventDefault();
- return;
- }
-
- this.$emit('insertVideo', url);
- },
- generateUrl() {
- let { url } = this;
- const reYouTubeId = /^[A-z0-9]*$/;
- const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
-
- if (reYouTubeId.test(url)) {
- url = `${YOUTUBE_EMBED_URL}/${url}`;
- } else if (reYouTubeUrl.test(url)) {
- url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
- }
-
- if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
- this.urlError = __('Please provide a valid YouTube URL or ID');
- this.$refs.urlInput.$el.focus();
- return null;
- }
-
- return url;
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ref="modal"
- size="sm"
- modal-id="insert-video-modal"
- :title="$options.modalTitle"
- :ok-title="$options.okTitle"
- @primary="onPrimary"
- >
- <gl-form-group
- :label="$options.label"
- label-for="video-modal-url-input"
- :state="!Boolean(urlError)"
- :invalid-feedback="urlError"
- >
- <gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
- <template #description>
- <gl-sprintf :message="description" class="text-gl-muted">
- <template #id>
- <strong>{{ __('0t1DgySidms') }}</strong>
- </template>
- </gl-sprintf>
- </template>
- </gl-form-group>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue
deleted file mode 100644
index 8988dab85d2..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/rich_content_editor.vue
+++ /dev/null
@@ -1,150 +0,0 @@
-<script>
-import 'codemirror/lib/codemirror.css';
-import '@toast-ui/editor/dist/toastui-editor.css';
-
-import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
-import AddImageModal from './modals/add_image/add_image_modal.vue';
-import InsertVideoModal from './modals/insert_video_modal.vue';
-
-import {
- registerHTMLToMarkdownRenderer,
- getEditorOptions,
- addCustomEventListener,
- removeCustomEventListener,
- addImage,
- getMarkdown,
- insertVideo,
-} from './services/editor_service';
-
-export default {
- components: {
- ToastEditor: () =>
- import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
- (toast) => toast.Editor,
- ),
- AddImageModal,
- InsertVideoModal,
- },
- props: {
- content: {
- type: String,
- required: true,
- },
- options: {
- type: Object,
- required: false,
- default: () => null,
- },
- initialEditType: {
- type: String,
- required: false,
- default: EDITOR_TYPES.wysiwyg,
- },
- height: {
- type: String,
- required: false,
- default: EDITOR_HEIGHT,
- },
- previewStyle: {
- type: String,
- required: false,
- default: EDITOR_PREVIEW_STYLE,
- },
- imageRoot: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- editorApi: null,
- previousMode: null,
- };
- },
- computed: {
- editorInstance() {
- return this.$refs.editor;
- },
- customEventListeners() {
- return [
- { event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
- { event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
- ];
- },
- },
- created() {
- this.editorOptions = getEditorOptions(this.options);
- },
- beforeDestroy() {
- this.removeListeners();
- },
- methods: {
- addListeners(editorApi) {
- this.customEventListeners.forEach(({ event, listener }) => {
- addCustomEventListener(editorApi, event, listener);
- });
-
- editorApi.eventManager.listen('changeMode', this.onChangeMode);
- },
- removeListeners() {
- this.customEventListeners.forEach(({ event, listener }) => {
- removeCustomEventListener(this.editorApi, event, listener);
- });
-
- this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
- },
- resetInitialValue(newVal) {
- this.editorInstance.invoke('setMarkdown', newVal);
- },
- onContentChanged() {
- this.$emit('input', getMarkdown(this.editorInstance));
- },
- onLoad(editorApi) {
- this.editorApi = editorApi;
-
- registerHTMLToMarkdownRenderer(editorApi);
-
- this.addListeners(editorApi);
-
- this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() });
- },
- onOpenAddImageModal() {
- this.$refs.addImageModal.show();
- },
- onAddImage({ imageUrl, altText, file }) {
- const image = { imageUrl, altText };
-
- if (file) {
- this.$emit('uploadImage', { file, imageUrl });
- }
-
- addImage(this.editorInstance, image, file);
- },
- onOpenInsertVideoModal() {
- this.$refs.insertVideoModal.show();
- },
- onInsertVideo(url) {
- insertVideo(this.editorInstance, url);
- },
- onChangeMode(newMode) {
- this.$emit('modeChange', newMode);
- },
- },
-};
-</script>
-<template>
- <div>
- <toast-editor
- ref="editor"
- :initial-value="content"
- :options="editorOptions"
- :preview-style="previewStyle"
- :initial-edit-type="initialEditType"
- :height="height"
- @change="onContentChanged"
- @load="onLoad"
- />
- <add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
- <insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js
deleted file mode 100644
index 6ffd280e005..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_custom_renderer.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { union, mapValues } from 'lodash';
-import renderAttributeDefinition from './renderers/render_attribute_definition';
-import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline';
-import renderHeading from './renderers/render_heading';
-import renderBlockHtml from './renderers/render_html_block';
-import renderIdentifierInstanceText from './renderers/render_identifier_instance_text';
-import renderIdentifierParagraph from './renderers/render_identifier_paragraph';
-import renderListItem from './renderers/render_list_item';
-import renderSoftbreak from './renderers/render_softbreak';
-
-const htmlInlineRenderers = [renderFontAwesomeHtmlInline];
-const htmlBlockRenderers = [renderBlockHtml];
-const headingRenderers = [renderHeading];
-const paragraphRenderers = [renderIdentifierParagraph, renderBlockHtml];
-const textRenderers = [renderIdentifierInstanceText, renderAttributeDefinition];
-const listItemRenderers = [renderListItem];
-const softbreakRenderers = [renderSoftbreak];
-
-const executeRenderer = (renderers, node, context) => {
- const availableRenderer = renderers.find((renderer) => renderer.canRender(node, context));
-
- return availableRenderer ? availableRenderer.render(node, context) : context.origin();
-};
-
-const buildCustomHTMLRenderer = (customRenderers) => {
- const renderersByType = {
- ...customRenderers,
- htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock),
- htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline),
- heading: union(headingRenderers, customRenderers?.heading),
- item: union(listItemRenderers, customRenderers?.listItem),
- paragraph: union(paragraphRenderers, customRenderers?.paragraph),
- text: union(textRenderers, customRenderers?.text),
- softbreak: union(softbreakRenderers, customRenderers?.softbreak),
- };
-
- return mapValues(renderersByType, (renderers) => {
- return (node, context) => executeRenderer(renderers, node, context);
- });
-};
-
-export default buildCustomHTMLRenderer;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js
deleted file mode 100644
index 273e0a59963..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/build_html_to_markdown_renderer.js
+++ /dev/null
@@ -1,109 +0,0 @@
-/* eslint-disable @gitlab/require-i18n-strings */
-import { defaults, repeat } from 'lodash';
-
-const DEFAULTS = {
- subListIndentSpaces: 4,
- unorderedListBulletChar: '-',
- incrementListMarker: false,
- strong: '*',
- emphasis: '_',
-};
-
-const countIndentSpaces = (text) => {
- const matches = text.match(/^\s+/m);
-
- return matches ? matches[0].length : 0;
-};
-
-const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => {
- const {
- subListIndentSpaces,
- unorderedListBulletChar,
- incrementListMarker,
- strong,
- emphasis,
- } = defaults(formattingPreferences, DEFAULTS);
- const sublistNode = 'LI OL, LI UL';
- const unorderedListItemNode = 'UL LI';
- const orderedListItemNode = 'OL LI';
- const emphasisNode = 'EM, I';
- const strongNode = 'STRONG, B';
- const headingNode = 'H1, H2, H3, H4, H5, H6';
- const preCodeNode = 'PRE CODE';
-
- return {
- TEXT_NODE(node) {
- return baseRenderer.getSpaceControlled(
- baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)),
- node,
- );
- },
- /*
- * This converter overwrites the default indented list converter
- * to allow us to parameterize the number of indent spaces for
- * sublists.
- *
- * See the original implementation in
- * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161
- */
- [sublistNode](node, subContent) {
- const baseResult = baseRenderer.convert(node, subContent);
- // Default to 1 to prevent possible divide by 0
- const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1;
- const reindentedList = baseResult
- .split('\n')
- .map((line) => {
- const itemIndentSpacesCount = countIndentSpaces(line);
- const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount);
- const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel);
-
- return line.replace(/^ +/, indentSpaces);
- })
- .join('\n');
-
- return reindentedList;
- },
- [unorderedListItemNode](node, subContent) {
- const baseResult = baseRenderer.convert(node, subContent);
- const formatted = baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`);
- const { attributeDefinition } = node.dataset;
-
- return attributeDefinition ? `${formatted.trimRight()}\n${attributeDefinition}\n` : formatted;
- },
- [orderedListItemNode](node, subContent) {
- const baseResult = baseRenderer.convert(node, subContent);
-
- return incrementListMarker ? baseResult : baseResult.replace(/^(\s*)\d+?\./, '$11.');
- },
- [emphasisNode](node, subContent) {
- const result = baseRenderer.convert(node, subContent);
-
- return result.replace(/(^[*_]{1}|[*_]{1}$)/g, emphasis);
- },
- [strongNode](node, subContent) {
- const result = baseRenderer.convert(node, subContent);
- const strongSyntax = repeat(strong, 2);
-
- return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax);
- },
- [headingNode](node, subContent) {
- const result = baseRenderer.convert(node, subContent);
- const { attributeDefinition } = node.dataset;
-
- return attributeDefinition ? `${result.trimRight()}\n${attributeDefinition}\n\n` : result;
- },
- [preCodeNode](node, subContent) {
- const isReferenceDefinition = Boolean(node.dataset.sseReferenceDefinition);
-
- return isReferenceDefinition
- ? `\n\n${node.innerText}\n\n`
- : baseRenderer.convert(node, subContent);
- },
- IMG(node) {
- const { originalSrc } = node.dataset;
- return `![${node.alt}](${originalSrc || node.src})`;
- },
- };
-};
-
-export default buildHTMLToMarkdownRender;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js
deleted file mode 100644
index 026a4069d9b..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/editor_service.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import { defaults } from 'lodash';
-import Vue from 'vue';
-import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
-import ToolbarItem from '../toolbar_item.vue';
-import buildCustomHTMLRenderer from './build_custom_renderer';
-import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
-import sanitizeHTML from './sanitize_html';
-
-const buildWrapper = (propsData) => {
- const instance = new Vue({
- render(createElement) {
- return createElement(ToolbarItem, propsData);
- },
- });
-
- instance.$mount();
- return instance.$el;
-};
-
-const buildVideoIframe = (src) => {
- const wrapper = document.createElement('figure');
- const iframe = document.createElement('iframe');
- const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
- const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
- const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
-
- wrapper.setAttribute('contenteditable', 'false');
- wrapper.classList.add(...wrapperClasses);
- iframe.classList.add(...iframeClasses);
- Object.assign(iframe, videoAttributes);
-
- wrapper.appendChild(iframe);
-
- return wrapper;
-};
-
-const buildImg = (alt, originalSrc, file) => {
- const img = document.createElement('img');
- const src = file ? URL.createObjectURL(file) : originalSrc;
- const attributes = { alt, src };
-
- if (file) {
- img.dataset.originalSrc = originalSrc;
- }
-
- Object.assign(img, attributes);
-
- return img;
-};
-
-export const generateToolbarItem = (config) => {
- const { icon, classes, event, command, tooltip, isDivider } = config;
-
- if (isDivider) {
- return 'divider';
- }
-
- return {
- type: 'button',
- options: {
- el: buildWrapper({ props: { icon, tooltip }, class: classes }),
- event,
- command,
- },
- };
-};
-
-export const addCustomEventListener = (editorApi, event, handler) => {
- editorApi.eventManager.addEventType(event);
- editorApi.eventManager.listen(event, handler);
-};
-
-export const removeCustomEventListener = (editorApi, event, handler) =>
- editorApi.eventManager.removeEventHandler(event, handler);
-
-export const addImage = ({ editor }, { altText, imageUrl }, file) => {
- if (editor.isWysiwygMode()) {
- const img = buildImg(altText, imageUrl, file);
- editor.getSquire().insertElement(img);
- } else {
- editor.insertText(`![${altText}](${imageUrl})`);
- }
-};
-
-export const insertVideo = ({ editor }, url) => {
- const videoIframe = buildVideoIframe(url);
-
- if (editor.isWysiwygMode()) {
- editor.getSquire().insertElement(videoIframe);
- } else {
- editor.insertText(videoIframe.outerHTML);
- }
-};
-
-export const getMarkdown = (editorInstance) => editorInstance.invoke('getMarkdown');
-
-/**
- * This function allow us to extend Toast UI HTML to Markdown renderer. It is
- * a temporary measure because Toast UI does not provide an API
- * to achieve this goal.
- */
-export const registerHTMLToMarkdownRenderer = (editorApi) => {
- const { renderer } = editorApi.toMarkOptions;
-
- Object.assign(editorApi.toMarkOptions, {
- renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)),
- });
-};
-
-export const getEditorOptions = (externalOptions) => {
- return defaults({
- customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers),
- toolbarItems: TOOLBAR_ITEM_CONFIGS.map((toolbarItem) => generateToolbarItem(toolbarItem)),
- customHTMLSanitizer: (html) => sanitizeHTML(html),
- });
-};
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js
deleted file mode 100644
index 638e5fd6f60..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/build_uneditable_token.js
+++ /dev/null
@@ -1,63 +0,0 @@
-const buildToken = (type, tagName, props) => {
- return { type, tagName, ...props };
-};
-
-const TAG_TYPES = {
- block: 'div',
- inline: 'a',
-};
-
-// Open helpers (singular and multiple)
-
-const buildUneditableOpenToken = (tagType = TAG_TYPES.block) =>
- buildToken('openTag', tagType, {
- attributes: { contenteditable: false },
- classNames: [
- 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed',
- ],
- });
-
-export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => {
- return [buildUneditableOpenToken(tagType), token];
-};
-
-// Close helpers (singular and multiple)
-
-export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) =>
- buildToken('closeTag', tagType);
-
-export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => {
- return [token, buildUneditableCloseToken(tagType)];
-};
-
-// Complete helpers (open plus close)
-
-export const buildTextToken = (content) => buildToken('text', null, { content });
-
-export const buildUneditableBlockTokens = (token) => {
- return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()];
-};
-
-export const buildUneditableInlineTokens = (token) => {
- return [
- ...buildUneditableOpenTokens(token, TAG_TYPES.inline),
- buildUneditableCloseToken(TAG_TYPES.inline),
- ];
-};
-
-export const buildUneditableHtmlAsTextTokens = (node) => {
- /*
- Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain
- nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want
- to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html`
- type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass `
- to prevent their persistence within the `text` content as the user did not intend these as edits.
-
- https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72
- */
- const regex = / data-tomark-pass /gm;
- const content = node.literal.replace(regex, '');
- const htmlAsTextToken = buildToken('text', null, { content });
-
- return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()];
-};
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js
deleted file mode 100644
index bd419447a48..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_attribute_definition.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { isAttributeDefinition } from './render_utils';
-
-const canRender = ({ literal }) => isAttributeDefinition(literal);
-
-const render = () => ({ type: 'html', content: '<!-- sse-attribute-definition -->' });
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js
deleted file mode 100644
index 0e122f598e5..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_embedded_ruby_text.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { renderUneditableLeaf as render } from './render_utils';
-
-const embeddedRubyRegex = /(^<%.+%>$)/;
-
-const canRender = ({ literal }) => {
- return embeddedRubyRegex.test(literal);
-};
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
deleted file mode 100644
index 572f6e3cf9d..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_font_awesome_html_inline.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { buildUneditableInlineTokens } from './build_uneditable_token';
-
-const fontAwesomeRegexOpen = /<i class="fa.+>/;
-
-const canRender = ({ literal }) => {
- return fontAwesomeRegexOpen.test(literal);
-};
-
-const render = (_, { origin }) => buildUneditableInlineTokens(origin());
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js
deleted file mode 100644
index 71026fd0d65..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_heading.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import {
- renderWithAttributeDefinitions as render,
- willAlwaysRender as canRender,
-} from './render_utils';
-
-export default { render, canRender };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js
deleted file mode 100644
index 710b807275b..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_html_block.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import { getURLOrigin } from '~/lib/utils/url_utility';
-import { ALLOWED_VIDEO_ORIGINS } from '../../constants';
-import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token';
-
-const isVideoFrame = (html) => {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- const {
- children: { length },
- } = doc;
- const iframe = doc.querySelector('iframe');
- const origin = iframe && getURLOrigin(iframe.getAttribute('src'));
-
- return length === 1 && ALLOWED_VIDEO_ORIGINS.includes(origin);
-};
-
-const canRender = ({ type, literal }) => {
- return type === 'htmlBlock' && !isVideoFrame(literal);
-};
-
-const render = (node) => buildUneditableHtmlAsTextTokens(node);
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
deleted file mode 100644
index e41dc51457a..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token';
-
-/*
-Use case examples:
-- Majority: two bracket pairs, back-to-back, each with content (including spaces)
- - `[environment terraform plans][terraform]`
- - `[an issue labelled `~"main:broken"`][broken-main-issues]`
-- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces)
- - `[this link][]`
- - `[this link]`
-
-Regexp notes:
- - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces)
- - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces)
- - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`)
- - Each of the three parts is non-captured, but the match as a whole is captured
-*/
-const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g;
-
-const isIdentifierInstance = (literal) => {
- // Reset lastIndex as global flag in regexp are stateful
- identifierInstanceRegex.lastIndex = 0;
- return identifierInstanceRegex.test(literal);
-};
-
-const canRender = ({ literal }) => isIdentifierInstance(literal);
-
-const tokenize = (text) => {
- const matches = text.split(identifierInstanceRegex);
- const tokens = matches.map((match) => {
- const token = buildTextToken(match);
- return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token;
- });
-
- return tokens.flat();
-};
-
-const render = (_, { origin }) => tokenize(origin().content);
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js
deleted file mode 100644
index 4829f0f2243..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_paragraph.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const identifierRegex = /(^\[.+\]: .+)/;
-
-const isIdentifier = (text) => {
- return identifierRegex.test(text);
-};
-
-const canRender = (node, context) => {
- return isIdentifier(context.getChildrenText(node));
-};
-
-const getReferenceDefinitions = (node, definitions = '') => {
- if (!node) {
- return definitions;
- }
-
- const definition = node.type === 'text' ? node.literal : '\n';
-
- return getReferenceDefinitions(node.next, `${definitions}${definition}`);
-};
-
-const render = (node, { skipChildren }) => {
- const content = getReferenceDefinitions(node.firstChild);
-
- skipChildren();
-
- return [
- {
- type: 'openTag',
- tagName: 'pre',
- classNames: ['code-block', 'language-markdown'],
- attributes: { 'data-sse-reference-definition': true },
- },
- { type: 'openTag', tagName: 'code' },
- { type: 'text', content },
- { type: 'closeTag', tagName: 'code' },
- { type: 'closeTag', tagName: 'pre' },
- ];
-};
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js
deleted file mode 100644
index 71026fd0d65..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_list_item.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import {
- renderWithAttributeDefinitions as render,
- willAlwaysRender as canRender,
-} from './render_utils';
-
-export default { render, canRender };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js
deleted file mode 100644
index c004e839821..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_softbreak.js
+++ /dev/null
@@ -1,7 +0,0 @@
-const canRender = (node) => ['emph', 'strong'].includes(node.parent?.type);
-const render = () => ({
- type: 'text',
- content: ' ',
-});
-
-export default { canRender, render };
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js
deleted file mode 100644
index eff5dbf59f2..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_utils.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import {
- buildUneditableBlockTokens,
- buildUneditableOpenTokens,
- buildUneditableCloseToken,
-} from './build_uneditable_token';
-
-export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin());
-
-export const renderUneditableBranch = (_, { entering, origin }) =>
- entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken();
-
-const attributeDefinitionRegexp = /(^{:.+}$)/;
-
-export const isAttributeDefinition = (text) => attributeDefinitionRegexp.test(text);
-
-const findAttributeDefinition = (node) => {
- const literal =
- node?.next?.firstChild?.literal || node?.firstChild?.firstChild?.next?.next?.literal; // for headings // for list items;
-
- return isAttributeDefinition(literal) ? literal : null;
-};
-
-export const renderWithAttributeDefinitions = (node, { origin }) => {
- const attributes = findAttributeDefinition(node);
- const token = origin();
-
- if (token.type === 'openTag' && attributes) {
- Object.assign(token, {
- attributes: {
- 'data-attribute-definition': attributes,
- },
- });
- }
-
- return token;
-};
-
-export const willAlwaysRender = () => true;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js
deleted file mode 100644
index 486d88466b7..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/sanitize_html.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import createSanitizer from 'dompurify';
-import { getURLOrigin } from '~/lib/utils/url_utility';
-import { ALLOWED_VIDEO_ORIGINS } from '../constants';
-
-const sanitizer = createSanitizer(window);
-const ADD_TAGS = ['iframe'];
-
-sanitizer.addHook('uponSanitizeElement', (node) => {
- if (node.tagName !== 'IFRAME') {
- return;
- }
-
- const origin = getURLOrigin(node.getAttribute('src'));
-
- if (!ALLOWED_VIDEO_ORIGINS.includes(origin)) {
- node.remove();
- }
-});
-
-const sanitize = (content) => sanitizer.sanitize(content, { ADD_TAGS });
-
-export default sanitize;
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue
deleted file mode 100644
index 85a67c087bb..00000000000
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/toolbar_item.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- icon: {
- type: String,
- required: true,
- },
- tooltip: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-<template>
- <button
- v-gl-tooltip="{ title: tooltip }"
- :aria-label="tooltip"
- class="p-0 gl-display-flex toolbar-button"
- >
- <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" />
- </button>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/router/constants.js b/app/assets/javascripts/static_site_editor/router/constants.js
deleted file mode 100644
index fd715f918ce..00000000000
--- a/app/assets/javascripts/static_site_editor/router/constants.js
+++ /dev/null
@@ -1,2 +0,0 @@
-export const HOME_ROUTE = { name: 'home' };
-export const SUCCESS_ROUTE = { name: 'success' };
diff --git a/app/assets/javascripts/static_site_editor/router/index.js b/app/assets/javascripts/static_site_editor/router/index.js
deleted file mode 100644
index 12692612bbc..00000000000
--- a/app/assets/javascripts/static_site_editor/router/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import Vue from 'vue';
-import VueRouter from 'vue-router';
-import routes from './routes';
-
-Vue.use(VueRouter);
-
-export default function createRouter(base) {
- const router = new VueRouter({
- base,
- mode: 'history',
- routes,
- });
-
- return router;
-}
diff --git a/app/assets/javascripts/static_site_editor/router/routes.js b/app/assets/javascripts/static_site_editor/router/routes.js
deleted file mode 100644
index 6fb9dbe0182..00000000000
--- a/app/assets/javascripts/static_site_editor/router/routes.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import Home from '../pages/home.vue';
-import Success from '../pages/success.vue';
-
-import { HOME_ROUTE, SUCCESS_ROUTE } from './constants';
-
-export default [
- {
- ...HOME_ROUTE,
- path: '/',
- component: Home,
- },
- {
- ...SUCCESS_ROUTE,
- path: '/success',
- component: Success,
- },
- {
- path: '*',
- redirect: HOME_ROUTE,
- },
-];
diff --git a/app/assets/javascripts/static_site_editor/services/formatter.js b/app/assets/javascripts/static_site_editor/services/formatter.js
deleted file mode 100644
index e841c664406..00000000000
--- a/app/assets/javascripts/static_site_editor/services/formatter.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { repeat } from 'lodash';
-
-const topLevelOrderedRegexp = /^\d{1,3}/;
-const nestedLineRegexp = /^\s+/;
-
-/**
- * DISCLAIMER: This is a temporary fix that corrects the indentation
- * spaces of list items. This workaround originates in the usage of
- * the Static Site Editor to edit the Handbook. The Handbook uses a
- * Markdown parser called Kramdown interprets lines indented
- * with two spaces as content within a list. For example:
- *
- * 1. ordered list
- * - nested unordered list
- *
- * The Static Site Editor uses a different Markdown parser based on the
- * CommonMark specification (official Markdown spec) called ToastMark.
- * When the SSE encounters a nested list with only two spaces, it flattens
- * the list:
- *
- * 1. ordered list
- * - nested unordered list
- *
- * This function attempts to correct this problem before the content is loaded
- * by Toast UI.
- */
-const correctNestedContentIndenation = (source) => {
- const lines = source.split('\n');
- let topLevelOrderedListDetected = false;
-
- return lines
- .reduce((result, line) => {
- if (topLevelOrderedListDetected && nestedLineRegexp.test(line)) {
- return [...result, line.replace(nestedLineRegexp, repeat(' ', 4))];
- }
-
- topLevelOrderedListDetected = topLevelOrderedRegexp.test(line);
- return [...result, line];
- }, [])
- .join('\n');
-};
-
-const removeOrphanedBrTags = (source) => {
- /* Until the underlying Squire editor of Toast UI Editor resolves duplicate `<br>` tags, this
- `replace` solution will clear out orphaned `<br>` tags that it generates. Additionally,
- it cleans up orphaned `<br>` tags in the source markdown document that should be new lines.
- https://gitlab.com/gitlab-org/gitlab/-/issues/227602#note_380765330
- */
- return source.replace(/\n^<br>$/gm, '');
-};
-
-const format = (source) => {
- return correctNestedContentIndenation(removeOrphanedBrTags(source));
-};
-
-export default format;
diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js
deleted file mode 100644
index 6b897b42648..00000000000
--- a/app/assets/javascripts/static_site_editor/services/front_matterify.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import jsYaml from 'js-yaml';
-
-const NEW_LINE = '\n';
-
-const hasMatter = (firstThreeChars, fourthChar) => {
- const isYamlDelimiter = firstThreeChars === '---';
- const isFourthCharNewline = fourthChar === NEW_LINE;
- return isYamlDelimiter && isFourthCharNewline;
-};
-
-export const frontMatterify = (source) => {
- let index = 3;
- let offset;
- const delimiter = source.slice(0, index);
- const type = 'yaml';
- const NO_FRONTMATTER = {
- source,
- matter: null,
- hasMatter: false,
- spacing: null,
- content: source,
- delimiter: null,
- type: null,
- };
-
- if (!hasMatter(delimiter, source.charAt(index))) {
- return NO_FRONTMATTER;
- }
-
- offset = source.indexOf(delimiter, index);
-
- // Finds the end delimiter that starts at a new line
- while (offset !== -1 && source.charAt(offset - 1) !== NEW_LINE) {
- index = offset + delimiter.length;
- offset = source.indexOf(delimiter, index);
- }
-
- if (offset === -1) {
- return NO_FRONTMATTER;
- }
-
- const matterStr = source.slice(index, offset);
- const matter = jsYaml.safeLoad(matterStr);
-
- let content = source.slice(offset + delimiter.length);
- let spacing = '';
- let idx = 0;
- while (content.charAt(idx).match(/(\s|\n)/)) {
- spacing += content.charAt(idx);
- idx += 1;
- }
- content = content.replace(spacing, '');
-
- return {
- source,
- matter,
- hasMatter: true,
- spacing,
- content,
- delimiter,
- type,
- };
-};
-
-export const stringify = ({ matter, spacing, content, delimiter }, newMatter) => {
- const matterObj = newMatter || matter;
-
- if (!matterObj) {
- return content;
- }
-
- const header = `${delimiter}${NEW_LINE}${jsYaml.safeDump(matterObj)}${delimiter}`;
- const body = `${spacing}${content}`;
- return `${header}${body}`;
-};
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
deleted file mode 100644
index cbf03a41ce2..00000000000
--- a/app/assets/javascripts/static_site_editor/services/generate_branch_name.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { BRANCH_SUFFIX_COUNT } from '../constants';
-
-const generateBranchSuffix = () => `${Date.now()}`.substr(BRANCH_SUFFIX_COUNT);
-
-const generateBranchName = (username, targetBranch) =>
- `${username}-${targetBranch}-patch-${generateBranchSuffix()}`;
-
-export default generateBranchName;
diff --git a/app/assets/javascripts/static_site_editor/services/image_service.js b/app/assets/javascripts/static_site_editor/services/image_service.js
deleted file mode 100644
index a9b85057e3d..00000000000
--- a/app/assets/javascripts/static_site_editor/services/image_service.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const getBinary = (file) => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => resolve(reader.result.split(',')[1]);
- reader.onerror = (error) => reject(error);
- });
-};
diff --git a/app/assets/javascripts/static_site_editor/services/load_source_content.js b/app/assets/javascripts/static_site_editor/services/load_source_content.js
deleted file mode 100644
index fcf69efafd8..00000000000
--- a/app/assets/javascripts/static_site_editor/services/load_source_content.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import Api from '~/api';
-
-const extractTitle = (content) => {
- const matches = content.match(/title: (.+)\n/i);
-
- return matches ? Array.from(matches)[1] : '';
-};
-
-const loadSourceContent = ({ projectId, sourcePath }) =>
- Api.getRawFile(projectId, sourcePath).then(({ data }) => ({
- title: extractTitle(data),
- content: data,
- }));
-
-export default loadSourceContent;
diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js
deleted file mode 100644
index d7499d75a21..00000000000
--- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import { frontMatterify, stringify } from './front_matterify';
-
-const parseSourceFile = (raw) => {
- let editable;
-
- const syncContent = (newVal, isBody) => {
- if (isBody) {
- editable.content = newVal;
- } else {
- try {
- editable = frontMatterify(newVal);
- editable.isMatterValid = true;
- } catch (e) {
- editable.isMatterValid = false;
- }
- }
- };
-
- const content = (isBody = false) => (isBody ? editable.content : stringify(editable));
-
- const matter = () => editable.matter;
-
- const syncMatter = (settings) => {
- editable.matter = settings;
- };
-
- const isModified = () => stringify(editable) !== raw;
-
- const hasMatter = () => editable.hasMatter;
-
- const isMatterValid = () => editable.isMatterValid;
-
- syncContent(raw);
-
- return {
- matter,
- isMatterValid,
- syncMatter,
- content,
- syncContent,
- isModified,
- hasMatter,
- };
-};
-
-export default parseSourceFile;
diff --git a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
deleted file mode 100644
index b5651e7163e..00000000000
--- a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import { isAbsolute, getBaseURL, joinPaths } from '~/lib/utils/url_utility';
-
-const canRender = ({ type }) => type === 'image';
-
-let metadata;
-
-const getCachedContent = (basePath) => metadata.imageRepository.get(basePath);
-
-const isRelativeToCurrentDirectory = (basePath) => !basePath.startsWith('/');
-
-const extractSourceDirectory = (url) => {
- const sourceDir = /^(.+)\/([^/]+)$/.exec(url); // Extracts the base path and fileName from an image path
- return sourceDir || [null, null, url]; // If no source directory was extracted it means only a fileName was specified (e.g. url='file.png')
-};
-
-const parseCurrentDirectory = (basePath) => {
- const baseUrl = decodeURIComponent(metadata.baseUrl);
- const sourceDirectory = extractSourceDirectory(baseUrl)[1];
- const currentDirectory = sourceDirectory.split(`/-/sse/${metadata.branch}`)[1];
-
- return joinPaths(currentDirectory, basePath);
-};
-
-// For more context around this logic, please see the following comment:
-// https://gitlab.com/gitlab-org/gitlab/-/issues/241166#note_409413500
-const generateSourceDirectory = (basePath) => {
- let sourceDir = '';
- let defaultSourceDir = '';
-
- if (!basePath || isRelativeToCurrentDirectory(basePath)) {
- return parseCurrentDirectory(basePath);
- }
-
- if (!metadata.mounts.length) {
- return basePath;
- }
-
- metadata.mounts.forEach(({ source, target }) => {
- const hasTarget = target !== '';
-
- if (hasTarget && basePath.includes(target)) {
- sourceDir = source;
- } else if (!hasTarget) {
- defaultSourceDir = joinPaths(source, basePath);
- }
- });
-
- return sourceDir || defaultSourceDir;
-};
-
-const resolveFullPath = (originalSrc, cachedContent) => {
- if (cachedContent) {
- return `data:image;base64,${cachedContent}`;
- }
-
- if (isAbsolute(originalSrc)) {
- return originalSrc;
- }
-
- const sourceDirectory = extractSourceDirectory(originalSrc);
- const [, basePath, fileName] = sourceDirectory;
- const sourceDir = generateSourceDirectory(basePath);
-
- return joinPaths(getBaseURL(), metadata.project, '/-/raw/', metadata.branch, sourceDir, fileName);
-};
-
-const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => {
- skipChildren();
-
- const cachedContent = getCachedContent(originalSrc);
-
- return {
- type: 'openTag',
- tagName: 'img',
- selfClose: true,
- attributes: {
- 'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '',
- src: resolveFullPath(originalSrc, cachedContent),
- alt: firstChild.literal,
- },
- };
-};
-
-const build = (mounts = [], project, branch, baseUrl, imageRepository) => {
- metadata = { mounts, project, branch, baseUrl, imageRepository };
- return { canRender, render };
-};
-
-export default { build };
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
deleted file mode 100644
index 99534413d92..00000000000
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import Api from '~/api';
-import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
-import generateBranchName from '~/static_site_editor/services/generate_branch_name';
-import Tracking from '~/tracking';
-
-import {
- SUBMIT_CHANGES_BRANCH_ERROR,
- SUBMIT_CHANGES_COMMIT_ERROR,
- SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
- TRACKING_ACTION_CREATE_COMMIT,
- TRACKING_ACTION_CREATE_MERGE_REQUEST,
- SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT,
- SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST,
- DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE,
- DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION,
-} from '../constants';
-
-const createBranch = (projectId, branch, targetBranch) =>
- Api.createBranch(projectId, {
- ref: targetBranch,
- branch,
- }).catch(() => {
- throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
- });
-
-const createImageActions = (images, markdown) => {
- const actions = [];
-
- if (!markdown) {
- return actions;
- }
-
- images.forEach((imageContent, filePath) => {
- const imageExistsInMarkdown = (path) => new RegExp(`!\\[([^[\\]\\n]*)\\](\\(${path})\\)`); // matches the image markdown syntax: ![<any-string-except-newline>](<path>)
-
- if (imageExistsInMarkdown(filePath).test(markdown)) {
- actions.push(
- convertObjectPropsToSnakeCase({
- encoding: 'base64',
- action: 'create',
- content: imageContent,
- filePath,
- }),
- );
- }
- });
-
- return actions;
-};
-
-const createUpdateSourceFileAction = (sourcePath, content) => [
- convertObjectPropsToSnakeCase({
- action: 'update',
- filePath: sourcePath,
- content,
- }),
-];
-
-const commit = (projectId, message, branch, actions) => {
- Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
- Api.trackRedisCounterEvent(SERVICE_PING_TRACKING_ACTION_CREATE_COMMIT);
-
- return Api.commitMultiple(
- projectId,
- convertObjectPropsToSnakeCase({
- branch,
- commitMessage: message,
- actions,
- }),
- ).catch(() => {
- throw new Error(SUBMIT_CHANGES_COMMIT_ERROR);
- });
-};
-
-const createMergeRequest = (projectId, title, description, sourceBranch, targetBranch) => {
- Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST);
- Api.trackRedisCounterEvent(SERVICE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST);
-
- return Api.createProjectMergeRequest(
- projectId,
- convertObjectPropsToSnakeCase({
- title,
- description,
- sourceBranch,
- targetBranch,
- }),
- ).catch(() => {
- throw new Error(SUBMIT_CHANGES_MERGE_REQUEST_ERROR);
- });
-};
-
-const submitContentChanges = ({
- username,
- projectId,
- sourcePath,
- targetBranch,
- content,
- images,
- mergeRequestMeta,
- formattedMarkdown,
-}) => {
- const branch = generateBranchName(username, targetBranch);
- const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta;
- const meta = {};
-
- return createBranch(projectId, branch, targetBranch)
- .then(({ data: { web_url: url } }) => {
- const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`;
-
- Object.assign(meta, { branch: { label: branch, url } });
-
- return formattedMarkdown
- ? commit(
- projectId,
- message,
- branch,
- createUpdateSourceFileAction(sourcePath, formattedMarkdown),
- )
- : meta;
- })
- .then(() =>
- commit(projectId, mergeRequestTitle, branch, [
- ...createUpdateSourceFileAction(sourcePath, content),
- ...createImageActions(images, content),
- ]),
- )
- .then(({ data: { short_id: label, web_url: url } }) => {
- Object.assign(meta, { commit: { label, url } });
-
- return createMergeRequest(
- projectId,
- mergeRequestTitle,
- mergeRequestDescription,
- branch,
- targetBranch,
- );
- })
- .then(({ data: { iid: label, web_url: url } }) => {
- Object.assign(meta, { mergeRequest: { label: label.toString(), url } });
-
- return meta;
- });
-};
-
-export default submitContentChanges;
diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js
deleted file mode 100644
index 47fc36c3d18..00000000000
--- a/app/assets/javascripts/static_site_editor/services/templater.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * The purpose of this file is to modify Markdown source such that templated code (embedded ruby currently) can be temporarily wrapped and unwrapped in codeblocks:
- * 1. `wrap()`: temporarily wrap in codeblocks (useful for a WYSIWYG editing experience)
- * 2. `unwrap()`: undo the temporarily wrapped codeblocks (useful for Markdown editing experience and saving edits)
- *
- * Without this `templater`, the templated code is otherwise interpreted as Markdown content resulting in loss of spacing, indentation, escape characters, etc.
- *
- */
-
-const ticks = '```';
-const marker = 'sse';
-const wrapPrefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26
-const wrapPostfix = `\n${ticks}`;
-const markPrefix = `${marker}-${Date.now()}`;
-
-const reHelpers = {
- template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
- openTag: '<(?!figure|iframe)[a-zA-Z]+.*?>',
- closeTag: '</.+>',
-};
-const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');
-const rePreexistingCodeBlocks = new RegExp(`(^${ticks}.*\\n(.|\\s)+?${ticks}$)`, 'gm');
-const reHtmlMarkup = new RegExp(
- `^((${reHelpers.openTag}){1}(${reHelpers.template})*(${reHelpers.closeTag}){1})$`,
- 'gm',
-);
-const reEmbeddedRubyBlock = new RegExp(`(^<%(${reHelpers.template})+%>$)`, 'gm');
-const reEmbeddedRubyInline = new RegExp(`(^.*[<|&lt;]%(${reHelpers.template})+$)`, 'gm');
-
-const patternGroups = {
- ignore: [rePreexistingCodeBlocks],
- // Order is intentional (general to specific) where HTML markup is marked first, then ERB blocks, then inline ERB
- // Order in combo with the `mark()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example)
- allow: [reHtmlMarkup, reEmbeddedRubyBlock, reEmbeddedRubyInline],
-};
-
-const mark = (source, groups) => {
- let text = source;
- let id = 0;
- const hash = {};
-
- Object.entries(groups).forEach(([groupKey, group]) => {
- group.forEach((pattern) => {
- const matches = text.match(pattern);
- if (matches) {
- matches.forEach((match) => {
- const key = `${markPrefix}-${groupKey}-${id}`;
- text = text.replace(match, key);
- hash[key] = match;
- id += 1;
- });
- }
- });
- });
-
- return { text, hash };
-};
-
-const unmark = (text, hash) => {
- let source = text;
-
- Object.entries(hash).forEach(([key, value]) => {
- const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`;
- source = source.replace(key, newVal);
- });
-
- return source;
-};
-
-const unwrap = (source) => {
- let text = source;
- const matches = text.match(reTemplated);
-
- if (matches) {
- matches.forEach((match) => {
- const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, '');
- text = text.replace(match, initial);
- });
- }
-
- return text;
-};
-
-const wrap = (source) => {
- const { text, hash } = mark(unwrap(source), patternGroups);
- return unmark(text, hash);
-};
-
-export default { wrap, unwrap };
diff --git a/app/assets/javascripts/tags/components/delete_tag_modal.vue b/app/assets/javascripts/tags/components/delete_tag_modal.vue
new file mode 100644
index 00000000000..e3b666ec968
--- /dev/null
+++ b/app/assets/javascripts/tags/components/delete_tag_modal.vue
@@ -0,0 +1,192 @@
+<script>
+import { GlButton, GlFormInput, GlModal, GlSprintf, GlAlert } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import csrf from '~/lib/utils/csrf';
+import { sprintf, s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ csrf,
+ components: {
+ GlModal,
+ GlButton,
+ GlFormInput,
+ GlSprintf,
+ GlAlert,
+ },
+ data() {
+ return {
+ isProtected: false,
+ tagName: '',
+ path: '',
+ enteredTagName: '',
+ modalId: 'delete-tag-modal',
+ };
+ },
+ computed: {
+ title() {
+ const modalTitle = this.isProtected
+ ? this.$options.i18n.modalTitleProtectedTag
+ : this.$options.i18n.modalTitle;
+
+ return sprintf(modalTitle, { tagName: this.tagName });
+ },
+ message() {
+ const modalMessage = this.isProtected
+ ? this.$options.i18n.modalMessageProtectedTag
+ : this.$options.i18n.modalMessage;
+
+ return sprintf(modalMessage, { tagName: this.tagName });
+ },
+ undoneWarning() {
+ return sprintf(this.$options.i18n.undoneWarning, {
+ buttonText: this.buttonText,
+ });
+ },
+ confirmationText() {
+ return sprintf(this.$options.i18n.confirmationText, {
+ tagName: this.tagName,
+ });
+ },
+ buttonText() {
+ return this.isProtected
+ ? this.$options.i18n.deleteButtonTextProtectedTag
+ : this.$options.i18n.deleteButtonText;
+ },
+ tagNameConfirmed() {
+ return this.enteredTagName === this.tagName;
+ },
+ deleteButtonDisabled() {
+ return this.isProtected && !this.tagNameConfirmed;
+ },
+ },
+ mounted() {
+ eventHub.$on('openModal', this.openModal);
+ for (const btn of document.querySelectorAll('.js-delete-tag-button')) {
+ btn.addEventListener('click', this.deleteTagBtnListener.bind(this, btn));
+ }
+ },
+ destroyed() {
+ eventHub.$off('openModal', this.openModal);
+ for (const btn of document.querySelectorAll('.js-delete-tag-button')) {
+ btn.removeEventListener('click', this.deleteTagBtnListener.bind(this, btn));
+ }
+ },
+ methods: {
+ deleteTagBtnListener(btn) {
+ return this.openModal({
+ ...btn.dataset,
+ isProtected: parseBoolean(btn.dataset.isProtected),
+ });
+ },
+ openModal({ isProtected, tagName, path }) {
+ this.enteredTagName = '';
+ this.isProtected = isProtected;
+ this.tagName = tagName;
+ this.path = path;
+
+ this.$refs.modal.show();
+ },
+ submitForm() {
+ this.$refs.form.submit();
+ },
+ closeModal() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n: {
+ modalTitle: s__('TagsPage|Delete tag. Are you ABSOLUTELY SURE?'),
+ modalTitleProtectedTag: s__('TagsPage|Delete protected tag. Are you ABSOLUTELY SURE?'),
+ modalMessage: s__(
+ "TagsPage|You're about to permanently delete the tag %{strongStart}%{tagName}.%{strongEnd}",
+ ),
+ modalMessageProtectedTag: s__(
+ "TagsPage|You're about to permanently delete the protected tag %{strongStart}%{tagName}.%{strongEnd}",
+ ),
+ undoneWarning: s__(
+ 'TagsPage|After you confirm and select %{strongStart}%{buttonText},%{strongEnd} you cannot recover this tag.',
+ ),
+ cancelButtonText: s__('TagsPage|Cancel, keep tag'),
+ confirmationText: s__(
+ 'TagsPage|Deleting the %{strongStart}%{tagName}%{strongEnd} tag cannot be undone. Are you sure?',
+ ),
+ confirmationTextProtectedTag: s__('TagsPage|Please type the following to confirm:'),
+ deleteButtonText: s__('TagsPage|Yes, delete tag'),
+ deleteButtonTextProtectedTag: s__('TagsPage|Yes, delete protected tag'),
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" size="sm" :modal-id="modalId" :title="title">
+ <gl-alert class="gl-mb-5" variant="danger" :dismissible="false">
+ <div data-testid="modal-message">
+ <gl-sprintf :message="message">
+ <template #strong="{ content }">
+ <strong> {{ content }} </strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ </gl-alert>
+
+ <form ref="form" :action="path" method="post">
+ <div v-if="isProtected" class="gl-mt-4">
+ <p>
+ <gl-sprintf :message="undoneWarning">
+ <template #strong="{ content }">
+ <strong> {{ content }} </strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-sprintf :message="$options.i18n.confirmationTextProtectedTag">
+ <template #strong="{ content }">
+ {{ content }}
+ </template>
+ </gl-sprintf>
+ <code class="gl-white-space-pre-wrap"> {{ tagName }} </code>
+ <gl-form-input
+ v-model="enteredTagName"
+ name="delete_tag_input"
+ type="text"
+ class="gl-mt-4"
+ aria-labelledby="input-label"
+ autocomplete="off"
+ />
+ </p>
+ </div>
+ <div v-else>
+ <p class="gl-mt-4">
+ <gl-sprintf :message="confirmationText">
+ <template #strong="{ content }">
+ <strong>
+ {{ content }}
+ </strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+
+ <input ref="method" type="hidden" name="_method" value="delete" />
+ <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
+ </form>
+
+ <template #modal-footer>
+ <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0">
+ <gl-button data-testid="delete-tag-cancel-button" @click="closeModal">
+ {{ $options.i18n.cancelButtonText }}
+ </gl-button>
+ <div class="gl-mr-3"></div>
+ <gl-button
+ ref="deleteTagButton"
+ :disabled="deleteButtonDisabled"
+ variant="danger"
+ data-qa-selector="delete_tag_confirmation_button"
+ data-testid="delete-tag-confirmation-button"
+ @click="submitForm"
+ >{{ buttonText }}</gl-button
+ >
+ </div>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/tags/event_hub.js b/app/assets/javascripts/tags/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/tags/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/tags/init_delete_tag_modal.js b/app/assets/javascripts/tags/init_delete_tag_modal.js
new file mode 100644
index 00000000000..03e3d393c14
--- /dev/null
+++ b/app/assets/javascripts/tags/init_delete_tag_modal.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import DeleteTagModal from '~/tags/components/delete_tag_modal.vue';
+
+export default function initDeleteTagModal() {
+ const el = document.querySelector('.js-delete-tag-modal');
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ render(createComponent) {
+ return createComponent(DeleteTagModal);
+ },
+ });
+}
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
index efc2991f40f..b19f92aaeb4 100644
--- a/app/assets/javascripts/terraform/components/states_table.vue
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -1,7 +1,16 @@
<script>
-import { GlAlert, GlBadge, GlLink, GlLoadingIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlBadge,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTable,
+ GlTooltip,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -20,6 +29,9 @@ export default {
StateActions,
TimeAgoTooltip,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
mixins: [timeagoMixin],
props: {
states: {
@@ -68,6 +80,8 @@ export default {
locked: s__('Terraform|Locked'),
lockedByUser: s__('Terraform|Locked by %{user} %{timeAgo}'),
lockingState: s__('Terraform|Locking state'),
+ deleting: s__('Terraform|Removed'),
+ deletionInProgress: s__('Terraform|Deletion in progress'),
name: s__('Terraform|Name'),
pipeline: s__('Terraform|Pipeline'),
removing: s__('Terraform|Removing'),
@@ -85,6 +99,12 @@ export default {
lockedByUserName(item) {
return item.lockedByUser?.name || this.$options.i18n.unknownUser;
},
+ lockedByUserText(item) {
+ return sprintf(this.$options.i18n.lockedByUser, {
+ user: this.lockedByUserName(item),
+ timeAgo: this.timeFormatted(item.lockedAt),
+ });
+ },
pipelineDetailedStatus(item) {
return item.latestVersion?.job?.detailedStatus;
},
@@ -142,29 +162,27 @@ export default {
</div>
<div
+ v-else-if="item.deletedAt"
+ v-gl-tooltip.right
+ class="gl-mx-3"
+ :title="$options.i18n.deletionInProgress"
+ :data-testid="`state-badge-${item.name}`"
+ >
+ <gl-badge icon="remove">
+ {{ $options.i18n.deleting }}
+ </gl-badge>
+ </div>
+
+ <div
v-else-if="item.lockedAt"
- :id="`terraformLockedBadgeContainer${item.name}`"
+ v-gl-tooltip.right
class="gl-mx-3"
+ :title="lockedByUserText(item)"
+ :data-testid="`state-badge-${item.name}`"
>
- <gl-badge :id="`terraformLockedBadge${item.name}`" icon="lock">
+ <gl-badge icon="lock">
{{ $options.i18n.locked }}
</gl-badge>
-
- <gl-tooltip
- :container="`terraformLockedBadgeContainer${item.name}`"
- :target="`terraformLockedBadge${item.name}`"
- placement="right"
- >
- <gl-sprintf :message="$options.i18n.lockedByUser">
- <template #user>
- {{ lockedByUserName(item) }}
- </template>
-
- <template #timeAgo>
- {{ timeFormatted(item.lockedAt) }}
- </template>
- </gl-sprintf>
- </gl-tooltip>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue
index 1970d6d7949..773ecf1d5d5 100644
--- a/app/assets/javascripts/terraform/components/states_table_actions.vue
+++ b/app/assets/javascripts/terraform/components/states_table_actions.vue
@@ -33,6 +33,7 @@ export default {
directives: {
GlModalDirective,
},
+ inject: ['projectPath'],
props: {
state: {
required: true,
@@ -149,7 +150,14 @@ export default {
variables: {
stateID: this.state.id,
},
- refetchQueries: () => [{ query: getStatesQuery }],
+ refetchQueries: () => [
+ {
+ query: getStatesQuery,
+ variables: {
+ projectPath: this.projectPath,
+ },
+ },
+ ],
awaitRefetchQueries: true,
notifyOnNetworkStatusChange: true,
})
diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
index 7eb79120fb8..f098b447d10 100644
--- a/app/assets/javascripts/terraform/components/terraform_list.vue
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -31,15 +31,12 @@ export default {
GlTabs,
StatesTable,
},
+ inject: ['projectPath'],
props: {
emptyStateImage: {
required: true,
type: String,
},
- projectPath: {
- required: true,
- type: String,
- },
terraformAdmin: {
required: false,
type: Boolean,
@@ -105,7 +102,7 @@ export default {
</p>
</template>
- <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<div v-else-if="statesList">
<div v-if="statesCount">
diff --git a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
index fb823336411..ee3d5f474e2 100644
--- a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
+++ b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql
@@ -11,6 +11,7 @@ fragment State on TerraformState {
name
lockedAt
updatedAt
+ deletedAt
lockedByUser {
...User
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
index 34261f3c4db..2d70ccfac4d 100644
--- a/app/assets/javascripts/terraform/index.js
+++ b/app/assets/javascripts/terraform/index.js
@@ -30,6 +30,7 @@ export default () => {
el,
apolloProvider: new VueApollo({ defaultClient }),
provide: {
+ projectPath,
accessTokensPath,
terraformApiUrl,
username,
@@ -38,8 +39,7 @@ export default () => {
return createElement(TerraformList, {
props: {
emptyStateImage,
- projectPath,
- terraformAdmin: el.hasAttribute('data-terraform-admin'),
+ terraformAdmin: Object.prototype.hasOwnProperty.call(el.dataset, 'terraformAdmin'),
},
});
},
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue
index e739ec37739..de8cd856bf7 100644
--- a/app/assets/javascripts/token_access/components/token_access.vue
+++ b/app/assets/javascripts/token_access/components/token_access.vue
@@ -167,7 +167,7 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
+ <gl-loading-icon v-if="$apollo.loading" size="lg" class="gl-mt-5" />
<template v-else>
<gl-toggle
v-model="jobTokenScopeEnabled"
diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue
index b6c9330c754..82ef3371d91 100644
--- a/app/assets/javascripts/token_access/components/token_projects_table.vue
+++ b/app/assets/javascripts/token_access/components/token_projects_table.vue
@@ -2,10 +2,6 @@
import { GlButton, GlTable } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-const defaultTableClasses = {
- thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!',
-};
-
export default {
i18n: {
emptyText: s__('CI/CD|No projects have been added to the scope'),
@@ -15,14 +11,12 @@ export default {
key: 'project',
label: __('Projects that can be accessed'),
tdClass: 'gl-p-5!',
- ...defaultTableClasses,
columnClass: 'gl-w-85p',
},
{
key: 'actions',
label: '',
tdClass: 'gl-p-5! gl-text-right',
- ...defaultTableClasses,
columnClass: 'gl-w-15p',
},
],
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 438ae2bc1bc..a3615eab26f 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -1,6 +1,8 @@
import Vue from 'vue';
+import { debounce } from 'lodash';
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
+import { USER_POPOVER_DELAY } from './vue_shared/components/user_popover/constants';
const removeTitle = (el) => {
// Removing titles so its not showing tooltips also
@@ -59,87 +61,78 @@ const populateUserInfo = (user) => {
);
};
-const initializedPopovers = new Map();
-let domObservedForChanges = false;
+function createPopover(el, user) {
+ removeTitle(el);
+ const preloadedUserInfo = getPreloadedUserInfo(el.dataset);
-const addPopoversToModifiedTree = new MutationObserver(() => {
- const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member');
+ Object.assign(user, preloadedUserInfo);
- if (userLinks) {
- addPopovers(userLinks); /* eslint-disable-line no-use-before-define */
+ if (preloadedUserInfo.userId) {
+ populateUserInfo(user);
}
-});
+ const UserPopoverComponent = Vue.extend(UserPopover);
+ return new UserPopoverComponent({
+ propsData: {
+ target: el,
+ user,
+ show: true,
+ placement: el.dataset.placement || 'top',
+ },
+ });
+}
-function observeBody() {
- if (!domObservedForChanges) {
- addPopoversToModifiedTree.observe(document.body, {
- subtree: true,
- childList: true,
- });
+function launchPopover(el, mountPopover) {
+ if (el.user) return;
- domObservedForChanges = true;
- }
+ const emptyUser = {
+ location: null,
+ bio: null,
+ workInformation: null,
+ status: null,
+ isFollowed: false,
+ loaded: false,
+ };
+ el.user = emptyUser;
+ el.addEventListener(
+ 'mouseleave',
+ ({ target }) => {
+ target.removeAttribute('aria-describedby');
+ },
+ { once: true },
+ );
+ const popoverInstance = createPopover(el, emptyUser);
+
+ const { userId } = el.dataset;
+
+ popoverInstance.$on('follow', () => {
+ UsersCache.updateById(userId, { is_followed: true });
+ el.user.isFollowed = true;
+ });
+
+ popoverInstance.$on('unfollow', () => {
+ UsersCache.updateById(userId, { is_followed: false });
+ el.user.isFollowed = false;
+ });
+
+ mountPopover(popoverInstance);
}
-export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) {
- const userLinks = Array.from(elements);
- const UserPopoverComponent = Vue.extend(UserPopover);
+const userLinkSelector = 'a.js-user-link, a.gfm-project_member';
- observeBody();
+const getUserLinkNode = (node) => node.closest(userLinkSelector);
- return userLinks
- .filter(({ dataset }) => dataset.user || dataset.userId)
- .map((el) => {
- if (initializedPopovers.has(el)) {
- return initializedPopovers.get(el);
- }
+const lazyLaunchPopover = debounce((mountPopover, event) => {
+ const userLink = getUserLinkNode(event.target);
+ if (userLink) {
+ launchPopover(userLink, mountPopover);
+ }
+}, USER_POPOVER_DELAY);
- const user = {
- location: null,
- bio: null,
- workInformation: null,
- status: null,
- isFollowed: false,
- loaded: false,
- };
- const renderedPopover = new UserPopoverComponent({
- propsData: {
- target: el,
- user,
- placement: el.dataset.placement || 'top',
- },
- });
-
- const { userId } = el.dataset;
-
- renderedPopover.$on('follow', () => {
- UsersCache.updateById(userId, { is_followed: true });
- user.isFollowed = true;
- });
-
- renderedPopover.$on('unfollow', () => {
- UsersCache.updateById(userId, { is_followed: false });
- user.isFollowed = false;
- });
-
- initializedPopovers.set(el, renderedPopover);
-
- renderedPopover.$mount();
-
- el.addEventListener('mouseenter', ({ target }) => {
- removeTitle(target);
- const preloadedUserInfo = getPreloadedUserInfo(target.dataset);
-
- Object.assign(user, preloadedUserInfo);
-
- if (preloadedUserInfo.userId) {
- populateUserInfo(user);
- }
- });
- el.addEventListener('mouseleave', ({ target }) => {
- target.removeAttribute('aria-describedby');
- });
-
- return renderedPopover;
- });
+let hasAddedLazyPopovers = false;
+
+export default function addPopovers(mountPopover = (instance) => instance.$mount()) {
+ if (!hasAddedLazyPopovers) {
+ document.addEventListener('mouseover', (event) => lazyLaunchPopover(mountPopover, event));
+ hasAddedLazyPopovers = true;
+ }
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
index e7d5e4086bc..4163d195e0f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -11,9 +11,11 @@ import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
import MrWidgetContainer from '../mr_widget_container.vue';
import MrWidgetIcon from '../mr_widget_icon.vue';
+import { INVALID_RULES_DOCS_PATH } from '../../constants';
import ApprovalsSummary from './approvals_summary.vue';
import ApprovalsSummaryOptional from './approvals_summary_optional.vue';
import { FETCH_LOADING, FETCH_ERROR, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages';
+import { humanizeInvalidApproversRules } from './humanized_text';
export default {
name: 'MRWidgetApprovals',
@@ -23,6 +25,8 @@ export default {
ApprovalsSummary,
ApprovalsSummaryOptional,
GlButton,
+ GlSprintf,
+ GlLink,
},
mixins: [approvalsMixin, glFeatureFlagsMixin()],
props: {
@@ -78,6 +82,15 @@ export default {
approvals() {
return this.mr.approvals || {};
},
+ invalidRules() {
+ return this.approvals.invalid_approvers_rules || [];
+ },
+ hasInvalidRules() {
+ return this.approvals.merge_request_approvers_available && this.invalidRules.length;
+ },
+ invalidRulesText() {
+ return humanizeInvalidApproversRules(this.invalidRules);
+ },
approvedBy() {
return this.approvals.approved_by ? this.approvals.approved_by.map((x) => x.user) : [];
},
@@ -104,20 +117,24 @@ export default {
return {
text: this.approvalText,
category: this.isApproved ? 'secondary' : 'primary',
- variant: 'info',
+ variant: 'confirm',
action: () => this.approve(),
};
} else if (this.showUnapprove) {
return {
text: s__('mrWidget|Revoke approval'),
- variant: 'warning',
- category: 'secondary',
+ variant: 'default',
action: () => this.unapprove(),
};
}
return null;
},
+ pluralizedRuleText() {
+ return this.invalidRules.length > 1
+ ? this.$options.i18n.invalidRulesPlural
+ : this.$options.i18n.invalidRuleSingular;
+ },
},
created() {
this.refreshApprovals()
@@ -194,6 +211,16 @@ export default {
},
},
FETCH_LOADING,
+ linkToInvalidRules: INVALID_RULES_DOCS_PATH,
+ i18n: {
+ invalidRuleSingular: s__(
+ 'mrWidget|Approval rule %{rules} is invalid. GitLab has approved this rule automatically to unblock the merge request. %{link}',
+ ),
+ invalidRulesPlural: s__(
+ 'mrWidget|Approval rules %{rules} are invalid. GitLab has approved these rules automatically to unblock the merge request. %{link}',
+ ),
+ learnMore: __('Learn more.'),
+ },
};
</script>
<template>
@@ -202,29 +229,45 @@ export default {
<mr-widget-icon name="approval" />
<div v-if="fetchingApprovals">{{ $options.FETCH_LOADING }}</div>
<template v-else>
- <gl-button
- v-if="action"
- :variant="action.variant"
- :category="action.category"
- :loading="isApproving"
- class="mr-3"
- data-qa-selector="approve_button"
- @click="action.action"
- >
- {{ action.text }}
- </gl-button>
- <approvals-summary-optional
- v-if="isOptional"
- :can-approve="hasAction"
- :help-path="mr.approvalsHelpPath"
- />
- <approvals-summary
- v-else
- :approved="isApproved"
- :approvals-left="approvals.approvals_left || 0"
- :rules-left="approvals.approvalRuleNamesLeft"
- :approvers="approvedBy"
- />
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <gl-button
+ v-if="action"
+ :variant="action.variant"
+ :category="action.category"
+ :loading="isApproving"
+ class="gl-mr-5"
+ data-qa-selector="approve_button"
+ @click="action.action"
+ >
+ {{ action.text }}
+ </gl-button>
+ <approvals-summary-optional
+ v-if="isOptional"
+ :can-approve="hasAction"
+ :help-path="mr.approvalsHelpPath"
+ />
+ <approvals-summary
+ v-else
+ :approved="isApproved"
+ :approvals-left="approvals.approvals_left || 0"
+ :rules-left="approvals.approvalRuleNamesLeft"
+ :approvers="approvedBy"
+ />
+ </div>
+ <div v-if="hasInvalidRules" class="gl-text-gray-400 gl-mt-2" data-testid="invalid-rules">
+ <gl-sprintf :message="pluralizedRuleText">
+ <template #rules>
+ {{ invalidRulesText }}
+ </template>
+ <template #link>
+ <gl-link :href="$options.linkToInvalidRules" target="_blank">
+ {{ $options.i18n.learnMore }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
<slot
:is-approving="isApproving"
:approve-with-auth="approveWithAuth"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index 0e31f97b9db..b1c4f7c5a7c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -98,10 +98,10 @@ export default {
<template>
<div data-qa-selector="approvals_summary_content">
- <strong>{{ approvalLeftMessage }}</strong>
+ <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span>
<template v-if="hasApprovers">
<span v-if="approvalLeftMessage">{{ message }}</span>
- <strong v-else>{{ message }}</strong>
+ <span v-else class="gl-font-weight-bold">{{ message }}</span>
<user-avatar-list
class="gl-display-inline-block gl-vertical-align-middle"
:img-size="24"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/humanized_text.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/humanized_text.js
new file mode 100644
index 00000000000..6689d070053
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/humanized_text.js
@@ -0,0 +1,23 @@
+import { __ } from '~/locale';
+
+const humanizeRules = (invalidRules) => {
+ if (invalidRules.length > 1) {
+ return invalidRules.reduce((rules, { name }, index) => {
+ if (index === invalidRules.length - 1) {
+ return `${rules}${__(' and ')}"${name}"`;
+ }
+ return rules ? `${rules}, "${name}"` : `"${name}"`;
+ }, '');
+ }
+ return `"${invalidRules[0].name}"`;
+};
+
+export const humanizeInvalidApproversRules = (invalidRules) => {
+ const ruleCount = invalidRules.length;
+
+ if (!ruleCount) {
+ return '';
+ }
+
+ return humanizeRules(invalidRules);
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
index d878a1fa2e0..655ceb5f700 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
@@ -26,6 +26,7 @@ export default {
},
methods: {
onClickAction(action) {
+ this.$emit('clickedAction', action);
if (action.onClick) {
action.onClick();
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
index 0bc17de638b..4ba620da00a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue
@@ -6,16 +6,16 @@ import {
GlTooltipDirective,
GlIntersectionObserver,
} from '@gitlab/ui';
-import { once } from 'lodash';
import * as Sentry from '@sentry/browser';
import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
-import api from '~/api';
import { sprintf, s__, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants';
import StatusIcon from './status_icon.vue';
import Actions from './actions.vue';
import ChildContent from './child_content.vue';
+import { createTelemetryHub } from './telemetry';
import { generateText } from './utils';
export const LOADING_STATES = {
@@ -26,6 +26,7 @@ export const LOADING_STATES = {
};
export default {
+ telemetry: true,
components: {
GlButton,
GlLoadingIcon,
@@ -49,6 +50,7 @@ export default {
showFade: false,
modalData: undefined,
modalName: undefined,
+ telemetry: null,
};
},
computed: {
@@ -131,50 +133,85 @@ export default {
}
},
},
+ created() {
+ if (this.$options.telemetry) {
+ this.telemetry = createTelemetryHub(this.$options.name);
+ }
+ },
mounted() {
this.loadCollapsedData();
+
+ this.telemetry?.viewed();
},
methods: {
- triggerRedisTracking: once(function triggerRedisTracking() {
- if (this.$options.expandEvent) {
- api.trackRedisHllUserEvent(this.$options.expandEvent);
- }
- }),
toggleCollapsed(e) {
if (this.isCollapsible && !e?.target?.closest('.btn:not(.btn-icon),a')) {
- this.isCollapsed = !this.isCollapsed;
+ if (this.isCollapsed) {
+ this.telemetry?.expanded({ type: this.statusIconName });
+ }
- this.triggerRedisTracking();
+ this.isCollapsed = !this.isCollapsed;
}
},
+ initExtensionMultiPolling() {
+ const allData = [];
+ const requests = this.fetchMultiData();
+
+ requests.forEach((request) => {
+ const poll = new Poll({
+ resource: {
+ fetchData: () => request(this),
+ },
+ method: 'fetchData',
+ successCallback: (response) => {
+ this.headerCheck(response, (data) => allData.push(data));
+
+ if (allData.length === requests.length) {
+ this.setCollapsedData(allData);
+ }
+ },
+ errorCallback: (e) => {
+ this.setCollapsedError(e);
+ },
+ });
+
+ poll.makeRequest();
+ });
+ },
initExtensionPolling() {
const poll = new Poll({
resource: {
- fetchData: () => this.fetchCollapsedData(this.$props),
+ fetchData: () => this.fetchCollapsedData(this),
},
method: 'fetchData',
- successCallback: ({ data }) => {
- if (Object.keys(data).length > 0) {
- poll.stop();
- this.setCollapsedData(data);
- }
+ successCallback: (response) => {
+ this.headerCheck(response, (data) => this.setCollapsedData(data));
},
errorCallback: (e) => {
- poll.stop();
-
this.setCollapsedError(e);
},
});
poll.makeRequest();
},
+ headerCheck(response, callback) {
+ const headers = normalizeHeaders(response.headers);
+
+ if (!headers['POLL-INTERVAL']) {
+ callback(response.data);
+ }
+ },
loadCollapsedData() {
this.loadingState = LOADING_STATES.collapsedLoading;
if (this.$options.enablePolling) {
- this.initExtensionPolling();
+ if (this.fetchMultiData) {
+ this.initExtensionMultiPolling();
+ } else {
+ this.initExtensionPolling();
+ }
} else {
- this.fetchCollapsedData(this.$props)
+ this.fetchCollapsedData(this)
.then((data) => {
this.setCollapsedData(data);
})
@@ -197,7 +234,7 @@ export default {
this.loadingState = LOADING_STATES.expandedLoading;
- this.fetchFullData(this.$props)
+ this.fetchFullData(this)
.then((data) => {
this.loadingState = null;
this.fullData = data.map((x, i) => ({ id: i, ...x }));
@@ -231,6 +268,11 @@ export default {
this.toggleCollapsed(e);
}
},
+ onClickedAction(action) {
+ if (action.fullReport) {
+ this.telemetry?.fullReportClicked();
+ }
+ },
generateText,
},
EXTENSION_ICON_CLASS,
@@ -268,6 +310,7 @@ export default {
<actions
:widget="$options.label || $options.name"
:tertiary-buttons="tertiaryActionsButtons"
+ @clickedAction="onClickedAction"
/>
<div
v-if="isCollapsible"
@@ -324,6 +367,7 @@ export default {
:widget-label="widgetLabel"
:modal-id="modalId"
:level="2"
+ @clickedAction="onClickedAction"
/>
</gl-intersection-observer>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
index 0ca4c92a5ae..38f83a61b30 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue
@@ -39,6 +39,9 @@ export default {
isArray(arr) {
return Array.isArray(arr);
},
+ onClickedAction(action) {
+ this.$emit('clickedAction', action);
+ },
generateText,
},
};
@@ -63,14 +66,14 @@ export default {
<div class="gl-w-full">
<div class="gl-display-flex gl-flex-nowrap">
<div class="gl-flex-wrap gl-display-flex gl-w-full">
- <div class="gl-mr-4 gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-center">
<p v-safe-html="generateText(data.text)" class="gl-m-0"></p>
</div>
- <div v-if="data.link">
+ <div v-if="data.link" class="gl-pr-2">
<gl-link :href="data.link.href">{{ data.link.text }}</gl-link>
</div>
- <div v-if="data.modal">
- <gl-link v-gl-modal="modalId" @click="data.modal.onClick">
+ <div v-if="data.modal" class="gl-pr-2">
+ <gl-link v-gl-modal="modalId" data-testid="modal-link" @click="data.modal.onClick">
{{ data.modal.text }}
</gl-link>
</div>
@@ -81,7 +84,12 @@ export default {
{{ data.badge.text }}
</gl-badge>
</div>
- <actions :widget="widgetLabel" :tertiary-buttons="data.actions" class="gl-ml-auto" />
+ <actions
+ :widget="widgetLabel"
+ :tertiary-buttons="data.actions"
+ class="gl-ml-auto gl-pl-3"
+ @clickedAction="onClickedAction"
+ />
</div>
<p
v-if="data.subtext"
@@ -101,6 +109,7 @@ export default {
:modal-id="modalId"
:level="3"
data-testid="child-content"
+ @clickedAction="onClickedAction"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
index b9dfd3bd41e..a58d524b9ed 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js
@@ -14,7 +14,7 @@ export default {
if (extensions.length === 0) return null;
return h(
- 'div',
+ 'section',
{
attrs: {
role: 'region',
@@ -34,13 +34,7 @@ export default {
{ ...extension },
{
props: {
- ...extension.props.reduce(
- (acc, key) => ({
- ...acc,
- [key]: this.mr[key],
- }),
- {},
- ),
+ mr: this.mr,
},
},
),
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
index 65273678fb9..f4fcf4c9571 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js
@@ -10,12 +10,27 @@ export const registerExtension = (extension) => {
registeredExtensions.extensions.push({
extends: ExtensionBase,
name: extension.name,
- props: extension.props,
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ telemetry: extension.telemetry,
i18n: extension.i18n,
expandEvent: extension.expandEvent,
enablePolling: extension.enablePolling,
modalComponent: extension.modalComponent,
computed: {
+ ...extension.props.reduce(
+ (acc, propKey) => ({
+ ...acc,
+ [propKey]() {
+ return this.mr[propKey];
+ },
+ }),
+ {},
+ ),
...Object.keys(extension.computed).reduce(
(acc, computedKey) => ({
...acc,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index 456a1f17aae..bb626c9adba 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -49,7 +49,7 @@ export default {
]"
class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
>
- <gl-loading-icon v-if="isLoading" size="lg" inline class="gl-display-block" />
+ <gl-loading-icon v-if="isLoading" size="sm" inline class="gl-display-block" />
<gl-icon
v-else
:name="$options.EXTENSION_ICON_NAMES[iconName]"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
new file mode 100644
index 00000000000..aec3a35f37c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -0,0 +1,207 @@
+import api from '~/api';
+import createEventHub from '~/helpers/event_hub_factory';
+import {
+ TELEMETRY_WIDGET_VIEWED,
+ TELEMETRY_WIDGET_EXPANDED,
+ TELEMETRY_WIDGET_FULL_REPORT_CLICKED,
+} from '../../constants';
+
+/*
+ * Additional events to send beyond the defaults for certain widget extensions
+ */
+const nonStandardEvents = {
+ codeQuality: {
+ uniqueUser: {
+ expand: ['i_testing_code_quality_widget_total'],
+ },
+ counter: {},
+ },
+ terraform: {
+ uniqueUser: {
+ expand: ['i_testing_terraform_widget_total'],
+ },
+ counter: {},
+ },
+ issues: {
+ uniqueUser: {
+ expand: ['i_testing_load_performance_widget_total'],
+ },
+ counter: {},
+ },
+ testReport: {
+ uniqueUser: {
+ expand: ['i_testing_summary_widget_total'],
+ },
+ counter: {},
+ },
+};
+
+function combineDeepArray(path, ...objects) {
+ const parts = path.split('.');
+ const allEntries = objects.reduce((entries, currentObject) => {
+ let expandedEntries = entries;
+ let traversed = currentObject;
+
+ parts.forEach((part) => {
+ traversed = traversed?.[part];
+ });
+
+ if (traversed) {
+ expandedEntries = [...entries, ...traversed];
+ }
+
+ return expandedEntries;
+ }, []);
+
+ return Array.from(new Set(allEntries));
+}
+
+function simplifyWidgetName(componentName) {
+ const noWidget = componentName.replace(/^Widget/, '');
+
+ return noWidget.charAt(0).toLowerCase() + noWidget.slice(1);
+}
+
+function baseRedisEventName(extensionName) {
+ const redisEventName = extensionName.replace(/([A-Z])/g, '_$1').toLowerCase();
+
+ return `i_merge_request_widget_${redisEventName}`;
+}
+
+function whenable(bus) {
+ return function when(event) {
+ return ({ execute, track, special }) => {
+ bus.$on(event, (busEvent) => {
+ track.forEach((redisEvent) => {
+ execute(redisEvent);
+ });
+
+ special?.({ event, execute, track, bus, busEvent });
+ });
+ };
+ };
+}
+
+function defaultBehaviorEvents({ bus, config }) {
+ const when = whenable(bus);
+ const isViewed = when(TELEMETRY_WIDGET_VIEWED);
+ const isExpanded = when(TELEMETRY_WIDGET_EXPANDED);
+ const fullReportIsClicked = when(TELEMETRY_WIDGET_FULL_REPORT_CLICKED);
+ const toHll = config?.uniqueUser || {};
+ const toCounts = config?.counter || {};
+ const user = api.trackRedisHllUserEvent.bind(api);
+ const count = api.trackRedisCounterEvent.bind(api);
+
+ if (toHll.view) {
+ isViewed({ execute: user, track: toHll.view });
+ }
+ if (toCounts.view) {
+ isViewed({ execute: count, track: toCounts.view });
+ }
+
+ if (toHll.expand) {
+ isExpanded({
+ execute: user,
+ track: toHll.expand,
+ special: ({ execute, track, busEvent }) => {
+ if (busEvent.type) {
+ track.forEach((event) => {
+ execute(`${event}_${busEvent.type}`);
+ });
+ }
+ },
+ });
+ }
+ if (toCounts.expand) {
+ isExpanded({
+ execute: count,
+ track: toCounts.expand,
+ special: ({ execute, track, busEvent }) => {
+ if (busEvent.type) {
+ track.forEach((event) => {
+ execute(`${event}_${busEvent.type}`);
+ });
+ }
+ },
+ });
+ }
+
+ if (toHll.clickFullReport) {
+ fullReportIsClicked({ execute: user, track: toHll.clickFullReport });
+ }
+ if (toCounts.clickFullReport) {
+ fullReportIsClicked({ execute: count, track: toCounts.clickFullReport });
+ }
+}
+
+function baseTelemetry(componentName) {
+ const simpleExtensionName = simplifyWidgetName(componentName);
+ const additionalNonStandard = nonStandardEvents[simpleExtensionName] || {};
+ /*
+ * Telemetry config format is:
+ * {
+ * TELEMETRY_TYPE: {
+ * BEHAVIOR: [ EVENT_NAME, ... ]
+ * }
+ * }
+ *
+ * Right now, there are currently configurations for these telemetry types:
+ * - uniqueUser is sent to RedisHLL
+ * - counter is sent to a regular Redis counter
+ */
+ const defaultTelemetry = {
+ uniqueUser: {
+ view: [`${baseRedisEventName(simpleExtensionName)}_view`],
+ expand: [`${baseRedisEventName(simpleExtensionName)}_expand`],
+ clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_click_full_report`],
+ },
+ counter: {
+ view: [`${baseRedisEventName(simpleExtensionName)}_count_view`],
+ expand: [`${baseRedisEventName(simpleExtensionName)}_count_expand`],
+ clickFullReport: [`${baseRedisEventName(simpleExtensionName)}_count_click_full_report`],
+ },
+ };
+
+ return {
+ uniqueUser: {
+ view: combineDeepArray('uniqueUser.view', defaultTelemetry, additionalNonStandard),
+ expand: combineDeepArray('uniqueUser.expand', defaultTelemetry, additionalNonStandard),
+ clickFullReport: combineDeepArray(
+ 'uniqueUser.clickFullReport',
+ defaultTelemetry,
+ additionalNonStandard,
+ ),
+ },
+ counter: {
+ view: combineDeepArray('counter.view', defaultTelemetry, additionalNonStandard),
+ expand: combineDeepArray('counter.expand', defaultTelemetry, additionalNonStandard),
+ clickFullReport: combineDeepArray(
+ 'counter.clickFullReport',
+ defaultTelemetry,
+ additionalNonStandard,
+ ),
+ },
+ };
+}
+
+export function createTelemetryHub(componentName) {
+ const bus = createEventHub();
+ const config = baseTelemetry(componentName);
+
+ defaultBehaviorEvents({ bus, config });
+
+ return {
+ viewed() {
+ bus.$emit(TELEMETRY_WIDGET_VIEWED);
+ },
+ expanded({ type }) {
+ bus.$emit(TELEMETRY_WIDGET_EXPANDED, { type });
+ },
+ fullReportClicked() {
+ bus.$emit(TELEMETRY_WIDGET_FULL_REPORT_CLICKED);
+ },
+ /* I want a Record here: #{ ...config } // and then the comment would be: This is for debugging only, changing your reference to it does nothing. šŸ˜˜ */
+ config: Object.freeze({ ...config }), // This is *intended* to be for debugging only, but it's pretty mutable, and it has references to live data in child props
+ bus,
+ };
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index f5667aee15b..f8d2732b385 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -32,7 +32,7 @@ export default {
computed: {
arrowIconName() {
- return this.isCollapsed ? 'angle-right' : 'angle-down';
+ return this.isCollapsed ? 'chevron-lg-right' : 'chevron-lg-down';
},
ariaLabel() {
return this.isCollapsed ? __('Expand') : __('Collapse');
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
deleted file mode 100644
index e1d88099580..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ /dev/null
@@ -1,100 +0,0 @@
-<script>
-import {
- GlLink,
- GlTooltipDirective,
- GlModalDirective,
- GlSafeHtmlDirective as SafeHtml,
- GlSprintf,
-} from '@gitlab/ui';
-import { constructWebIDEPath } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
-import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import MrWidgetIcon from './mr_widget_icon.vue';
-
-export default {
- name: 'MRWidgetHeader',
- components: {
- clipboardButton,
- TooltipOnTruncate,
- MrWidgetIcon,
- GlLink,
- GlSprintf,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- GlModalDirective,
- SafeHtml,
- },
- props: {
- mr: {
- type: Object,
- required: true,
- },
- },
- computed: {
- shouldShowCommitsBehindText() {
- return this.mr.divergedCommitsCount > 0;
- },
- branchNameClipboardData() {
- // This supports code in app/assets/javascripts/copy_to_clipboard.js that
- // works around ClipboardJS limitations to allow the context-specific
- // copy/pasting of plain text or GFM.
- return JSON.stringify({
- text: this.mr.sourceBranch,
- gfm: `\`${this.mr.sourceBranch}\``,
- });
- },
- webIdePath() {
- return constructWebIDEPath(this.mr);
- },
- isFork() {
- return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath;
- },
- },
- i18n: {
- webIdeText: s__('mrWidget|Open in Web IDE'),
- gitpodText: s__('mrWidget|Open in Gitpod'),
- },
-};
-</script>
-<template>
- <div class="gl-display-flex mr-source-target">
- <mr-widget-icon name="git-merge" />
- <div class="git-merge-container d-flex">
- <div class="normal">
- <strong>
- {{ s__('mrWidget|Request to merge') }}
- <tooltip-on-truncate
- v-safe-html="mr.sourceBranchLink"
- :title="mr.sourceBranch"
- truncate-target="child"
- class="label-branch label-truncate js-source-branch"
- /><clipboard-button
- data-testid="mr-widget-copy-clipboard"
- :text="branchNameClipboardData"
- :title="__('Copy branch name')"
- category="tertiary"
- />
- {{ s__('mrWidget|into') }}
- <tooltip-on-truncate
- :title="mr.targetBranch"
- truncate-target="child"
- class="label-branch label-truncate"
- >
- <a :href="mr.targetBranchTreePath" class="js-target-branch"> {{ mr.targetBranch }} </a>
- </tooltip-on-truncate>
- </strong>
- <div v-if="shouldShowCommitsBehindText" class="diverged-commits-count">
- <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')">
- <template #link>
- <gl-link :href="mr.targetBranchPath">{{
- n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount)
- }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
index 472df8e3110..437342bf438 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <div class="circle-icon-container gl-mr-3 align-self-start align-self-lg-center">
+ <div class="circle-icon-container gl-mr-3 align-self-start">
<gl-icon :name="name" :size="24" />
</div>
</template>
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 3b3b46e9772..1e1a2049414 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
@@ -172,7 +172,7 @@ export default {
</p>
</template>
<template v-else-if="!hasPipeline">
- <gl-loading-icon size="md" />
+ <gl-loading-icon size="lg" />
<p
class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index b8a1f89d232..913aa0e1e34 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -93,9 +93,7 @@ export default {
</span>
</p>
<div
- v-if="
- divergedCommitsCount > 0 && glFeatures.updatedMrHeader && !glFeatures.restructuredMrWidget
- "
+ v-if="divergedCommitsCount > 0 && !glFeatures.restructuredMrWidget"
class="diverged-commits-count"
>
<gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
index 8b410926c46..45958d7fb8d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -113,7 +113,7 @@ export default {
data-testid="ok"
category="primary"
class="gl-mt-2"
- variant="info"
+ variant="confirm"
:href="pipelinePath"
:data-track-property="humanAccess"
:data-track-value="$options.SP_SHOW_TRACK_VALUE"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 4fb95fe635c..cf482410bef 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -417,6 +417,7 @@ export default {
}
this.isMakingRequest = true;
+ this.editCommitMessage = false;
if (!useAutoMerge) {
this.mr.transitionStateMachine({ transition: MERGE });
@@ -663,7 +664,11 @@ export default {
<gl-sprintf v-else :message="mergeDisabledText" />
</div>
<template v-if="glFeatures.restructuredMrWidget">
- <div v-show="editCommitMessage" class="gl-w-full gl-order-n1">
+ <div
+ v-if="editCommitMessage"
+ class="gl-w-full gl-order-n1"
+ data-testid="edit_commit_message"
+ >
<ul
:class="{
'content-list': !glFeatures.restructuredMrWidget,
@@ -711,15 +716,13 @@ export default {
<div
v-if="!restructuredWidgetShowMergeButtons"
class="gl-w-full gl-order-n1 gl-text-gray-500"
+ data-qa-selector="merged_status_content"
>
<strong v-if="mr.state !== 'closed'">
{{ __('Merge details') }}
</strong>
<ul class="gl-pl-4 gl-m-0">
- <li
- v-if="mr.divergedCommitsCount > 0 && glFeatures.updatedMrHeader"
- class="gl-line-height-normal"
- >
+ <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal">
<gl-sprintf
:message="s__('mrWidget|The source branch is %{link} the target branch')"
>
@@ -788,11 +791,7 @@ export default {
</div>
</div>
<template v-if="shouldShowMergeControls && !glFeatures.restructuredMrWidget">
- <div
- v-if="!shouldShowMergeEdit"
- class="mr-fast-forward-message"
- data-qa-selector="fast_forward_message_content"
- >
+ <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message">
{{ __('Fast-forward merge without a merge commit') }}
</div>
<commits-header
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index e0e19094c40..5bd7745d704 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -126,6 +126,7 @@ export default {
}) => {
toast(__('Marked as ready. Merging is now allowed.'));
$('.merge-request .detail-page-description .title').text(title);
+ eventHub.$emit('MRWidgetUpdateRequested');
},
)
.catch(() =>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue
index 2ba945a3ecf..18fdb29ba54 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue
@@ -1,5 +1,5 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
+import { GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { n__ } from '~/locale';
@@ -9,7 +9,7 @@ import TerraformPlan from './terraform_plan.vue';
export default {
name: 'MRWidgetTerraformContainer',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
GlSprintf,
MrWidgetExpanableSection,
TerraformPlan,
@@ -100,7 +100,7 @@ export default {
<template>
<section class="mr-widget-section">
<div v-if="loading" class="mr-widget-body">
- <gl-skeleton-loading />
+ <gl-skeleton-loader />
</div>
<mr-widget-expanable-section v-else>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index 533bb38a88c..c148a35209f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -1,4 +1,5 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps';
export const SUCCESS = 'success';
@@ -165,4 +166,15 @@ export const EXTENSION_ICON_CLASS = {
export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
+export const TELEMETRY_WIDGET_VIEWED = 'WIDGET_VIEWED';
+export const TELEMETRY_WIDGET_EXPANDED = 'WIDGET_EXPANDED';
+export const TELEMETRY_WIDGET_FULL_REPORT_CLICKED = 'WIDGET_FULL_REPORT_CLICKED';
+
export { STATE_MACHINE };
+
+export const INVALID_RULES_DOCS_PATH = helpPagePath(
+ 'user/project/merge_requests/approvals/index.md',
+ {
+ anchor: 'invalid-rules',
+ },
+);
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
index 168f10bd148..f14e80d0be6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js
@@ -6,6 +6,7 @@ import { EXTENSION_ICONS } from '../../constants';
export default {
name: 'WidgetAccessibility',
enablePolling: true,
+ telemetry: false,
i18n: {
loading: s__('Reports|Accessibility scanning results are being parsed'),
error: s__('Reports|Accessibility scanning failed loading results'),
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
index cea8df2484b..2477429af5b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js
@@ -13,7 +13,6 @@ export default {
loading: s__('ciReport|Code Quality test metrics results are being parsed'),
error: s__('ciReport|Code Quality failed loading results'),
},
- expandEvent: 'i_testing_code_quality_widget_total',
computed: {
summary() {
const { newErrors, resolvedErrors, errorSummary } = this.collapsedData;
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
index 6ca0ea9c4e7..a7aaa2f4476 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js
@@ -12,7 +12,6 @@ export default {
label: 'Issues',
loading: 'Loading issues...',
},
- expandEvent: 'i_testing_load_performance_widget_total',
// Add an array of props
// These then get mapped to values stored in the MR Widget store
props: ['targetProjectFullPath', 'conflictsDocsPath'],
@@ -45,7 +44,7 @@ export default {
console.log('Hello world');
},
},
- { text: 'Full report', href: this.conflictsDocsPath, target: '_blank' },
+ { text: 'Full report', href: this.conflictsDocsPath, target: '_blank', fullReport: true },
];
},
shouldCollapse() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/index.js
new file mode 100644
index 00000000000..ce35ae033de
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/index.js
@@ -0,0 +1,10 @@
+// This is here because ee_else_ce requires both ce and ee versions of the
+// file to be present.
+// TODO: implement me
+export default {
+ name: 'WidgetSecurityReportsCE',
+ data() {
+ return {};
+ },
+ props: ['securityReportPaths'],
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
index 8fcc4f818ec..6611aedcb07 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/terraform/index.js
@@ -23,7 +23,6 @@ export default {
reportErrored: s__('Terraform|Generating the report caused an error.'),
fullLog: __('Full log'),
},
- expandEvent: 'i_testing_terraform_widget_total',
props: ['terraformReportsPath'],
computed: {
// Extension computed props
@@ -113,6 +112,7 @@ export default {
href: report.job_path,
text: this.$options.i18n.fullLog,
target: '_blank',
+ fullReport: true,
};
actions.push(action);
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
index 577b2cbfc5c..164bda33b95 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -1,5 +1,6 @@
import { uniqueId } from 'lodash';
import axios from '~/lib/utils/axios_utils';
+import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue';
import { EXTENSION_ICONS } from '../../constants';
import {
summaryTextBuilder,
@@ -7,6 +8,7 @@ import {
reportSubTextBuilder,
countRecentlyFailedTests,
recentFailuresTextBuilder,
+ formatFilePath,
} from './utils';
import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants';
@@ -14,8 +16,8 @@ export default {
name: 'WidgetTestSummary',
enablePolling: true,
i18n,
- expandEvent: 'i_testing_summary_widget_total',
props: ['testResultsPath', 'headBlobPath', 'pipeline'],
+ modalComponent: TestCaseDetails,
computed: {
summary(data) {
if (data.parsingInProgress) {
@@ -47,14 +49,18 @@ export default {
text: this.$options.i18n.fullReport,
href: `${this.pipeline.path}/test_report`,
target: '_blank',
+ fullReport: true,
},
];
},
},
methods: {
fetchCollapsedData() {
- return axios.get(this.testResultsPath).then(({ data = {}, status }) => {
+ return axios.get(this.testResultsPath).then((res) => {
+ const { data = {}, status } = res;
+
return {
+ ...res,
data: {
hasSuiteError: data.suites?.some((suite) => suite.status === ERROR_STATUS),
parsingInProgress: status === 204,
@@ -94,8 +100,18 @@ export default {
return {
id: uniqueId('test-'),
header: this.testHeader(test, sectionHeader, index),
+ modal: {
+ text: test.name,
+ onClick: () => {
+ this.modalData = {
+ testCase: {
+ filePath: test.file && `${this.headBlobPath}/${formatFilePath(test.file)}`,
+ ...test,
+ },
+ };
+ },
+ },
icon: { name: iconName },
- text: test.name,
};
};
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
index 9e4b0ac581c..7bbcb0cd04a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js
@@ -82,3 +82,12 @@ export const countRecentlyFailedTests = (subject) => {
})
.reduce((total, count) => total + count, 0);
};
+
+/**
+ * Removes `./` from the beginning of a file path so it can be appended onto a blob path
+ * @param {String} file
+ * @returns {String} - formatted value
+ */
+export const formatFilePath = (file) => {
+ return file.replace(/^\.?\/*/, '');
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 8ebb7f6f159..c68437b9879 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
@@ -1,6 +1,7 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
+import securityReportExtension from 'ee_else_ce/vue_merge_request_widget/extensions/security_reports';
import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
@@ -15,7 +16,6 @@ import SmartInterval from '~/smart_interval';
import { setFaviconOverlay } from '../lib/utils/favicon';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
-import WidgetHeader from './components/mr_widget_header.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
@@ -59,7 +59,6 @@ export default {
components: {
Loading,
ExtensionsContainer,
- 'mr-widget-header': WidgetHeader,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
MrWidgetPipelineContainer,
'mr-widget-related-links': WidgetRelatedLinks,
@@ -190,7 +189,7 @@ export default {
);
},
shouldRenderSecurityReport() {
- return Boolean(this.mr.pipeline.id);
+ return Boolean(this.mr?.pipeline?.id);
},
shouldRenderTerraformPlans() {
return Boolean(this.mr?.terraformReportsPath);
@@ -231,12 +230,15 @@ export default {
window.gon?.features?.refactorMrWidgetsExtensionsUser
);
},
+ shouldShowSecurityExtension() {
+ return window.gon?.features?.refactorSecurityExtension;
+ },
+ shouldShowCodeQualityExtension() {
+ return window.gon?.features?.refactorCodeQualityExtension;
+ },
isRestructuredMrWidgetEnabled() {
return window.gon?.features?.restructuredMrWidget;
},
- isUpdatedHeaderEnabled() {
- return window.gon?.features?.updatedMrHeader;
- },
},
watch: {
'mr.machineValue': {
@@ -270,6 +272,11 @@ export default {
this.registerTestReportExtension();
}
},
+ shouldRenderSecurityReport(newVal) {
+ if (newVal) {
+ this.registerSecurityReportExtension();
+ }
+ },
},
mounted() {
MRWidgetService.fetchInitialData()
@@ -516,7 +523,7 @@ export default {
}
},
registerCodeQualityExtension() {
- if (this.shouldRenderCodeQuality && this.shouldShowExtension) {
+ if (this.shouldRenderCodeQuality && this.shouldShowCodeQualityExtension) {
registerExtension(codeQualityExtension);
}
},
@@ -525,20 +532,23 @@ export default {
registerExtension(testReportExtension);
}
},
+ registerSecurityReportExtension() {
+ if (this.shouldRenderSecurityReport && this.shouldShowSecurityExtension) {
+ registerExtension(securityReportExtension);
+ }
+ },
},
};
</script>
<template>
<div v-if="isLoaded" class="mr-state-widget gl-mt-3">
<header
- v-if="shouldRenderCollaborationStatus || !isUpdatedHeaderEnabled"
- :class="{ 'mr-widget-workflow gl-mt-0!': isUpdatedHeaderEnabled }"
- class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100 gl-overflow-hidden"
+ v-if="shouldRenderCollaborationStatus"
+ class="gl-rounded-base gl-border-solid gl-border-1 gl-border-gray-100 gl-overflow-hidden mr-widget-workflow gl-mt-0!"
>
<mr-widget-alert-message v-if="shouldRenderCollaborationStatus" type="info">
{{ s__('mrWidget|Members who can merge are allowed to add commits.') }}
</mr-widget-alert-message>
- <mr-widget-header v-if="!isUpdatedHeaderEnabled" :mr="mr" />
</header>
<mr-widget-suggest-pipeline
v-if="shouldSuggestPipelines"
@@ -584,7 +594,7 @@ export default {
</div>
<extensions-container :mr="mr" />
<grouped-codequality-reports-app
- v-if="shouldRenderCodeQuality && !shouldShowExtension"
+ v-if="shouldRenderCodeQuality && !shouldShowCodeQualityExtension"
:head-blob-path="mr.headBlobPath"
:base-blob-path="mr.baseBlobPath"
:codequality-reports-path="mr.codequalityReportsPath"
@@ -592,7 +602,7 @@ export default {
/>
<security-reports-app
- v-if="shouldRenderSecurityReport"
+ v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension"
:pipeline-id="mr.pipeline.id"
:project-id="mr.sourceProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 2ae4f4da2f3..18d955652ba 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -3,8 +3,6 @@ import { stateKey } from './state_maps';
export default function deviseState() {
if (!this.commitsCount) {
return stateKey.nothingToMerge;
- } else if (this.hasMergeChecksFailed && !this.autoMergeEnabled) {
- return stateKey.mergeChecksFailed;
} else if (this.projectArchived) {
return stateKey.archived;
} else if (this.branchMissing) {
@@ -15,6 +13,8 @@ export default function deviseState() {
return stateKey.conflicts;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
+ } else if (this.hasMergeChecksFailed && !this.autoMergeEnabled) {
+ return stateKey.mergeChecksFailed;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
} else if (this.draft) {
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 146cf7e11a7..03c9a01cc7a 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
@@ -33,6 +33,7 @@ export default class MergeRequestStore {
this.setData(data);
this.initCodeQualityReport(data);
+ this.initSecurityReport(data);
this.setGitpodData(data);
}
@@ -41,6 +42,19 @@ export default class MergeRequestStore {
this.codeQuality = data.codequality_reports_path;
}
+ initSecurityReport(data) {
+ // TODO: check if gl.mrWidgetData can be safely removed after we migrate to the
+ // widget extension.
+ this.securityReportPaths = {
+ apiFuzzingReportPath: data.api_fuzzing_comparison_path,
+ coverageFuzzingReportPath: data.coverage_fuzzing_comparison_path,
+ sastReportPath: data.sast_comparison_path,
+ dastReportPath: data.dast_comparison_path,
+ secretDetectionReportPath: data.secret_detection_comparison_path,
+ dependencyScanningReportPath: data.dependency_scanning_comparison_path,
+ };
+ }
+
setData(data, isRebased) {
this.initApprovals();
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
index c93f620995f..f2ea55df63d 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -18,7 +18,6 @@ import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
-import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
@@ -83,9 +82,6 @@ export default {
alertId: {
default: '',
},
- isThreatMonitoringPage: {
- default: false,
- },
projectId: {
default: '',
},
@@ -175,7 +171,6 @@ export default {
updated() {
this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
- initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
methods: {
@@ -225,9 +220,7 @@ export default {
});
},
incidentPath(issueId) {
- return this.isThreatMonitoringPage
- ? joinPaths(this.projectIssuesPath, issueId)
- : joinPaths(this.projectIssuesPath, 'incident', issueId);
+ return joinPaths(this.projectIssuesPath, 'incident', issueId);
},
trackPageViews() {
const { category, action } = this.trackAlertsDetailsViewsOptions;
@@ -374,7 +367,6 @@ export default {
</gl-tab>
<metric-images-tab
- v-if="!isThreatMonitoringPage"
:data-testid="$options.tabsConfig[1].id"
:title="$options.tabsConfig[1].title"
/>
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 489d4afa41f..72dcc16b57a 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
@@ -302,9 +302,11 @@ export default {
<span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
{{ __('None') }} -
<gl-button
- class="gl-ml-2"
+ class="gl-ml-2 gl-reset-color!"
href="#"
+ category="tertiary"
variant="link"
+ size="small"
data-testid="unassigned-users"
@click="updateAlertAssignees(currentUser)"
>
diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
index 6cc70739eaa..d106f545c61 100644
--- a/app/assets/javascripts/vue_shared/alert_details/constants.js
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -30,13 +30,4 @@ export const PAGE_CONFIG = {
label: 'Status',
},
},
- THREAT_MONITORING: {
- TITLE: 'THREAT_MONITORING',
- STATUSES: {
- TRIGGERED: s__('ThreatMonitoring|Unreviewed'),
- ACKNOWLEDGED: s__('ThreatMonitoring|In review'),
- RESOLVED: s__('ThreatMonitoring|Resolved'),
- IGNORED: s__('ThreatMonitoring|Dismissed'),
- },
- },
};
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
index 614748fa80d..5793069440c 100644
--- a/app/assets/javascripts/vue_shared/alert_details/index.js
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -65,16 +65,12 @@ export default (selector) => {
const opsProperties = {};
- if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
- const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[
- page
- ];
- provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS;
- provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS;
- opsProperties.store = createStore({}, service);
- } else if (page === PAGE_CONFIG.THREAT_MONITORING.TITLE) {
- provide.isThreatMonitoringPage = true;
- }
+ const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[
+ page
+ ];
+ provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS;
+ provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS;
+ opsProperties.store = createStore({}, service);
// eslint-disable-next-line no-new
new Vue({
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 9bccc49e894..8bffc2479a1 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -45,7 +45,12 @@ export default {
return validSizes.includes(value);
},
},
- borderless: {
+ isActive: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isBorderless: {
type: Boolean,
required: false,
default: false,
@@ -67,15 +72,19 @@ export default {
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status} gl-rounded-full gl-justify-content-center`;
},
icon() {
- return this.borderless ? `${this.status.icon}_borderless` : this.status.icon;
+ return this.isBorderless ? `${this.status.icon}_borderless` : this.status.icon;
},
},
};
</script>
<template>
<span
- :class="[wrapperStyleClasses, { interactive: isInteractive }]"
+ :class="[
+ wrapperStyleClasses,
+ { interactive: isInteractive, active: isActive, borderless: isBorderless },
+ ]"
:style="{ height: `${size}px`, width: `${size}px` }"
+ data-testid="ci-icon-wrapper"
>
<gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index f14e1992901..dd6923d9fcd 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -45,7 +45,7 @@ export default {
};
</script>
<template>
- <gl-dropdown right :text="$options.labels.defaultLabel" category="primary" variant="info">
+ <gl-dropdown right :text="$options.labels.defaultLabel" category="primary" variant="confirm">
<div class="pb-2 mx-1">
<template v-if="sshLink">
<gl-dropdown-section-header>{{ $options.labels.ssh }}</gl-dropdown-section-header>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
new file mode 100644
index 00000000000..92817d5fa70
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_item.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ props: {
+ color: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <span
+ class="dropdown-label-box gl-flex-shrink-0 gl-top-1 gl-mr-0"
+ data-testid="color-item"
+ :style="{ backgroundColor: color }"
+ ></span>
+ <span class="hide-collapsed">{{ title }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
new file mode 100644
index 00000000000..6b79883d76b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/color_select_root.vue
@@ -0,0 +1,214 @@
+<script>
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import { DEFAULT_COLOR, COLOR_WIDGET_COLOR, DROPDOWN_VARIANT, ISSUABLE_COLORS } from './constants';
+import DropdownContents from './dropdown_contents.vue';
+import DropdownValue from './dropdown_value.vue';
+import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils';
+import epicColorQuery from './graphql/epic_color.query.graphql';
+import updateEpicColorMutation from './graphql/epic_update_color.mutation.graphql';
+
+export default {
+ i18n: {
+ assignColor: s__('ColorWidget|Assign epic color'),
+ dropdownButtonText: COLOR_WIDGET_COLOR,
+ fetchingError: s__('ColorWidget|Error fetching epic color.'),
+ updatingError: s__('ColorWidget|An error occurred while updating color.'),
+ widgetTitle: COLOR_WIDGET_COLOR,
+ },
+ components: {
+ DropdownValue,
+ DropdownContents,
+ SidebarEditableItem,
+ },
+ props: {
+ allowEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ iid: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: DROPDOWN_VARIANT.Sidebar,
+ },
+ dropdownButtonText: {
+ type: String,
+ required: false,
+ default: COLOR_WIDGET_COLOR,
+ },
+ dropdownTitle: {
+ type: String,
+ required: false,
+ default: s__('ColorWidget|Assign epic color'),
+ },
+ },
+ data() {
+ return {
+ issuableColor: {
+ color: '',
+ title: '',
+ },
+ colorUpdateInProgress: false,
+ oldIid: null,
+ sidebarExpandedOnClick: false,
+ };
+ },
+ apollo: {
+ issuableColor: {
+ query: epicColorQuery,
+ skip() {
+ return !isDropdownVariantSidebar(this.variant);
+ },
+ variables() {
+ return {
+ iid: this.iid,
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ const issuableColor = data.workspace?.issuable?.color;
+
+ if (issuableColor) {
+ return ISSUABLE_COLORS.find((color) => color.color === issuableColor) ?? DEFAULT_COLOR;
+ }
+
+ return DEFAULT_COLOR;
+ },
+ error() {
+ createFlash({
+ message: this.$options.i18n.fetchingError,
+ captureError: true,
+ });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.colorUpdateInProgress || this.$apollo.queries.issuableColor.loading;
+ },
+ },
+ watch: {
+ iid(_, oldVal) {
+ this.oldIid = oldVal;
+ },
+ },
+ methods: {
+ handleDropdownClose(color) {
+ if (this.iid !== '') {
+ this.updateSelectedColor(this.getUpdateVariables(color));
+ } else {
+ this.$emit('updateSelectedColor', color);
+ }
+
+ this.collapseEditableItem();
+ },
+ collapseEditableItem() {
+ this.$refs.editable?.collapse();
+ if (this.sidebarExpandedOnClick) {
+ this.sidebarExpandedOnClick = false;
+ this.$emit('toggleCollapse');
+ }
+ },
+ getUpdateVariables(color) {
+ const currentIid = this.oldIid || this.iid;
+
+ return {
+ iid: currentIid,
+ groupPath: this.fullPath,
+ color: color.color,
+ };
+ },
+ updateSelectedColor(inputVariables) {
+ this.colorUpdateInProgress = true;
+
+ this.$apollo
+ .mutate({
+ mutation: updateEpicColorMutation,
+ variables: { input: inputVariables },
+ })
+ .then(({ data }) => {
+ if (data.updateIssuableColor?.errors?.length) {
+ throw new Error();
+ }
+
+ this.$emit('updateSelectedColor', {
+ id: data.updateIssuableColor?.issuable?.id,
+ color: data.updateIssuableColor?.issuable?.color,
+ });
+ })
+ .catch((error) =>
+ createFlash({
+ message: this.$options.i18n.updatingError,
+ captureError: true,
+ error,
+ }),
+ )
+ .finally(() => {
+ this.colorUpdateInProgress = false;
+ });
+ },
+ isDropdownVariantSidebar,
+ isDropdownVariantEmbedded,
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-wrapper gl-relative"
+ :class="{
+ 'is-embedded': isDropdownVariantEmbedded(variant),
+ }"
+ >
+ <template v-if="isDropdownVariantSidebar(variant)">
+ <sidebar-editable-item
+ ref="editable"
+ :title="$options.i18n.widgetTitle"
+ :loading="isLoading"
+ :can-edit="allowEdit"
+ @open="oldIid = null"
+ >
+ <template #collapsed>
+ <dropdown-value :selected-color="issuableColor">
+ <slot></slot>
+ </dropdown-value>
+ </template>
+ <template #default="{ edit }">
+ <dropdown-value :selected-color="issuableColor" class="gl-mb-2">
+ <slot></slot>
+ </dropdown-value>
+ <dropdown-contents
+ ref="dropdownContents"
+ :dropdown-button-text="dropdownButtonText"
+ :dropdown-title="dropdownTitle"
+ :selected-color="issuableColor"
+ :variant="variant"
+ :is-visible="edit"
+ @setColor="handleDropdownClose"
+ @closeDropdown="collapseEditableItem"
+ />
+ </template>
+ </sidebar-editable-item>
+ </template>
+ <dropdown-contents
+ v-else
+ ref="dropdownContents"
+ :dropdown-button-text="dropdownButtonText"
+ :dropdown-title="dropdownTitle"
+ :selected-color="issuableColor"
+ :variant="variant"
+ @setColor="handleDropdownClose"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
new file mode 100644
index 00000000000..c70785abd1e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/constants.js
@@ -0,0 +1,30 @@
+import { __, s__ } from '~/locale';
+
+export const COLOR_WIDGET_COLOR = s__('ColorWidget|Color');
+
+export const DROPDOWN_VARIANT = {
+ Sidebar: 'sidebar',
+ Embedded: 'embedded',
+};
+
+export const DEFAULT_COLOR = { title: __('SuggestedColors|Blue'), color: '#1068bf' };
+
+export const ISSUABLE_COLORS = [
+ DEFAULT_COLOR,
+ {
+ title: s__('SuggestedColors|Green'),
+ color: '#217645',
+ },
+ {
+ title: s__('SuggestedColors|Red'),
+ color: '#c91c00',
+ },
+ {
+ title: s__('SuggestedColors|Orange'),
+ color: '#9e5400',
+ },
+ {
+ title: s__('SuggestedColors|Purple'),
+ color: '#694cc0',
+ },
+];
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
new file mode 100644
index 00000000000..4eb1d3d08ca
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlDropdown } from '@gitlab/ui';
+import DropdownContentsColorView from './dropdown_contents_color_view.vue';
+import DropdownHeader from './dropdown_header.vue';
+import { isDropdownVariantSidebar } from './utils';
+
+export default {
+ components: {
+ DropdownContentsColorView,
+ DropdownHeader,
+ GlDropdown,
+ },
+ props: {
+ dropdownTitle: {
+ type: String,
+ required: true,
+ },
+ selectedColor: {
+ type: Object,
+ required: true,
+ },
+ dropdownButtonText: {
+ type: String,
+ required: true,
+ },
+ variant: {
+ type: String,
+ required: true,
+ },
+ isVisible: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ showDropdownContentsCreateView: false,
+ localSelectedColor: this.selectedColor,
+ isDirty: false,
+ };
+ },
+ computed: {
+ buttonText() {
+ if (!this.localSelectedColor?.title) {
+ return this.dropdownButtonText;
+ }
+
+ return this.localSelectedColor.title;
+ },
+ },
+ watch: {
+ localSelectedColor: {
+ handler() {
+ this.isDirty = true;
+ },
+ deep: true,
+ },
+ isVisible(newVal) {
+ if (newVal) {
+ this.$refs.dropdown.show();
+ this.isDirty = false;
+ this.localSelectedColor = this.selectedColor;
+ } else {
+ this.$refs.dropdown.hide();
+ this.setColor();
+ }
+ },
+ selectedColor(newVal) {
+ if (!this.isDirty) {
+ this.localSelectedColor = newVal;
+ }
+ },
+ },
+ methods: {
+ setColor() {
+ if (!this.isDirty) {
+ return;
+ }
+ this.$emit('setColor', this.localSelectedColor);
+ },
+ handleDropdownHide() {
+ this.$emit('closeDropdown');
+ if (!isDropdownVariantSidebar(this.variant)) {
+ this.setColor();
+ }
+ this.$refs.dropdown.hide();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown ref="dropdown" :text="buttonText" class="gl-w-full" @hide="handleDropdownHide">
+ <template #header>
+ <dropdown-header
+ ref="header"
+ :dropdown-title="dropdownTitle"
+ @closeDropdown="handleDropdownHide"
+ />
+ </template>
+ <template #default>
+ <dropdown-contents-color-view
+ v-model="localSelectedColor"
+ @closeDropdown="handleDropdownHide"
+ />
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
new file mode 100644
index 00000000000..62f4cf59c14
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlDropdownForm, GlDropdownItem } from '@gitlab/ui';
+import ColorItem from './color_item.vue';
+import { ISSUABLE_COLORS } from './constants';
+
+export default {
+ components: {
+ GlDropdownForm,
+ GlDropdownItem,
+ ColorItem,
+ },
+ model: {
+ prop: 'selectedColor',
+ },
+ props: {
+ selectedColor: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ colors: ISSUABLE_COLORS,
+ };
+ },
+ methods: {
+ isColorSelected(color) {
+ return this.selectedColor.color === color.color;
+ },
+ handleColorClick(color) {
+ this.$emit('input', color);
+ this.$emit('closeDropdown', this.selectedColor);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-form>
+ <div>
+ <gl-dropdown-item
+ v-for="color in colors"
+ :key="color.color"
+ :is-checked="isColorSelected(color)"
+ :is-check-centered="true"
+ :is-check-item="true"
+ @click.native.capture.stop="handleColorClick(color)"
+ >
+ <color-item :color="color.color" :title="color.title" />
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown-form>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_header.vue
new file mode 100644
index 00000000000..a32b1570f5f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_header.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ dropdownTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!">
+ <span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="$emit('closeDropdown')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
new file mode 100644
index 00000000000..4cba66eefd2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_value.vue
@@ -0,0 +1,43 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { COLOR_WIDGET_COLOR } from './constants';
+import ColorItem from './color_item.vue';
+
+export default {
+ i18n: {
+ dropdownTitle: COLOR_WIDGET_COLOR,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlIcon,
+ ColorItem,
+ },
+ props: {
+ selectedColor: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="value js-value">
+ <div
+ v-gl-tooltip.left.viewport
+ :title="$options.i18n.dropdownTitle"
+ class="sidebar-collapsed-icon"
+ >
+ <gl-icon name="appearance" />
+ <color-item
+ :color="selectedColor.color"
+ :title="selectedColor.title"
+ class="gl-font-base gl-line-height-24"
+ />
+ </div>
+
+ <color-item class="hide-collapsed" :color="selectedColor.color" :title="selectedColor.title" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_color.query.graphql b/app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_color.query.graphql
new file mode 100644
index 00000000000..959e0f8c1a5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_color.query.graphql
@@ -0,0 +1,9 @@
+query epicColor($fullPath: ID!, $iid: ID) {
+ workspace: group(fullPath: $fullPath) {
+ id
+ issuable: epic(iid: $iid) {
+ id
+ color
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_update_color.mutation.graphql b/app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_update_color.mutation.graphql
new file mode 100644
index 00000000000..2975b42253f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/graphql/epic_update_color.mutation.graphql
@@ -0,0 +1,9 @@
+mutation updateEpicColor($input: UpdateEpicInput!) {
+ updateIssuableColor: updateEpic(input: $input) {
+ issuable: epic {
+ id
+ color
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/utils.js b/app/assets/javascripts/vue_shared/components/color_select_dropdown/utils.js
new file mode 100644
index 00000000000..46196e793b3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/utils.js
@@ -0,0 +1,15 @@
+import { DROPDOWN_VARIANT } from './constants';
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {string} variant
+ */
+export const isDropdownVariantSidebar = (variant) => variant === DROPDOWN_VARIANT.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {string} variant
+ */
+export const isDropdownVariantEmbedded = (variant) => variant === DROPDOWN_VARIANT.Embedded;
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index 9cf8638f3cb..3ecfac10f9c 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -1,8 +1,5 @@
<script>
-import {
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlSafeHtmlDirective as SafeHtml,
-} from '@gitlab/ui';
+import { GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
import { forEach, escape } from 'lodash';
@@ -14,7 +11,7 @@ let axiosSource;
export default {
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
},
directives: {
SafeHtml,
@@ -115,7 +112,7 @@ export default {
<template>
<div ref="markdownPreview" class="md-previewer" data-testid="md-previewer">
- <gl-skeleton-loading v-if="isLoading" />
+ <gl-skeleton-loader v-if="isLoading" />
<div
v-else
v-safe-html:[$options.safeHtmlConfig]="previewContent"
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 d7a84798e47..5d7f4ae2a01 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,4 +1,4 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
export const DEBOUNCE_DELAY = 500;
export const MAX_RECENT_TOKENS_SIZE = 3;
@@ -46,11 +46,13 @@ export const SortDirection = {
export const FILTERED_SEARCH_LABELS = 'labels';
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
-export const TOKEN_TITLE_AUTHOR = __('Author');
export const TOKEN_TITLE_ASSIGNEE = __('Assignee');
-export const TOKEN_TITLE_MILESTONE = __('Milestone');
+export const TOKEN_TITLE_AUTHOR = __('Author');
+export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
+export const TOKEN_TITLE_CONTACT = s__('Crm|Contact');
export const TOKEN_TITLE_LABEL = __('Label');
-export const TOKEN_TITLE_TYPE = __('Type');
-export const TOKEN_TITLE_RELEASE = __('Release');
+export const TOKEN_TITLE_MILESTONE = __('Milestone');
export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
-export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
+export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization');
+export const TOKEN_TITLE_RELEASE = __('Release');
+export const TOKEN_TITLE_TYPE = __('Type');
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
index c3a0a97a7ba..6a4ff07c999 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -168,7 +168,7 @@ export default {
if (data || operator) {
this.searchKey = data;
- if (!this.suggestionsLoading && !this.activeTokenValue) {
+ if (!this.activeTokenValue) {
let search = this.searchTerm ? this.searchTerm : data;
if (search.startsWith('"') && search.endsWith('"')) {
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
index 6f24955814c..178c57a5666 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue
@@ -43,9 +43,7 @@ export default {
},
methods: {
getActiveLabel(labels, data) {
- return labels.find(
- (label) => this.getLabelName(label).toLowerCase() === stripQuotes(data).toLowerCase(),
- );
+ return labels.find((label) => this.getLabelName(label) === stripQuotes(data));
},
/**
* There's an inconsistency between private and public API
@@ -128,7 +126,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<span
:style="{ backgroundColor: label.color }"
- class="gl-display-inline-block mr-2 p-2"
+ class="gl-display-inline-block gl-mr-3 gl-p-3"
></span>
<div>{{ getLabelName(label) }}</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
index 69548f0e7a8..15d858b99b9 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue
@@ -1,5 +1,11 @@
<script>
-import { GlFormInputGroup, GlFormGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import {
+ GlFormInputGroup,
+ GlFormInput,
+ GlFormGroup,
+ GlButton,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -12,6 +18,7 @@ export default {
},
components: {
GlFormInputGroup,
+ GlFormInput,
GlFormGroup,
GlButton,
ClipboardButton,
@@ -80,10 +87,15 @@ export default {
this.$emit('visibility-change', this.valueIsVisible);
},
+ handleClick() {
+ this.$refs.input.$el.select();
+ },
handleCopyButtonClick() {
this.$emit('copy');
},
handleFormInputCopy(event) {
+ this.handleCopyButtonClick();
+
if (this.computedValueIsVisible) {
return;
}
@@ -96,14 +108,21 @@ export default {
</script>
<template>
<gl-form-group v-bind="$attrs">
- <gl-form-input-group
- :value="displayedValue"
- input-class="gl-font-monospace! gl-cursor-default!"
- select-on-click
- readonly
- v-bind="formInputGroupProps"
- @copy="handleFormInputCopy"
- >
+ <gl-form-input-group>
+ <gl-form-input
+ ref="input"
+ readonly
+ class="gl-font-monospace! gl-cursor-default!"
+ v-bind="formInputGroupProps"
+ :value="displayedValue"
+ @copy="handleFormInputCopy"
+ @click="handleClick"
+ />
+
+ <!--
+ This v-if is necessary to avoid an issue with border radius.
+ See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88059#note_969812649
+ -->
<template v-if="showToggleVisibilityButton || showCopyButton" #append>
<gl-button
v-if="showToggleVisibilityButton"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index ba2b5eaa4f9..4fdf7f45643 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -266,7 +266,7 @@ export default {
}}
</p>
<gl-button
- variant="info"
+ variant="confirm"
category="primary"
size="small"
@click="handleSuggestDismissed"
diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index 7a7074da084..78a7fed6293 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -98,13 +98,15 @@ export default {
<span v-else-if="isConfidential" ref="confidential">
{{ confidentialContextText }}
{{ __('People without permission will never get a notification.') }}
- <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link>
+ <gl-link :href="confidentialNoteableDocsPath" target="_blank">{{
+ __('Learn more.')
+ }}</gl-link>
</span>
<span v-else-if="isLocked" ref="locked">
{{ lockedContextText }}
{{ __('Only project members can comment.') }}
- <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ __('Learn more') }}</gl-link>
+ <gl-link :href="lockedNoteableDocsPath" target="_blank">{{ __('Learn more.') }}</gl-link>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index 3aca068c074..2206ae98c73 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -1,11 +1,11 @@
<script>
-import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSkeletonLoader } from '@gitlab/ui';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
export default {
name: 'SkeletonNote',
components: {
- GlSkeletonLoading,
+ GlSkeletonLoader,
TimelineEntryItem,
},
};
@@ -16,7 +16,7 @@ export default {
<div class="timeline-icon"></div>
<div class="timeline-content">
<div class="note-header"></div>
- <div class="note-body"><gl-skeleton-loading /></div>
+ <div class="note-body"><gl-skeleton-loader /></div>
</div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index dd7a851b1be..3593ea16968 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -18,7 +18,7 @@
*/
import {
GlButton,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlSkeletonLoader,
GlTooltipDirective,
GlIcon,
GlSafeHtmlDirective as SafeHtml,
@@ -26,9 +26,9 @@ import {
import $ from 'jquery';
import { mapGetters, mapActions, mapState } from 'vuex';
import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
+import '~/behaviors/markdown/render_gfm';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import initMRPopovers from '~/mr_popover/';
import noteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { spriteIcon } from '~/lib/utils/common_utils';
@@ -46,7 +46,7 @@ export default {
noteHeader,
TimelineEntryItem,
GlButton,
- GlSkeletonLoading,
+ GlSkeletonLoader,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -94,7 +94,7 @@ export default {
},
},
mounted() {
- initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
+ $(this.$refs['gfm-content']).renderGFM();
},
methods: {
...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']),
@@ -130,7 +130,7 @@ export default {
<div class="timeline-content">
<div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
- <span v-safe-html="actionTextHtml"></span>
+ <span ref="gfm-content" v-safe-html="actionTextHtml"></span>
<template
v-if="canSeeDescriptionVersion || note.outdated_line_change_path"
#extra-controls
@@ -172,7 +172,7 @@ export default {
</div>
<div v-if="shouldShowDescriptionVersion" class="description-version pt-2">
<pre v-if="isLoadingDescriptionVersion" class="loading-state">
- <gl-skeleton-loading />
+ <gl-skeleton-loader />
</pre>
<pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre>
<gl-button
@@ -218,7 +218,9 @@ export default {
</tr>
</table>
</div>
- <gl-skeleton-loading v-else-if="showLines" class="gl-mt-4" />
+ <div v-else-if="showLines" class="mt-4">
+ <gl-skeleton-loader />
+ </div>
</div>
</div>
</timeline-entry-item>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index f21092af501..67ad7769c7c 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -130,16 +130,19 @@ export default {
<span data-testid="legend-text">{{ legendText }}</span>
</template>
</gl-infinite-scroll>
- <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
+ <div v-if="showNoResultsMessage" class="gl-text-gray-600 gl-ml-3 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
</div>
<div
v-if="showMinimumSearchQueryMessage"
- class="text-muted ml-2 js-minimum-search-query-message"
+ class="gl-text-gray-600 gl-ml-3 js-minimum-search-query-message"
>
{{ __('Enter at least three characters to search') }}
</div>
- <div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message">
+ <div
+ v-if="showSearchErrorMessage"
+ class="gl-text-red-500 gl-font-weight-bold gl-ml-3 js-search-error-message"
+ >
{{ __('Something went wrong, unable to search projects') }}
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
index da68fe961a6..1948a6778f4 100644
--- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue
@@ -12,7 +12,7 @@ export default {
GlFilteredSearch,
},
props: {
- filter: {
+ filters: {
type: Array,
required: true,
},
@@ -33,7 +33,7 @@ export default {
computed: {
internalFilter: {
get() {
- return this.filter;
+ return this.filters;
},
set(value) {
this.$emit('filter:changed', value);
@@ -71,7 +71,7 @@ export default {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
const newQueryString = this.generateQueryData({
sorting: { ...this.sorting, sort },
- filter: this.filter,
+ filter: this.filters,
});
this.$emit('sorting:changed', { sort });
this.$emit('query:changed', newQueryString);
@@ -79,7 +79,7 @@ export default {
onSortItemClick(item) {
const newQueryString = this.generateQueryData({
sorting: { ...this.sorting, orderBy: item },
- filter: this.filter,
+ filter: this.filters,
});
this.$emit('sorting:changed', { orderBy: item });
this.$emit('query:changed', newQueryString);
@@ -87,7 +87,7 @@ export default {
submitSearch() {
const newQueryString = this.generateQueryData({
sorting: this.sorting,
- filter: this.filter,
+ filter: this.filters,
});
this.$emit('filter:submit');
this.$emit('query:changed', newQueryString);
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
index 34845e3d9e4..c97e191b630 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/constants.js
@@ -1,7 +1,5 @@
import { s__ } from '~/locale';
-export const PLATFORMS_WITHOUT_ARCHITECTURES = ['docker', 'kubernetes'];
-
export const REGISTRATION_TOKEN_PLACEHOLDER = '$REGISTRATION_TOKEN';
export const INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES = {
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
index 5d144c0d699..06852f511bf 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -9,35 +9,19 @@ export default {
RunnerInstructionsModal,
},
directives: {
- GlModalDirective,
+ GlModal: GlModalDirective,
},
modalId: 'runner-instructions-modal',
i18n: {
buttonText: s__('Runners|Show runner installation instructions'),
},
- data() {
- return {
- opened: false,
- };
- },
- methods: {
- onClick() {
- // lazily mount modal to prevent premature instructions requests
- this.opened = true;
- },
- },
};
</script>
<template>
<div>
- <gl-button
- v-gl-modal-directive="$options.modalId"
- class="gl-mt-4"
- data-testid="show-modal-button"
- @click="onClick"
- >
+ <gl-button v-gl-modal="$options.modalId" class="gl-mt-4" data-testid="show-modal-button">
{{ $options.i18n.buttonText }}
</gl-button>
- <runner-instructions-modal v-if="opened" :modal-id="$options.modalId" />
+ <runner-instructions-modal :modal-id="$options.modalId" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 9eaaf7d1c18..bfaf3b92c34 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
@@ -17,7 +17,6 @@ import { __, s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import {
INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES,
- PLATFORMS_WITHOUT_ARCHITECTURES,
REGISTRATION_TOKEN_PLACEHOLDER,
} from './constants';
import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql';
@@ -59,21 +58,25 @@ export default {
apollo: {
platforms: {
query: getRunnerPlatformsQuery,
+ skip() {
+ // Only load instructions once the modal is shown
+ return !this.shown;
+ },
update(data) {
- return data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => {
- return {
- name,
- humanReadableName,
- architectures: architectures?.nodes || [],
- };
- });
+ return (
+ data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => {
+ return {
+ name,
+ humanReadableName,
+ architectures: architectures?.nodes || [],
+ };
+ }) ?? []
+ );
},
result() {
- if (this.platforms.length) {
- // If it is set and available, select the defaultSelectedPlatform.
- // Otherwise, select the first available platform
- this.selectPlatform(this.defaultPlatform() || this.platforms[0]);
- }
+ // If it is set and available, select the defaultSelectedPlatform.
+ // Otherwise, select the first available platform
+ this.selectPlatform(this.defaultPlatformName || this.platforms?.[0].name);
},
error() {
this.toggleAlert(true);
@@ -82,12 +85,12 @@ export default {
instructions: {
query: getRunnerSetupInstructionsQuery,
skip() {
- return !this.selectedPlatform;
+ return !this.shown || !this.selectedPlatform;
},
variables() {
return {
- platform: this.selectedPlatformName,
- architecture: this.selectedArchitectureName || '',
+ platform: this.selectedPlatform,
+ architecture: this.selectedArchitecture || '',
};
},
update(data) {
@@ -100,6 +103,7 @@ export default {
},
data() {
return {
+ shown: false,
platforms: [],
selectedPlatform: null,
selectedArchitecture: null,
@@ -109,55 +113,63 @@ export default {
};
},
computed: {
- platformsEmpty() {
- return isEmpty(this.platforms);
- },
instructionsEmpty() {
return isEmpty(this.instructions);
},
- selectedPlatformName() {
- return this.selectedPlatform?.name;
- },
- selectedArchitectureName() {
- return this.selectedArchitecture?.name;
+ architectures() {
+ return this.platforms.find(({ name }) => name === this.selectedPlatform)?.architectures || [];
},
- hasArchitecureList() {
- return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatformName);
+ binaryUrl() {
+ return this.architectures.find(({ name }) => name === this.selectedArchitecture)
+ ?.downloadLocation;
},
instructionsWithoutArchitecture() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.instructions;
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform]?.instructions;
},
runnerInstallationLink() {
- return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link;
+ return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform]?.link;
},
registerInstructionsWithToken() {
const { registerInstructions } = this.instructions || {};
if (this.registrationToken) {
- return registerInstructions.replace(REGISTRATION_TOKEN_PLACEHOLDER, this.registrationToken);
+ return registerInstructions?.replace(
+ REGISTRATION_TOKEN_PLACEHOLDER,
+ this.registrationToken,
+ );
}
-
return registerInstructions;
},
},
+ updated() {
+ // Refocus on dom changes, after loading data
+ this.refocusSelectedPlatformButton();
+ },
methods: {
show() {
this.$refs.modal.show();
},
- focusSelected() {
- // By default the first platform always gets the focus, but when the `defaultPlatformName`
- // property is present, any other platform might actually be selected.
- this.$refs[this.selectedPlatformName]?.[0].$el.focus();
+ onShown() {
+ this.shown = true;
+ this.refocusSelectedPlatformButton();
},
- defaultPlatform() {
- return this.platforms.find((platform) => platform.name === this.defaultPlatformName);
+ refocusSelectedPlatformButton() {
+ // On modal opening, the first focusable element is auto-focused by bootstrap-vue
+ // This can be confusing for users, because the wrong platform button can
+ // get focused when setting a `defaultPlatformName`.
+ // This method refocuses the expected button.
+ // See more about this auto-focus: https://bootstrap-vue.org/docs/components/modal#auto-focus-on-open
+ this.$refs[this.selectedPlatform]?.[0].$el.focus();
},
- selectPlatform(platform) {
- this.selectedPlatform = platform;
+ selectPlatform(platformName) {
+ this.selectedPlatform = platformName;
- if (!platform.architectures?.some(({ name }) => name === this.selectedArchitectureName)) {
- // Select first architecture when current value is not available
- this.selectArchitecture(platform.architectures[0]);
+ // Update architecture when platform changes
+ const arch = this.architectures.find(({ name }) => name === this.selectedArchitecture);
+ if (arch) {
+ this.selectArchitecture(arch.name);
+ } else {
+ this.selectArchitecture(this.architectures[0]?.name);
}
},
selectArchitecture(architecture) {
@@ -175,6 +187,7 @@ export default {
},
},
i18n: {
+ environment: __('Environment'),
installARunner: s__('Runners|Install a runner'),
architecture: s__('Runners|Architecture'),
downloadInstallBinary: s__('Runners|Download and install binary'),
@@ -182,6 +195,7 @@ export default {
registerRunnerCommand: s__('Runners|Command to register runner'),
fetchError: s__('Runners|An error has occurred fetching instructions'),
copyInstructions: s__('Runners|Copy instructions'),
+ viewInstallationInstructions: s__('Runners|View installation instructions'),
},
closeButton: {
text: __('Close'),
@@ -197,17 +211,17 @@ export default {
:action-secondary="$options.closeButton"
v-bind="$attrs"
v-on="$listeners"
- @shown="focusSelected"
+ @shown="onShown"
>
<gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)">
{{ $options.i18n.fetchError }}
</gl-alert>
- <gl-skeleton-loader v-if="platformsEmpty && $apollo.loading" />
+ <gl-skeleton-loader v-if="!platforms.length && $apollo.loading" />
- <template v-if="!platformsEmpty">
+ <template v-if="platforms.length">
<h5>
- {{ __('Environment') }}
+ {{ $options.i18n.environment }}
</h5>
<div v-gl-resize-observer="onPlatformsButtonResize">
<gl-button-group
@@ -220,29 +234,29 @@ export default {
v-for="platform in platforms"
:key="platform.name"
:ref="platform.name"
- :selected="selectedPlatform && selectedPlatform.name === platform.name"
- @click="selectPlatform(platform)"
+ :selected="selectedPlatform === platform.name"
+ @click="selectPlatform(platform.name)"
>
{{ platform.humanReadableName }}
</gl-button>
</gl-button-group>
</div>
</template>
- <template v-if="hasArchitecureList">
+ <template v-if="architectures.length">
<template v-if="selectedPlatform">
<h5>
{{ $options.i18n.architecture }}
<gl-loading-icon v-if="$apollo.loading" size="sm" inline />
</h5>
- <gl-dropdown class="gl-mb-3" :text="selectedArchitectureName">
+ <gl-dropdown class="gl-mb-3" :text="selectedArchitecture">
<gl-dropdown-item
- v-for="architecture in selectedPlatform.architectures"
+ v-for="architecture in architectures"
:key="architecture.name"
:is-check-item="true"
- :is-checked="selectedArchitectureName === architecture.name"
+ :is-checked="selectedArchitecture === architecture.name"
data-testid="architecture-dropdown-item"
- @click="selectArchitecture(architecture)"
+ @click="selectArchitecture(architecture.name)"
>
{{ architecture.name }}
</gl-dropdown-item>
@@ -250,8 +264,9 @@ export default {
<div class="gl-sm-display-flex gl-align-items-center gl-mb-3">
<h5>{{ $options.i18n.downloadInstallBinary }}</h5>
<gl-button
+ v-if="binaryUrl"
class="gl-ml-auto"
- :href="selectedArchitecture.downloadLocation"
+ :href="binaryUrl"
download
icon="download"
data-testid="binary-download-button"
@@ -298,7 +313,7 @@ export default {
<p>{{ instructionsWithoutArchitecture }}</p>
<gl-button :href="runnerInstallationLink">
<gl-icon name="external-link" />
- {{ s__('Runners|View installation instructions') }}
+ {{ $options.i18n.viewInstallationInstructions }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index 60111210f5d..9388ef4ba45 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -2,6 +2,9 @@
import { GlButton, GlIcon } from '@gitlab/ui';
import { mapActions, mapGetters } from 'vuex';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget` instead.
export default {
components: {
GlButton,
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 399db978b60..1064cbc26e3 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
@@ -4,6 +4,9 @@ import { mapGetters, mapState } from 'vuex';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue` instead.
export default {
components: {
DropdownContentsLabelsView,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index 2cccb8325f4..3ff3755de46 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -2,6 +2,9 @@
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue` instead.
export default {
components: {
GlButton,
@@ -51,10 +54,10 @@ export default {
<template>
<div class="labels-select-contents-create js-labels-create">
- <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <div class="dropdown-title d-flex align-items-center pt-0 pb-2 gl-mb-0">
<gl-button
:aria-label="__('Go back')"
- variant="link"
+ category="tertiary"
size="small"
class="js-btn-back dropdown-header-button p-0"
icon="arrow-left"
@@ -63,7 +66,7 @@ export default {
<span class="flex-grow-1">{{ labelsCreateTitle }}</span>
<gl-button
:aria-label="__('Close')"
- variant="link"
+ category="tertiary"
size="small"
class="dropdown-header-button p-0"
icon="close"
@@ -95,7 +98,7 @@ export default {
></span>
<gl-form-input
v-model.trim="selectedColor"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
:placeholder="__('Use custom color #FF0000')"
/>
</div>
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 134575b7a27..e235bfde394 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
@@ -13,6 +13,9 @@ import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/
import LabelItem from './label_item.vue';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue` instead.
export default {
components: {
GlIntersectionObserver,
@@ -166,7 +169,7 @@ export default {
<span class="flex-grow-1">{{ labelsListTitle }}</span>
<gl-button
:aria-label="__('Close')"
- variant="link"
+ category="tertiary"
size="small"
class="dropdown-header-button gl-p-0!"
icon="close"
@@ -193,6 +196,7 @@ export default {
:key="label.id"
:label="label"
:is-label-set="label.set"
+ :is-label-indeterminate="label.indeterminate"
:highlight="index === currentHighlightItem"
@clickLabel="handleLabelClick(label)"
/>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
index e91a0489ef1..e4325492334 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
@@ -2,6 +2,9 @@
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue` instead.
export default {
components: {
GlButton,
@@ -23,7 +26,9 @@ export default {
</script>
<template>
- <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold">
+ <div
+ class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-font-weight-bold gl-mb-0"
+ >
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline />
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 35ac9ef8565..e59d150dd43 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -5,6 +5,9 @@ import { mapState } from 'vuex';
import { isScopedLabel } from '~/lib/utils/common_utils';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue` instead.
export default {
components: {
GlLabel,
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
index 8a26c4a6618..5966c78aa51 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
@@ -2,6 +2,9 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget` instead.
export default {
directives: {
GlTooltip: GlTooltipDirective,
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 dd40add6376..154e3013acd 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
@@ -1,6 +1,10 @@
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue` instead.
export default {
functional: true,
props: {
@@ -12,6 +16,11 @@ export default {
type: Boolean,
required: true,
},
+ isLabelIndeterminate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
highlight: {
type: Boolean,
required: false,
@@ -19,7 +28,7 @@ export default {
},
},
render(h, { props, listeners }) {
- const { label, highlight, isLabelSet } = props;
+ const { label, highlight, isLabelSet, isLabelIndeterminate } = props;
const labelColorBox = h('span', {
class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
@@ -33,18 +42,36 @@ export default {
const checkedIcon = h(GlIcon, {
class: {
- 'gl-mr-3 gl-flex-shrink-0': true,
+ 'gl-mr-3 gl-flex-shrink-0 has-tooltip': true,
hidden: !isLabelSet,
},
+ attrs: {
+ title: __('Selected for all items.'),
+ 'data-testid': 'checked-icon',
+ },
props: {
name: 'mobile-issue-close',
},
});
+ const indeterminateIcon = h(GlIcon, {
+ class: {
+ 'gl-mr-3 gl-flex-shrink-0 has-tooltip': true,
+ hidden: !isLabelIndeterminate,
+ },
+ attrs: {
+ title: __('Selected for some items.'),
+ 'data-testid': 'indeterminate-icon',
+ },
+ props: {
+ name: 'dash',
+ },
+ });
+
const noIcon = h('span', {
class: {
'gl-mr-5 gl-pr-3': true,
- hidden: isLabelSet,
+ hidden: isLabelSet || isLabelIndeterminate,
},
attrs: {
'data-testid': 'no-icon',
@@ -63,7 +90,7 @@ export default {
},
},
},
- [noIcon, checkedIcon, labelColorBox, labelTitle],
+ [noIcon, checkedIcon, indeterminateIcon, labelColorBox, labelTitle],
);
return h(
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 7e259cb8b96..b61996cdcdb 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
@@ -15,6 +15,9 @@ import labelsSelectModule from './store';
Vue.use(Vuex);
+// @deprecated This component should only be used when there is no GraphQL API.
+// In most cases you should use
+// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue` instead.
export default {
store: new Vuex.Store(labelsSelectModule()),
components: {
@@ -198,11 +201,12 @@ export default {
!state.showDropdownButton &&
!state.showDropdownContents
) {
- let filterFn = (label) => label.touched;
- if (this.isDropdownVariantEmbedded) {
- filterFn = (label) => label.set;
- }
- this.handleDropdownClose(state.labels.filter(filterFn));
+ const filterTouchedLabelsFn = (label) => label.touched;
+ const filterSetLabelsFn = (label) => label.set;
+ const labels = this.isDropdownVariantEmbedded
+ ? state.labels.filter(filterSetLabelsFn)
+ : state.labels.filter(filterTouchedLabelsFn);
+ this.handleDropdownClose(labels, state.labels.filter(filterTouchedLabelsFn));
}
},
/**
@@ -265,11 +269,11 @@ export default {
isInDropdownContents
);
},
- handleDropdownClose(labels) {
- // Only emit label updates if there are any labels to update
- // on UI.
+ handleDropdownClose(labels, touchedLabels) {
+ // Only emit label updates if there are any
+ // labels to update on UI.
if (labels.length) this.$emit('updateSelectedLabels', labels);
- this.$emit('onDropdownClose');
+ this.$emit('onDropdownClose', touchedLabels);
},
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index d14f96720b7..ef3eedd9bb2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -8,9 +8,10 @@ import { DropdownVariant } from '../constants';
* @param {object} state
*/
export const dropdownButtonText = (state, getters) => {
- const selectedLabels = getters.isDropdownVariantSidebar
- ? state.labels.filter((label) => label.set)
- : state.selectedLabels;
+ const selectedLabels =
+ getters.isDropdownVariantSidebar || getters.isDropdownVariantEmbedded
+ ? state.labels.filter((label) => label.set || label.indeterminate)
+ : state.selectedLabels;
if (!selectedLabels.length) {
return state.dropdownButtonText || __('Label');
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 9e64f03fe84..43b23994cdf 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -2,8 +2,39 @@ import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
import { DropdownVariant } from '../constants';
import * as types from './mutation_types';
+const transformLabels = (labels, selectedLabels) =>
+ labels.map((label) => {
+ const selectedLabel = selectedLabels.find(({ id }) => id === label.id);
+
+ return {
+ ...label,
+ set: Boolean(selectedLabel?.set),
+ indeterminate: Boolean(selectedLabel?.indeterminate),
+ };
+ });
+
export default {
[types.SET_INITIAL_STATE](state, props) {
+ // We need to ensure that selectedLabels have
+ // `set` & `indeterminate` properties defined.
+ if (props.selectedLabels?.length) {
+ props.selectedLabels.forEach((label) => {
+ /* eslint-disable no-param-reassign */
+ if (label.set === undefined && label.indeterminate === undefined) {
+ label.set = true;
+ label.indeterminate = false;
+ } else if (label.set === undefined && label.indeterminate !== undefined) {
+ label.set = false;
+ } else if (label.set !== undefined && label.indeterminate === undefined) {
+ label.indeterminate = false;
+ } else {
+ label.set = false;
+ label.indeterminate = false;
+ }
+ /* eslint-enable no-param-reassign */
+ });
+ }
+
Object.assign(state, { ...props });
},
@@ -36,10 +67,7 @@ export default {
// selectedLabels array.
state.labelsFetchInProgress = false;
state.labelsFetched = true;
- state.labels = labels.map((label) => ({
- ...label,
- set: state.selectedLabels.some((selectedLabel) => selectedLabel.id === label.id),
- }));
+ state.labels = transformLabels(labels, state.selectedLabels);
},
[types.RECEIVE_SET_LABELS_FAILURE](state) {
state.labelsFetchInProgress = false;
@@ -62,7 +90,8 @@ export default {
const candidateLabel = state.labels.find((label) => labelId === label.id);
if (candidateLabel) {
candidateLabel.touched = true;
- candidateLabel.set = !candidateLabel.set;
+ candidateLabel.set = candidateLabel.indeterminate ? true : !candidateLabel.set;
+ candidateLabel.indeterminate = false;
}
if (isScopedLabel(candidateLabel)) {
@@ -80,9 +109,6 @@ export default {
},
[types.UPDATE_LABELS_SET_STATE](state) {
- state.labels = state.labels.map((label) => ({
- ...label,
- set: state.selectedLabels.some((selectedLabel) => selectedLabel.id === label.id),
- }));
+ state.labels = transformLabels(state.labels, state.selectedLabels);
},
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 0fa64a29b3a..5471cda0cc5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -169,6 +169,9 @@ export default {
setFocus() {
this.$refs.header.focusInput();
},
+ hideDropdown() {
+ this.$refs.dropdown.hide();
+ },
showDropdown() {
this.$refs.dropdown.show();
},
@@ -205,7 +208,7 @@ export default {
:show-dropdown-contents-create-view="showDropdownContentsCreateView"
:is-standalone="isStandalone"
@toggleDropdownContentsCreateView="toggleDropdownContent"
- @closeDropdown="$emit('closeDropdown')"
+ @closeDropdown="hideDropdown"
@input="debouncedSearchKeyUpdate"
@searchEnter="selectFirstItem"
/>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index 090bf9493bf..5f344ae4214 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -140,18 +140,19 @@ export default {
<template>
<div class="labels-select-contents-create js-labels-create">
<div class="dropdown-input">
- <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mb-3">
+ <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mt-3">
{{ error }}
</gl-alert>
<gl-form-input
v-model.trim="labelTitle"
+ class="gl-mt-3"
:placeholder="__('Name new label')"
:autofocus="true"
data-testid="label-title-input"
/>
</div>
<div class="dropdown-content gl-px-3">
- <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3!">
+ <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3! gl-mb-0">
<gl-link
v-for="(color, index) in suggestedColors"
:key="index"
@@ -169,7 +170,7 @@ export default {
></span>
<gl-form-input
v-model.trim="selectedColor"
- class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2"
:placeholder="__('Use custom color #FF0000')"
data-testid="selected-color-text"
/>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
index faad69732dd..aaddab43e2a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
@@ -51,7 +51,7 @@ export default {
<div data-testid="dropdown-header">
<div
v-if="!isStandalone"
- class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3! gl-mb-0"
data-testid="dropdown-header-title"
>
<gl-button
@@ -72,6 +72,7 @@ export default {
class="dropdown-header-button gl-p-0!"
icon="close"
data-testid="close-button"
+ data-qa-selector="close_labels_dropdown_button"
@click="$emit('closeDropdown')"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
index 57ee816c4c7..57e3ee4aaa5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -92,7 +92,9 @@ export default {
@click="handleCollapsedClick"
>
<gl-icon name="labels" />
- <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span>
+ <span class="collapse-truncated-title gl-pt-2 gl-px-3 gl-font-sm">{{
+ selectedLabels.length
+ }}</span>
</div>
<span
v-if="!selectedLabels.length"
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
index c30ca5369ee..7b62f0cdb7d 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue
@@ -72,7 +72,7 @@ export default {
</div>
<pre
- class="gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11! gl-border-none! code highlight"
+ class="gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11! gl-border-none! code highlight gl-line-height-normal"
:class="firstLineClass"
><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index bed6dd4d5c6..0d78530d878 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -134,3 +134,7 @@ export const BIDI_CHARS_CLASS_LIST = 'unicode-bidi has-tooltip';
export const BIDI_CHAR_TOOLTIP = __(
'Potentially unwanted character detected: Unicode BiDi Control',
);
+
+export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
+
+export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
new file mode 100644
index 00000000000..c9f7e5508be
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/index.js
@@ -0,0 +1,13 @@
+import { HLJS_ON_AFTER_HIGHLIGHT } from '../constants';
+import wrapComments from './wrap_comments';
+
+/**
+ * Registers our plugins for Highlight.js
+ *
+ * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
+ *
+ * @param {Object} hljs - the Highlight.js instance.
+ */
+export const registerPlugins = (hljs) => {
+ hljs.addPlugin({ [HLJS_ON_AFTER_HIGHLIGHT]: wrapComments });
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
new file mode 100644
index 00000000000..5be92af5b55
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
@@ -0,0 +1,39 @@
+import { HLJS_COMMENT_SELECTOR } from '../constants';
+
+const createWrapper = (content) => {
+ const span = document.createElement('span');
+ span.className = HLJS_COMMENT_SELECTOR;
+ span.innerHTML = content;
+ return span.outerHTML;
+};
+
+/**
+ * Highlight.js plugin for wrapping multi-line comments in the `hljs-comment` class.
+ * This ensures that multi-line comments are rendered correctly in the GitLab UI.
+ *
+ * Plugin API: https://github.com/highlightjs/highlight.js/blob/main/docs/plugin-api.rst
+ *
+ * @param {Object} Result - an object that represents the highlighted result from Highlight.js
+ */
+export default (result) => {
+ if (!result.value.includes(HLJS_COMMENT_SELECTOR)) return;
+
+ let wrapComment = false;
+
+ // eslint-disable-next-line no-param-reassign
+ result.value = result.value // Highlight.js expects the result param to be mutated for plugins to work
+ .split('\n')
+ .map((lineContent) => {
+ const includesClosingTag = lineContent.includes('</span>');
+ if (lineContent.includes(HLJS_COMMENT_SELECTOR) && !includesClosingTag) {
+ wrapComment = true;
+ return lineContent;
+ }
+ const line = wrapComment ? createWrapper(lineContent) : lineContent;
+ if (includesClosingTag) {
+ wrapComment = false;
+ }
+ return line;
+ })
+ .join('\n');
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
index ed87a202b15..f819a9e5be2 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue
@@ -5,6 +5,7 @@ import eventHub from '~/notes/event_hub';
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
import { ROUGE_TO_HLJS_LANGUAGE_MAP, LINES_PER_CHUNK } from './constants';
import Chunk from './components/chunk.vue';
+import { registerPlugins } from './plugins/index';
/*
* This component is optimized to handle source code with many lines of code by splitting source code into chunks of 70 lines of code,
@@ -111,6 +112,7 @@ export default {
let detectedLanguage = language;
let highlightedContent;
if (this.hljs) {
+ registerPlugins(this.hljs);
if (!detectedLanguage) {
const hljsHighlightAuto = this.hljs.highlightAuto(content);
highlightedContent = hljsHighlightAuto.value;
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index f62bf686f85..424cab20c7e 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -149,7 +149,7 @@ export default {
>
<slot>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4"
type="button"
@click="openFileUpload"
>
@@ -192,7 +192,7 @@ export default {
<transition name="upload-dropzone-fade">
<div
v-show="dragging && !enableDragBehavior"
- class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-4"
>
<div v-show="!isDragDataValid" class="mw-50 gl-text-center">
<slot name="invalid-drag-data-slot">
diff --git a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
index bc5e0cf10dd..20a666509a4 100644
--- a/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
+++ b/app/assets/javascripts/vue_shared/components/usage_quotas/usage_banner.vue
@@ -34,7 +34,7 @@ export default {
<div class="gl-display-flex gl-flex-direction-column gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1">
<div
v-if="$slots['left-primary-text']"
- class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0 gl-mb-4"
+ class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0"
>
<slot name="left-primary-text"></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/constants.js b/app/assets/javascripts/vue_shared/components/user_popover/constants.js
new file mode 100644
index 00000000000..1d49aefd297
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_popover/constants.js
@@ -0,0 +1 @@
+export const USER_POPOVER_DELAY = 200;
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 ec7a7cd72ae..768cd005727 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
@@ -14,12 +14,14 @@ import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
+import { USER_POPOVER_DELAY } from './constants';
const MAX_SKELETON_LINES = 4;
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
+ USER_POPOVER_DELAY,
components: {
GlIcon,
GlLink,
@@ -48,6 +50,11 @@ export default {
required: false,
default: 'top',
},
+ show: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -133,25 +140,21 @@ export default {
</script>
<template>
- <!-- 200ms delay so not every mouseover triggers Popover -->
- <gl-popover :target="target" :delay="200" :placement="placement" boundary="viewport">
- <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
- <div
- class="gl-p-2 flex-shrink-1 gl-display-flex gl-flex-direction-column align-items-center gl-w-70p"
- >
+ <!-- Delayed so not every mouseover triggers Popover -->
+ <gl-popover
+ :css-classes="['gl-max-w-48']"
+ :show="show"
+ :target="target"
+ :delay="$options.USER_POPOVER_DELAY"
+ :placement="placement"
+ boundary="viewport"
+ triggers="hover focus manual"
+ >
+ <div class="gl-py-3 gl-line-height-normal gl-display-flex" data-testid="user-popover">
+ <div class="gl-mr-4 gl-flex-shrink-0">
<user-avatar-image :img-src="user.avatarUrl" :size="64" css-classes="gl-m-0!" />
- <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3">
- <gl-button
- :variant="toggleFollowButtonVariant"
- :loading="toggleFollowLoading"
- size="small"
- data-testid="toggle-follow-button"
- @click="toggleFollow"
- >{{ toggleFollowButtonText }}</gl-button
- >
- </div>
</div>
- <div class="gl-w-full gl-min-w-0 gl-word-break-word">
+ <div class="gl-w-full gl-word-break-word gl-display-flex gl-align-items-center">
<template v-if="userIsLoading">
<gl-skeleton-loader
:lines="$options.maxSkeletonLines"
@@ -161,7 +164,7 @@ export default {
/>
</template>
<template v-else>
- <div class="gl-mb-3">
+ <div>
<h5 class="gl-m-0">
<user-name-with-status
:name="user.name"
@@ -170,42 +173,64 @@ export default {
/>
</h5>
<span class="gl-text-gray-500">@{{ user.username }}</span>
- </div>
- <div class="gl-text-gray-500">
- <div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <gl-icon name="profile" class="gl-flex-shrink-0" />
- <span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
+ <div v-if="shouldRenderToggleFollowButton" class="gl-mt-3">
+ <gl-button
+ :variant="toggleFollowButtonVariant"
+ :loading="toggleFollowLoading"
+ size="small"
+ data-testid="toggle-follow-button"
+ @click="toggleFollow"
+ >{{ toggleFollowButtonText }}</gl-button
+ >
</div>
- <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <gl-icon name="work" class="gl-flex-shrink-0" />
- <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
- </div>
- <div v-if="user.location" class="gl-display-flex gl-mb-2">
- <gl-icon name="location" class="gl-flex-shrink-0" />
- <span class="gl-ml-2">{{ user.location }}</span>
- </div>
- <div
- v-if="user.localTime && !user.bot"
- class="gl-display-flex gl-mb-2"
- data-testid="user-popover-local-time"
- >
- <gl-icon name="clock" class="gl-flex-shrink-0" />
- <span class="gl-ml-2">{{ user.localTime }}</span>
- </div>
- </div>
- <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
- <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
- </div>
- <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
- <gl-icon name="question" />
- <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
- <gl-sprintf :message="__('Learn more about %{username}')">
- <template #username>{{ user.name }}</template>
- </gl-sprintf>
- </gl-link>
</div>
</template>
</div>
</div>
+ <div class="gl-mt-2 gl-w-full gl-word-break-word">
+ <template v-if="userIsLoading">
+ <gl-skeleton-loader
+ :lines="$options.maxSkeletonLines"
+ preserve-aspect-ratio="none"
+ equal-width-lines
+ :height="24"
+ />
+ </template>
+ <template v-else>
+ <div class="gl-text-gray-500">
+ <div v-if="user.bio" class="gl-display-flex gl-mb-2">
+ <gl-icon name="profile" class="gl-flex-shrink-0" />
+ <span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
+ </div>
+ <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
+ <gl-icon name="work" class="gl-flex-shrink-0" />
+ <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ </div>
+ <div v-if="user.location" class="gl-display-flex gl-mb-2">
+ <gl-icon name="location" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div
+ v-if="user.localTime && !user.bot"
+ class="gl-display-flex gl-mb-2"
+ data-testid="user-popover-local-time"
+ >
+ <gl-icon name="clock" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.localTime }}</span>
+ </div>
+ </div>
+ <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
+ <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
+ </div>
+ <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
+ <gl-icon name="question" />
+ <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
+ <gl-sprintf :message="__('Learn more about %{username}')">
+ <template #username>{{ user.name }}</template>
+ </gl-sprintf>
+ </gl-link>
+ </div>
+ </template>
+ </div>
</gl-popover>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
index 15f84e48179..cac0d5a45c9 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -307,7 +307,7 @@ export default {
<actions-button
:actions="actions"
:selected-key="selection"
- :variant="isBlob ? 'info' : 'default'"
+ :variant="isBlob ? 'confirm' : 'default'"
:category="isBlob ? 'primary' : 'secondary'"
@select="select"
/>
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 3ebeec4a50b..14328b1f25f 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -71,10 +71,14 @@ export const AVATAR_SHAPE_OPTION_RECT = 'rect';
export const confidentialityInfoText = (workspaceType, issuableType) =>
sprintf(
__(
- 'Only %{workspaceType} members with at least Reporter role can view or be notified about this %{issuableType}.',
+ 'Only %{workspaceType} members with %{permissions} can view or be notified about this %{issuableType}.',
),
{
workspaceType: workspaceType === WorkspaceType.project ? __('project') : __('group'),
issuableType: issuableType === IssuableType.Issue ? __('issue') : __('epic'),
+ permissions:
+ issuableType === IssuableType.Issue
+ ? __('at least the Reporter role, the author, and assignees')
+ : __('at least the Reporter role'),
},
);
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
index f4cbaba9313..033bb8c3885 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_create_root.vue
@@ -29,7 +29,6 @@ export default {
<template>
<div class="issuable-create-container">
<slot name="title"></slot>
- <hr class="gl-mt-0" />
<issuable-form
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
index 0758cb507e9..89eecea5239 100644
--- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
+++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue
@@ -51,9 +51,9 @@ export default {
<template>
<gl-form class="common-note-form gfm-form" @submit.stop.prevent>
- <div data-testid="issuable-title" class="form-group row">
- <label for="issuable-title" class="col-form-label col-sm-2">{{ __('Title') }}</label>
- <div class="col-sm-10">
+ <div data-testid="issuable-title" class="row">
+ <label for="issuable-title" class="col-12 gl-mb-0">{{ __('Title') }}</label>
+ <div class="col-12">
<gl-form-group :description="__('Maximum of 255 characters')">
<gl-form-input
id="issuable-title"
@@ -66,10 +66,8 @@ export default {
</div>
</div>
<div data-testid="issuable-description" class="form-group row">
- <label for="issuable-description" class="col-form-label col-sm-2">{{
- __('Description')
- }}</label>
- <div class="col-sm-10">
+ <label for="issuable-description" class="col-12">{{ __('Description') }}</label>
+ <div class="col-12">
<markdown-field
:markdown-preview-path="descriptionPreviewPath"
:markdown-docs-path="descriptionHelpPath"
@@ -91,37 +89,28 @@ export default {
</markdown-field>
</div>
</div>
- <div class="row">
- <div class="col-lg-6">
- <div data-testid="issuable-labels" class="form-group row">
- <label for="issuable-labels" class="col-form-label col-md-2 col-lg-4">{{
- __('Labels')
- }}</label>
- <div class="col-md-8 col-sm-10">
- <div class="issuable-form-select-holder">
- <labels-select
- :allow-label-edit="true"
- :allow-label-create="true"
- :allow-multiselect="true"
- :allow-scoped-labels="true"
- :labels-fetch-path="labelsFetchPath"
- :labels-manage-path="labelsManagePath"
- :selected-labels="selectedLabels"
- :labels-list-title="__('Select label')"
- :footer-create-label-title="__('Create project label')"
- :footer-manage-label-title="__('Manage project labels')"
- :variant="$options.LabelSelectVariant.Embedded"
- @updateSelectedLabels="handleUpdateSelectedLabels"
- />
- </div>
- </div>
+ <div data-testid="issuable-labels" class="form-group row">
+ <label for="issuable-labels" class="col-12">{{ __('Labels') }}</label>
+ <div class="col-12">
+ <div class="issuable-form-select-holder">
+ <labels-select
+ :allow-label-edit="true"
+ :allow-label-create="true"
+ :allow-multiselect="true"
+ :allow-scoped-labels="true"
+ :labels-fetch-path="labelsFetchPath"
+ :labels-manage-path="labelsManagePath"
+ :selected-labels="selectedLabels"
+ :labels-list-title="__('Select label')"
+ :footer-create-label-title="__('Create project label')"
+ :footer-manage-label-title="__('Manage project labels')"
+ :variant="$options.LabelSelectVariant.Embedded"
+ @updateSelectedLabels="handleUpdateSelectedLabels"
+ />
</div>
</div>
</div>
- <div
- data-testid="issuable-create-actions"
- class="footer-block row-content-block gl-display-flex"
- >
+ <div data-testid="issuable-create-actions" class="footer-block gl-display-flex gl-mt-6">
<slot
name="actions"
:issuable-title="issuableTitle"
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
index 6453290f6ea..a9f8caa3e1f 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue
@@ -23,6 +23,11 @@ export default {
},
mixins: [timeagoMixin],
props: {
+ hasScopedLabelsFeature: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
issuableSymbol: {
type: String,
required: true,
@@ -132,7 +137,7 @@ export default {
return Boolean(this.$slots[slotName]);
},
scopedLabel(label) {
- return isScopedLabel(label);
+ return this.hasScopedLabelsFeature && isScopedLabel(label);
},
labelTitle(label) {
return label.title || label.name;
diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
index 8b293b2e9f6..8fbf0bb10a0 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue
@@ -1,10 +1,5 @@
<script>
-import {
- GlAlert,
- GlKeysetPagination,
- GlDeprecatedSkeletonLoading as GlSkeletonLoading,
- GlPagination,
-} from '@gitlab/ui';
+import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
@@ -27,7 +22,7 @@ export default {
components: {
GlAlert,
GlKeysetPagination,
- GlSkeletonLoading,
+ GlSkeletonLoader,
IssuableTabs,
FilteredSearchBar,
IssuableItem,
@@ -138,6 +133,11 @@ export default {
required: false,
default: 2,
},
+ hasScopedLabelsFeature: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
labelFilterParam: {
type: String,
required: false,
@@ -307,7 +307,7 @@ export default {
</issuable-bulk-edit-sidebar>
<ul v-if="issuablesLoading" class="content-list">
<li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!">
- <gl-skeleton-loading />
+ <gl-skeleton-loader />
</li>
</ul>
<template v-else>
@@ -325,6 +325,7 @@ export default {
:class="{ 'gl-cursor-grab': isManualOrdering }"
data-qa-selector="issuable_container"
:data-qa-issuable-title="issuable.title"
+ :has-scoped-labels-feature="hasScopedLabelsFeature"
:issuable-symbol="issuableSymbol"
:issuable="issuable"
:label-filter-param="labelFilterParam"
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index c6dce6a51c2..be9afc0610d 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -46,6 +46,13 @@ export const AvailableSortOptions = [
},
];
+export const IssuableTypes = {
+ Issue: 'ISSUE',
+ Incident: 'INCIDENT',
+ TestCase: 'TEST_CASE',
+ Requirement: 'REQUIREMENT',
+};
+
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
index 05dc1650379..5eb3da3c62e 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue
@@ -22,10 +22,6 @@ export default {
type: Object,
required: true,
},
- statusBadgeClass: {
- type: String,
- required: true,
- },
statusIcon: {
type: String,
required: true,
@@ -162,7 +158,6 @@ export default {
<template v-else>
<issuable-title
:issuable="issuable"
- :status-badge-class="statusBadgeClass"
:status-icon="statusIcon"
:enable-edit="enableEdit"
@edit-issuable="$emit('edit-issuable', $event)"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index 649dbd6576b..f035795a045 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
@@ -40,11 +40,6 @@ export default {
required: false,
default: '',
},
- statusBadgeClass: {
- type: String,
- required: false,
- default: '',
- },
statusIcon: {
type: String,
required: false,
@@ -113,12 +108,7 @@ export default {
<template>
<div class="detail-page-header">
<div class="detail-page-header-body">
- <gl-badge
- data-testid="status"
- class="issuable-status-badge gl-mr-3"
- :class="statusBadgeClass"
- :variant="badgeVariant"
- >
+ <gl-badge class="issuable-status-badge gl-mr-3" :variant="badgeVariant">
<gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" />
<span class="gl-display-none gl-sm-display-block"><slot name="status-badge"></slot></span>
</gl-badge>
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
index c165ee91c59..7ed93c042f8 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue
@@ -17,11 +17,6 @@ export default {
type: Object,
required: true,
},
- statusBadgeClass: {
- type: String,
- required: false,
- default: '',
- },
statusIcon: {
type: String,
required: false,
@@ -108,7 +103,6 @@ export default {
<div class="issuable-show-container" data-qa-selector="issuable_show_container">
<issuable-header
:issuable-state="issuable.state"
- :status-badge-class="statusBadgeClass"
:status-icon="statusIcon"
:status-icon-class="statusIconClass"
:blocked="issuable.blocked"
@@ -127,7 +121,6 @@ export default {
<issuable-body
:issuable="issuable"
- :status-badge-class="statusBadgeClass"
:status-icon="statusIcon"
:status-icon-class="statusIconClass"
:enable-edit="enableEdit"
diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
index 47f05a2cee2..3d7c71ce974 100644
--- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
+++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue
@@ -1,12 +1,14 @@
<script>
import {
GlIcon,
+ GlBadge,
GlButton,
GlIntersectionObserver,
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { __ } from '~/locale';
+import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
i18n: {
@@ -14,6 +16,7 @@ export default {
},
components: {
GlIcon,
+ GlBadge,
GlButton,
GlIntersectionObserver,
},
@@ -26,10 +29,6 @@ export default {
type: Object,
required: true,
},
- statusBadgeClass: {
- type: String,
- required: true,
- },
statusIcon: {
type: String,
required: true,
@@ -44,6 +43,11 @@ export default {
stickyTitleVisible: false,
};
},
+ computed: {
+ badgeVariant() {
+ return this.issuable.state === IssuableStates.Opened ? 'success' : 'info';
+ },
+ },
methods: {
handleTitleAppear() {
this.stickyTitleVisible = false;
@@ -60,7 +64,7 @@ export default {
<div class="title-container">
<h1
v-safe-html="issuable.titleHtml || issuable.title"
- class="title qa-title"
+ class="title qa-title gl-font-size-h-display"
dir="auto"
data-testid="title"
></h1>
@@ -84,14 +88,12 @@ export default {
<div
class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
>
- <p
- data-testid="status"
- class="issuable-status-box status-box gl-white-space-nowrap gl-my-0"
- :class="statusBadgeClass"
- >
- <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
- <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span>
- </p>
+ <gl-badge class="gl-white-space-nowrap gl-mr-3" :variant="badgeVariant">
+ <gl-icon v-if="statusIcon" class="gl-sm-display-none" :name="statusIcon" />
+ <span class="gl-display-none gl-sm-display-block">
+ <slot name="status-badge"></slot>
+ </span>
+ </gl-badge>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="issuable.title"
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
index 1f3cc663848..8e9b8ef3e6f 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -130,11 +130,7 @@ export default {
<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>
+ <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs" />
<legacy-container :key="activePanel.name" :selector="activePanel.selector" />
</div>
</div>
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index b74dba686ad..0c55cc2f8a6 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -33,7 +33,7 @@ export default {
this.fetchFreshItems();
const body = document.querySelector('body');
- const namespaceId = body.getAttribute('data-namespace-id');
+ const { namespaceId } = body.dataset;
this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId });
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index f209f145884..d3da988e3ed 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -1,5 +1,6 @@
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
import { STORAGE_KEY } from '../utils/notification';
import * as types from './mutation_types';
@@ -23,7 +24,7 @@ export default {
const v = versionDigest;
return axios
- .get('/-/whats_new', {
+ .get(joinPaths('/', gon.relative_url_root || '', '/-/whats_new'), {
params: {
page,
v,
diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js
index 66ee3b1a971..41aff202f48 100644
--- a/app/assets/javascripts/whats_new/utils/notification.js
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -1,6 +1,6 @@
export const STORAGE_KEY = 'display-whats-new-notification';
-export const getVersionDigest = (appEl) => appEl.getAttribute('data-version-digest');
+export const getVersionDigest = (appEl) => appEl.dataset.versionDigest;
export const setNotification = (appEl) => {
const versionDigest = getVersionDigest(appEl);
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 0b6c1a75bb2..69670d3471c 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -49,14 +49,28 @@ export default {
</script>
<template>
- <gl-form-group :label="$options.i18n.status" :label-for="$options.labelId">
+ <gl-form-group
+ :label="$options.i18n.status"
+ :label-for="$options.labelId"
+ label-cols="3"
+ label-cols-lg="2"
+ label-class="gl-pb-0!"
+ class="gl-align-items-center"
+ >
<gl-form-select
:id="$options.labelId"
:value="state"
:options="$options.states"
:disabled="loading"
- class="gl-w-auto"
+ class="gl-w-auto hide-select-decoration"
@change="setState"
/>
</gl-form-group>
</template>
+
+<style>
+.hide-select-decoration:not(:focus, :hover) {
+ background-image: none;
+ box-shadow: none;
+}
+</style>
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 232510b108d..ce2fa158596 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -40,18 +40,18 @@ export default {
<template>
<h2
- class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-display-inline-block"
+ class="gl-font-weight-normal gl-sm-font-weight-bold gl-my-5 gl-w-full"
:class="{ 'gl-cursor-not-allowed': disabled }"
aria-labelledby="item-title"
>
- <span
+ <div
id="item-title"
ref="titleEl"
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
- class="gl-pseudo-placeholder"
+ class="gl-pseudo-placeholder gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base"
@blur="handleBlur"
@keyup="handleInput"
@keydown.enter.exact="handleSubmit"
@@ -59,7 +59,8 @@ export default {
@keydown.meta.u.prevent
@keydown.ctrl.b.prevent
@keydown.meta.b.prevent
- >{{ title }}</span
>
+ {{ title }}
+ </div>
</h2>
</template>
diff --git a/app/assets/javascripts/work_items/components/update_work_item.js b/app/assets/javascripts/work_items/components/update_work_item.js
new file mode 100644
index 00000000000..fc395fa5be3
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/update_work_item.js
@@ -0,0 +1,23 @@
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
+
+export function getUpdateWorkItemMutation({ input, workItemParentId }) {
+ let mutation = updateWorkItemMutation;
+
+ const variables = {
+ input,
+ };
+
+ if (workItemParentId) {
+ mutation = updateWorkItemTaskMutation;
+ variables.input = {
+ id: workItemParentId,
+ taskData: input,
+ };
+ }
+
+ return {
+ mutation,
+ variables,
+ };
+}
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
new file mode 100644
index 00000000000..4d1c171772e
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlTokenSelector, GlIcon, GlAvatar, GlLink } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+
+function isClosingIcon(el) {
+ return el?.classList.contains('gl-token-close');
+}
+
+export default {
+ components: {
+ GlTokenSelector,
+ GlIcon,
+ GlAvatar,
+ GlLink,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ assignees: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isEditing: false,
+ localAssignees: this.assignees.map((assignee) => ({
+ ...assignee,
+ class: 'gl-bg-transparent!',
+ })),
+ };
+ },
+ computed: {
+ assigneeIds() {
+ return this.localAssignees.map((assignee) => assignee.id);
+ },
+ assigneeListEmpty() {
+ return this.assignees.length === 0;
+ },
+ containerClass() {
+ return !this.isEditing ? 'gl-shadow-none! gl-bg-transparent!' : '';
+ },
+ },
+ methods: {
+ getUserId(id) {
+ return getIdFromGraphQLId(id);
+ },
+ setAssignees(e) {
+ if (isClosingIcon(e.relatedTarget) || !this.isEditing) return;
+ this.isEditing = false;
+ this.$apollo.mutate({
+ mutation: localUpdateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneeIds: this.assigneeIds,
+ },
+ },
+ });
+ },
+ async focusTokenSelector() {
+ this.isEditing = true;
+ await this.$nextTick();
+ this.$refs.tokenSelector.focusTextInput();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-mb-4 work-item-assignees gl-relative">
+ <span class="gl-font-weight-bold gl-w-15 gl-pt-2" data-testid="assignees-title">{{
+ __('Assignee(s)')
+ }}</span>
+ <gl-token-selector
+ ref="tokenSelector"
+ v-model="localAssignees"
+ hide-dropdown-with-no-items
+ :container-class="containerClass"
+ class="gl-w-full gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base"
+ @token-remove="focusTokenSelector"
+ @focus="isEditing = true"
+ @blur="setAssignees"
+ >
+ <template #empty-placeholder>
+ <div
+ class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-top-2"
+ data-testid="empty-state"
+ >
+ <gl-icon name="profile" />
+ <span class="gl-ml-2">{{ __('Add assignees') }}</span>
+ </div>
+ </template>
+ <template #token-content="{ token }">
+ <gl-link
+ :href="token.webUrl"
+ :title="token.name"
+ :data-user-id="getUserId(token.id)"
+ data-placement="top"
+ class="gl-text-decoration-none! gl-text-body! gl-display-flex gl-md-display-inline-flex! gl-align-items-center js-user-link"
+ >
+ <gl-avatar :size="24" :src="token.avatarUrl" />
+ <span class="gl-pl-2">{{ token.name }}</span>
+ </gl-link>
+ </template>
+ </gl-token-selector>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
new file mode 100644
index 00000000000..5a85fcdd7ac
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -0,0 +1,234 @@
+<script>
+import { GlButton, GlFormGroup, GlSafeHtmlDirective } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import workItemQuery from '../graphql/work_item.query.graphql';
+import updateWorkItemWidgetsMutation from '../graphql/update_work_item_widgets.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
+
+export default {
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ components: {
+ GlButton,
+ GlFormGroup,
+ MarkdownField,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['fullPath'],
+ props: {
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ },
+ markdownDocsPath: helpPagePath('user/markdown'),
+ data() {
+ return {
+ workItem: {},
+ isEditing: false,
+ isSubmitting: false,
+ isSubmittingWithKeydown: false,
+ desc: '',
+ };
+ },
+ apollo: {
+ workItem: {
+ query: workItemQuery,
+ variables() {
+ return {
+ id: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemId;
+ },
+ error() {
+ this.error = i18n.fetchError;
+ },
+ },
+ },
+ computed: {
+ autosaveKey() {
+ return this.workItemId;
+ },
+ canEdit() {
+ return this.workItem?.userPermissions?.updateWorkItem;
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_description',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ descriptionHtml() {
+ return this.workItemDescription?.descriptionHtml;
+ },
+ descriptionText: {
+ get() {
+ return this.desc;
+ },
+ set(desc) {
+ this.desc = desc;
+ },
+ },
+ workItemDescription() {
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
+ },
+ workItemType() {
+ return this.workItem?.workItemType?.name;
+ },
+ markdownPreviewPath() {
+ return `${gon.relative_url_root || ''}/${this.fullPath}/preview_markdown?target_type=${
+ this.workItemType
+ }`;
+ },
+ },
+ methods: {
+ async startEditing() {
+ this.isEditing = true;
+
+ this.desc = getDraft(this.autosaveKey) || this.workItemDescription?.description || '';
+
+ await this.$nextTick();
+
+ this.$refs.textarea.focus();
+ },
+ async cancelEditing() {
+ const isDirty = this.desc !== this.workItemDescription?.description;
+
+ if (isDirty) {
+ const msg = s__('WorkItem|Are you sure you want to cancel editing?');
+
+ const confirmed = await confirmAction(msg, {
+ primaryBtnText: __('Discard changes'),
+ cancelBtnText: __('Continue editing'),
+ });
+
+ if (!confirmed) {
+ return;
+ }
+ }
+
+ this.isEditing = false;
+ clearDraft(this.autosaveKey);
+ },
+ onInput() {
+ if (this.isSubmittingWithKeydown) {
+ return;
+ }
+
+ updateDraft(this.autosaveKey, this.desc);
+ },
+ async updateWorkItem(event) {
+ if (event.key) {
+ this.isSubmittingWithKeydown = true;
+ }
+
+ this.isSubmitting = true;
+
+ try {
+ this.track('updated_description');
+
+ const {
+ data: { workItemUpdateWidgets },
+ } = await this.$apollo.mutate({
+ mutation: updateWorkItemWidgetsMutation,
+ variables: {
+ input: {
+ id: this.workItem.id,
+ descriptionWidget: {
+ description: this.descriptionText,
+ },
+ },
+ },
+ });
+
+ if (workItemUpdateWidgets.errors?.length) {
+ throw new Error(workItemUpdateWidgets.errors[0]);
+ }
+
+ this.isEditing = false;
+ clearDraft(this.autosaveKey);
+ } catch (error) {
+ this.$emit('error', error.message);
+ Sentry.captureException(error);
+ }
+
+ this.isSubmitting = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ v-if="isEditing"
+ class="gl-pt-5 gl-mb-5 gl-mt-0! gl-border-t! gl-border-b"
+ :label="__('Description')"
+ label-for="work-item-description"
+ label-class="gl-float-left"
+ >
+ <div class="gl-display-flex gl-justify-content-flex-end">
+ <gl-button class="gl-ml-auto" data-testid="cancel" @click="cancelEditing">{{
+ __('Cancel')
+ }}</gl-button>
+ <gl-button
+ class="js-no-auto-disable gl-ml-4"
+ category="primary"
+ variant="confirm"
+ :loading="isSubmitting"
+ data-testid="save-description"
+ @click="updateWorkItem"
+ >{{ __('Save') }}</gl-button
+ >
+ </div>
+ <markdown-field
+ can-attach-file
+ :textarea-value="descriptionText"
+ :is-submitting="isSubmitting"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="$options.markdownDocsPath"
+ class="gl-p-3 bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ id="work-item-description"
+ ref="textarea"
+ v-model="descriptionText"
+ :disabled="isSubmitting"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files hereā€¦')"
+ @keydown.meta.enter="updateWorkItem"
+ @keydown.ctrl.enter="updateWorkItem"
+ @keydown.exact.esc.stop="cancelEditing"
+ @input="onInput"
+ ></textarea>
+ </template>
+ </markdown-field>
+ </gl-form-group>
+ <div v-else class="gl-pt-5 gl-mb-5 gl-border-t gl-border-b">
+ <div class="gl-display-flex">
+ <h3 class="gl-font-base gl-mt-0">{{ __('Description') }}</h3>
+ <gl-button
+ v-if="canEdit"
+ class="gl-ml-auto"
+ icon="pencil"
+ data-testid="edit-description"
+ @click="startEditing"
+ >{{ __('Edit') }}</gl-button
+ >
+ </div>
+ <div v-safe-html="descriptionHtml" class="md gl-mb-5"></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 4222ffe42fe..5272df2d53f 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,27 +1,45 @@
<script>
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
-import { i18n } from '../constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+ i18n,
+ WIDGET_TYPE_ASSIGNEE,
+ WIDGET_TYPE_DESCRIPTION,
+ WIDGET_TYPE_WEIGHT,
+} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
+import WorkItemDescription from './work_item_description.vue';
+import WorkItemAssignees from './work_item_assignees.vue';
+import WorkItemWeight from './work_item_weight.vue';
export default {
i18n,
components: {
GlAlert,
GlSkeletonLoader,
+ WorkItemAssignees,
WorkItemActions,
+ WorkItemDescription,
WorkItemTitle,
WorkItemState,
+ WorkItemWeight,
},
+ mixins: [glFeatureFlagMixin()],
props: {
workItemId: {
type: String,
required: false,
default: null,
},
+ workItemParentId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -66,6 +84,18 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ workItemsMvc2Enabled() {
+ return this.glFeatures.workItemsMvc2;
+ },
+ hasDescriptionWidget() {
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION);
+ },
+ workItemAssignees() {
+ return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEE);
+ },
+ workItemWeight() {
+ return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
+ },
},
};
</script>
@@ -83,27 +113,40 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div class="gl-display-flex">
+ <div class="gl-display-flex gl-align-items-start">
<work-item-title
:work-item-id="workItem.id"
:work-item-title="workItem.title"
:work-item-type="workItemType"
+ :work-item-parent-id="workItemParentId"
class="gl-mr-5"
@error="error = $event"
- @updated="$emit('workItemUpdated')"
/>
<work-item-actions
:work-item-id="workItem.id"
:can-delete="canDelete"
- class="gl-ml-auto gl-mt-5"
+ class="gl-ml-auto gl-mt-6"
@deleteWorkItem="$emit('deleteWorkItem')"
@error="error = $event"
/>
</div>
+ <template v-if="workItemsMvc2Enabled">
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.nodes"
+ />
+ <work-item-weight v-if="workItemWeight" :weight="workItemWeight.weight" />
+ </template>
<work-item-state
:work-item="workItem"
+ :work-item-parent-id="workItemParentId"
+ @error="error = $event"
+ />
+ <work-item-description
+ v-if="hasDescriptionWidget"
+ :work-item-id="workItem.id"
@error="error = $event"
- @updated="$emit('workItemUpdated')"
/>
</template>
</section>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
index 172a40a6e56..d1c8022ac57 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue
@@ -37,7 +37,7 @@ export default {
default: null,
},
},
- emits: ['workItemDeleted', 'workItemUpdated', 'close'],
+ emits: ['workItemDeleted', 'close'],
data() {
return {
error: undefined,
@@ -98,15 +98,24 @@ export default {
</script>
<template>
- <gl-modal ref="modal" hide-footer size="lg" modal-id="work-item-detail-modal" @hide="closeModal">
+ <gl-modal
+ ref="modal"
+ hide-footer
+ size="lg"
+ modal-id="work-item-detail-modal"
+ header-class="gl-p-0 gl-pb-2!"
+ body-class="gl-pb-6!"
+ @hide="closeModal"
+ >
<gl-alert v-if="error" variant="danger" @dismiss="error = false">
{{ error }}
</gl-alert>
<work-item-detail
+ :work-item-parent-id="issueGid"
:work-item-id="workItemId"
+ class="gl-p-5 gl-mt-n3"
@deleteWorkItem="deleteWorkItem"
- @workItemUpdated="$emit('workItemUpdated')"
/>
</gl-modal>
</template>
@@ -114,7 +123,7 @@ export default {
<style>
/* hide the existing modal header
*/
-#work-item-detail-modal .modal-header {
+#work-item-detail-modal .modal-header * {
display: none;
}
</style>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js
new file mode 100644
index 00000000000..320a4a213e3
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -0,0 +1,37 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import WorkItemLinks from './work_item_links.vue';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export default function initWorkItemLinks() {
+ if (!window.gon.features.workItemsHierarchy) {
+ return;
+ }
+
+ const workItemLinksRoot = document.querySelector('.js-work-item-links-root');
+
+ if (!workItemLinksRoot) {
+ return;
+ }
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: workItemLinksRoot,
+ name: 'WorkItemLinksRoot',
+ apolloProvider,
+ components: {
+ workItemLinks: WorkItemLinks,
+ },
+ render: (createElement) =>
+ createElement('work-item-links', {
+ props: {
+ issuableId: parseInt(workItemLinksRoot.dataset.issuableId, 10),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
new file mode 100644
index 00000000000..bdfff100333
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -0,0 +1,165 @@
+<script>
+import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import {
+ STATE_OPEN,
+ WIDGET_ICONS,
+ WORK_ITEM_STATUS_TEXT,
+ WIDGET_TYPE_HIERARCHY,
+} from '../../constants';
+import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import WorkItemLinksForm from './work_item_links_form.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlBadge,
+ GlIcon,
+ GlLoadingIcon,
+ WorkItemLinksForm,
+ },
+ props: {
+ workItemId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ issuableId: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ apollo: {
+ children: {
+ query: getWorkItemLinksQuery,
+ variables() {
+ return {
+ id: this.issuableGid,
+ };
+ },
+ update(data) {
+ return (
+ data.workItem.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
+ .nodes ?? []
+ );
+ },
+ skip() {
+ return !this.issuableId;
+ },
+ },
+ },
+ data() {
+ return {
+ isShownAddForm: false,
+ isOpen: true,
+ children: [],
+ };
+ },
+ computed: {
+ // Only used for children for now but should be extended later to support parents and siblings
+ isChildrenEmpty() {
+ return this.children?.length === 0;
+ },
+ toggleIcon() {
+ return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
+ },
+ toggleLabel() {
+ return this.isOpen
+ ? s__('WorkItem|Collapse child items')
+ : s__('WorkItem|Expand child items');
+ },
+ issuableGid() {
+ return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null;
+ },
+ isLoading() {
+ return this.$apollo.queries.children.loading;
+ },
+ },
+ methods: {
+ badgeVariant(state) {
+ return state === STATE_OPEN ? 'success' : 'info';
+ },
+ toggle() {
+ this.isOpen = !this.isOpen;
+ },
+ toggleAddForm() {
+ this.isShownAddForm = !this.isShownAddForm;
+ },
+ },
+ i18n: {
+ title: s__('WorkItem|Child items'),
+ emptyStateMessage: s__(
+ 'WorkItem|No child items are currently assigned. Use child items to prioritize tasks that your team should complete in order to accomplish your goals!',
+ ),
+ addChildButtonLabel: s__('WorkItem|Add a child'),
+ },
+ WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
+ WORK_ITEM_STATUS_TEXT,
+};
+</script>
+
+<template>
+ <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10">
+ <div
+ class="gl-p-4 gl-display-flex gl-justify-content-space-between"
+ :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
+ >
+ <h5 class="gl-m-0 gl-line-height-32">{{ $options.i18n.title }}</h5>
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4">
+ <gl-button
+ category="tertiary"
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ data-testid="toggle-links"
+ @click="toggle"
+ />
+ </div>
+ </div>
+ <div
+ v-if="isOpen"
+ class="gl-bg-gray-10 gl-p-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ data-testid="links-body"
+ >
+ <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
+
+ <template v-else>
+ <div v-if="isChildrenEmpty" class="gl-px-8" data-testid="links-empty">
+ <p>
+ {{ $options.i18n.emptyStateMessage }}
+ </p>
+ <gl-button
+ v-if="!isShownAddForm"
+ category="secondary"
+ variant="confirm"
+ data-testid="toggle-add-form"
+ @click="toggleAddForm"
+ >
+ {{ $options.i18n.addChildButtonLabel }}
+ </gl-button>
+ <work-item-links-form v-else data-testid="add-links-form" @cancel="toggleAddForm" />
+ </div>
+ <div
+ v-for="child in children"
+ :key="child.id"
+ class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ data-testid="links-child"
+ >
+ <div>
+ <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" />
+ <span class="gl-word-break-all">{{ child.title }}</span>
+ </div>
+ <div class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0">
+ <gl-badge :variant="badgeVariant(child.state)">
+ <span class="gl-sm-display-block">{{
+ $options.WORK_ITEM_STATUS_TEXT[child.state]
+ }}</span>
+ </gl-badge>
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
new file mode 100644
index 00000000000..22728f58026
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlForm, GlFormInput, GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlForm,
+ GlFormInput,
+ GlButton,
+ },
+ data() {
+ return {
+ relatedWorkItem: '',
+ };
+ },
+};
+</script>
+
+<template>
+ <gl-form @submit.prevent>
+ <gl-form-input v-model="relatedWorkItem" class="gl-mb-4" />
+ <gl-button type="submit" category="secondary" variant="confirm">
+ {{ s__('WorkItem|Add') }}
+ </gl-button>
+ <gl-button category="tertiary" class="gl-float-right" @click="$emit('cancel')">
+ {{ s__('WorkItem|Cancel') }}
+ </gl-button>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state.vue
index 51db4c804eb..87f4a8822b1 100644
--- a/app/assets/javascripts/work_items/components/work_item_state.vue
+++ b/app/assets/javascripts/work_items/components/work_item_state.vue
@@ -7,8 +7,9 @@ import {
STATE_CLOSED,
STATE_EVENT_CLOSE,
STATE_EVENT_REOPEN,
+ TRACKING_CATEGORY_SHOW,
} from '../constants';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import { getUpdateWorkItemMutation } from './update_work_item';
import ItemState from './item_state.vue';
export default {
@@ -21,6 +22,11 @@ export default {
type: Object,
required: true,
},
+ workItemParentId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -33,14 +39,14 @@ export default {
},
tracking() {
return {
- category: 'workItems:show',
+ category: TRACKING_CATEGORY_SHOW,
label: 'item_state',
property: `type_${this.workItemType}`,
};
},
},
methods: {
- async updateWorkItemState(newState) {
+ updateWorkItemState(newState) {
const stateEventMap = {
[STATE_OPEN]: STATE_EVENT_REOPEN,
[STATE_CLOSED]: STATE_EVENT_CLOSE,
@@ -48,35 +54,39 @@ export default {
const stateEvent = stateEventMap[newState];
- await this.updateWorkItem(stateEvent);
+ this.updateWorkItem(stateEvent);
},
+
async updateWorkItem(updatedState) {
if (!updatedState) {
return;
}
+ const input = {
+ id: this.workItem.id,
+ stateEvent: updatedState,
+ };
+
this.updateInProgress = true;
try {
this.track('updated_state');
- const {
- data: { workItemUpdate },
- } = await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: {
- input: {
- id: this.workItem.id,
- stateEvent: updatedState,
- },
- },
+ const { mutation, variables } = getUpdateWorkItemMutation({
+ workItemParentId: this.workItemParentId,
+ input,
});
- if (workItemUpdate?.errors?.length) {
- throw new Error(workItemUpdate.errors[0]);
- }
+ const { data } = await this.$apollo.mutate({
+ mutation,
+ variables,
+ });
- this.$emit('updated');
+ const errors = data.workItemUpdate?.errors;
+
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
} catch (error) {
this.$emit('error', i18n.updateError);
Sentry.captureException(error);
diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue
index d2e6d3c0bbf..b4c13037038 100644
--- a/app/assets/javascripts/work_items/components/work_item_title.vue
+++ b/app/assets/javascripts/work_items/components/work_item_title.vue
@@ -1,7 +1,8 @@
<script>
+import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
-import { i18n } from '../constants';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import { getUpdateWorkItemMutation } from './update_work_item';
import ItemTitle from './item_title.vue';
export default {
@@ -25,11 +26,16 @@ export default {
required: false,
default: '',
},
+ workItemParentId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
tracking() {
return {
- category: 'workItems:show',
+ category: TRACKING_CATEGORY_SHOW,
label: 'item_title',
property: `type_${this.workItemType}`,
};
@@ -41,21 +47,37 @@ export default {
return;
}
+ const input = {
+ id: this.workItemId,
+ title: updatedTitle,
+ };
+
+ this.updateInProgress = true;
+
try {
- await this.$apollo.mutate({
- mutation: updateWorkItemMutation,
- variables: {
- input: {
- id: this.workItemId,
- title: updatedTitle,
- },
- },
- });
this.track('updated_title');
- this.$emit('updated');
- } catch {
+
+ const { mutation, variables } = getUpdateWorkItemMutation({
+ workItemParentId: this.workItemParentId,
+ input,
+ });
+
+ const { data } = await this.$apollo.mutate({
+ mutation,
+ variables,
+ });
+
+ const errors = data.workItemUpdate?.errors;
+
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ } catch (error) {
this.$emit('error', i18n.updateError);
+ Sentry.captureException(error);
}
+
+ this.updateInProgress = false;
},
},
};
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
new file mode 100644
index 00000000000..b0f2b3aa14a
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -0,0 +1,26 @@
+<script>
+import { __ } from '~/locale';
+
+export default {
+ inject: ['hasIssueWeightsFeature'],
+ props: {
+ weight: {
+ type: Number,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ weightText() {
+ return this.weight ?? __('None');
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="hasIssueWeightsFeature" class="gl-mb-5">
+ <span class="gl-display-inline-block gl-font-weight-bold gl-w-15">{{ __('Weight') }}</span>
+ {{ weightText }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index e914500108f..2df4978a319 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -6,9 +6,27 @@ export const STATE_CLOSED = 'CLOSED';
export const STATE_EVENT_REOPEN = 'REOPEN';
export const STATE_EVENT_CLOSE = 'CLOSE';
+export const TRACKING_CATEGORY_SHOW = 'workItems:show';
+
export const i18n = {
fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
};
export const DEFAULT_MODAL_TYPE = 'Task';
+
+export const WIDGET_TYPE_ASSIGNEE = 'ASSIGNEES';
+export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
+export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
+
+export const WIDGET_TYPE_TASK_ICON = 'task-done';
+
+export const WIDGET_ICONS = {
+ TASK: 'task-done',
+};
+
+export const WORK_ITEM_STATUS_TEXT = {
+ CLOSED: s__('WorkItem|Closed'),
+ OPEN: s__('WorkItem|Open'),
+};
diff --git a/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
new file mode 100644
index 00000000000..0d31ecef6f8
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql
@@ -0,0 +1,9 @@
+#import "./work_item.fragment.graphql"
+
+mutation localUpdateWorkItem($input: LocalWorkItemAssigneesInput) {
+ localUpdateWorkItem(input: $input) @client {
+ workItem {
+ ...WorkItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 3c2955ce1e2..09d929faae2 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -1,11 +1,93 @@
+import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { WIDGET_TYPE_ASSIGNEE } from '../constants';
+import typeDefs from './typedefs.graphql';
+import workItemQuery from './work_item.query.graphql';
+
+export const temporaryConfig = {
+ typeDefs,
+ cacheConfig: {
+ possibleTypes: {
+ LocalWorkItemWidget: ['LocalWorkItemAssignees'],
+ },
+ typePolicies: {
+ WorkItem: {
+ fields: {
+ mockWidgets: {
+ read(widgets) {
+ return (
+ widgets || [
+ {
+ __typename: 'LocalWorkItemAssignees',
+ type: 'ASSIGNEES',
+ nodes: [
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl: '',
+ webUrl: '',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'John Doe',
+ username: 'doe_I',
+ },
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/2',
+ avatarUrl: '',
+ webUrl: '',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Marcus Rutherford',
+ username: 'ruthfull',
+ },
+ ],
+ },
+ {
+ __typename: 'LocalWorkItemWeight',
+ type: 'WEIGHT',
+ weight: 0,
+ },
+ ]
+ );
+ },
+ },
+ },
+ },
+ },
+ },
+};
+
+export const resolvers = {
+ Mutation: {
+ localUpdateWorkItem(_, { input }, { cache }) {
+ const sourceData = cache.readQuery({
+ query: workItemQuery,
+ variables: { id: input.id },
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ const assigneesWidget = draftData.workItem.mockWidgets.find(
+ (widget) => widget.type === WIDGET_TYPE_ASSIGNEE,
+ );
+ assigneesWidget.nodes = assigneesWidget.nodes.filter((assignee) =>
+ input.assigneeIds.includes(assignee.id),
+ );
+ });
+
+ cache.writeQuery({
+ query: workItemQuery,
+ variables: { id: input.id },
+ data,
+ });
+ },
+ },
+};
export function createApolloProvider() {
Vue.use(VueApollo);
- const defaultClient = createDefaultClient();
+ const defaultClient = createDefaultClient(resolvers, temporaryConfig);
return new VueApollo({
defaultClient,
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
new file mode 100644
index 00000000000..bfe2f0fe0ce
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -0,0 +1,36 @@
+enum LocalWidgetType {
+ ASSIGNEES
+ WEIGHT
+}
+
+interface LocalWorkItemWidget {
+ type: LocalWidgetType!
+}
+
+type LocalWorkItemAssignees implements LocalWorkItemWidget {
+ type: LocalWidgetType!
+ nodes: [UserCore]
+}
+
+type LocalWorkItemWeight implements LocalWorkItemWidget {
+ type: LocalWidgetType!
+ weight: Int
+}
+
+extend type WorkItem {
+ mockWidgets: [LocalWorkItemWidget]
+}
+
+type LocalWorkItemAssigneesInput {
+ id: WorkItemID!
+ assigneeIds: [ID!]
+}
+
+type LocalWorkItemPayload {
+ workItem: WorkItem!
+ errors: [String!]
+}
+
+extend type Mutation {
+ localUpdateWorkItem(input: LocalWorkItemAssigneesInput!): LocalWorkItemPayload
+}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
new file mode 100644
index 00000000000..470de060ee3
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql
@@ -0,0 +1,8 @@
+mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) {
+ workItemUpdate: workItemUpdateTask(input: $input) {
+ workItem {
+ id
+ descriptionHtml
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
new file mode 100644
index 00000000000..148b340b439
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
@@ -0,0 +1,10 @@
+#import "./work_item.fragment.graphql"
+
+mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) {
+ workItemUpdateWidgets(input: $input) {
+ workItem {
+ ...WorkItem
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index e25fd102699..04701f6899e 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -11,4 +11,11 @@ fragment WorkItem on WorkItem {
deleteWorkItem
updateWorkItem
}
+ widgets {
+ ... on WorkItemWidgetDescription {
+ type
+ description
+ descriptionHtml
+ }
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
index 3b46fed97ec..30bc61f5c59 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -3,5 +3,21 @@
query workItem($id: WorkItemID!) {
workItem(id: $id) {
...WorkItem
+ mockWidgets @client {
+ ... on LocalWorkItemAssignees {
+ type
+ nodes {
+ id
+ avatarUrl
+ name
+ username
+ webUrl
+ }
+ }
+ ... on LocalWorkItemWeight {
+ type
+ weight
+ }
+ }
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
new file mode 100644
index 00000000000..c2496f53cc8
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -0,0 +1,28 @@
+query workItemQuery($id: WorkItemID!) {
+ workItem(id: $id) {
+ id
+ workItemType {
+ id
+ }
+ title
+ widgets {
+ type
+ ... on WorkItemWidgetHierarchy {
+ type
+ parent {
+ id
+ }
+ children {
+ nodes {
+ id
+ workItemType {
+ id
+ }
+ title
+ state
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index e39b0d6a353..33e28831b54 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -1,11 +1,12 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import App from './components/app.vue';
import { createRouter } from './router';
import { createApolloProvider } from './graphql/provider';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
- const { fullPath, issuesListPath } = el.dataset;
+ const { fullPath, hasIssueWeightsFeature, issuesListPath } = el.dataset;
return new Vue({
el,
@@ -13,6 +14,7 @@ export const initWorkItemsRoot = () => {
apolloProvider: createApolloProvider(),
provide: {
fullPath,
+ hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
issuesListPath,
},
render(createElement) {
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index 6dc3dc3b3c9..e9840889bdb 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -4,6 +4,7 @@ import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
+import ZenMode from '~/zen_mode';
import WorkItemDetail from '../components/work_item_detail.vue';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
@@ -29,6 +30,9 @@ export default {
return convertToGraphQLId(TYPE_WORK_ITEM, this.id);
},
},
+ mounted() {
+ this.ZenMode = new ZenMode();
+ },
methods: {
deleteWorkItem() {
this.$apollo