summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/auth_buttons/jwt_64.pngbin2457 -> 824 bytes
-rw-r--r--app/assets/images/auth_buttons/salesforce_64.pngbin8774 -> 2012 bytes
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue30
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_field.vue69
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_token_selector.vue156
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql1
-rw-r--r--app/assets/javascripts/access_tokens/index.js57
-rw-r--r--app/assets/javascripts/admin/deploy_keys/components/table.vue8
-rw-r--r--app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql1
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js26
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql1
-rw-r--r--app/assets/javascripts/api/analytics_api.js3
-rw-r--r--app/assets/javascripts/api/groups_api.js7
-rw-r--r--app/assets/javascripts/attention_requests/components/navigation_popover.vue122
-rw-r--r--app/assets/javascripts/attention_requests/index.js73
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue6
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue28
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue39
-rw-r--r--app/assets/javascripts/batch_comments/i18n.js3
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js5
-rw-r--r--app/assets/javascripts/behaviors/components/json_table.vue71
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/strike.js29
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/code_block.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js44
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_json_table.js70
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js231
-rw-r--r--app/assets/javascripts/blob/blob_links_tracking.js25
-rw-r--r--app/assets/javascripts/boards/boards_util.js4
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue29
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue3
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue5
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_members.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql2
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_members.query.graphql2
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js2
-rw-r--r--app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue101
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue49
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue104
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue212
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue86
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue75
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue6
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js59
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql7
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql16
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql17
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql13
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/resolvers.js113
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js29
-rw-r--r--app/assets/javascripts/ci_variable_list/store/utils.js6
-rw-r--r--app/assets/javascripts/ci_variable_list/utils.js50
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue2
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue60
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js21
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue24
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue9
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue11
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue55
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue25
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js3
-rw-r--r--app/assets/javascripts/content_editor/extensions/diagram.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/frontmatter.js7
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference_definition.js29
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_of_contents.js15
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js6
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js9
-rw-r--r--app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js82
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js36
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js67
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js56
-rw-r--r--app/assets/javascripts/content_editor/services/table_of_contents_utils.js67
-rw-r--r--app/assets/javascripts/contributors/stores/getters.js6
-rw-r--r--app/assets/javascripts/crm/components/form.vue20
-rw-r--r--app/assets/javascripts/crm/constants.js4
-rw-r--r--app/assets/javascripts/crm/contacts/bundle.js18
-rw-r--r--app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue9
-rw-r--r--app/assets/javascripts/crm/contacts/components/contacts_root.vue220
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql3
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql30
-rw-r--r--app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql11
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql2
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql1
-rw-r--r--app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue27
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue12
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js25
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js23
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js5
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue1
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue1
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql2
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql1
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql2
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue2
-rw-r--r--app/assets/javascripts/diff.js3
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue9
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue85
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue22
-rw-r--r--app/assets/javascripts/diffs/constants.js3
-rw-r--r--app/assets/javascripts/diffs/store/actions.js12
-rw-r--r--app/assets/javascripts/dropzone_input.js17
-rw-r--r--app/assets/javascripts/editor/components/source_editor_toolbar_button.vue1
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js5
-rw-r--r--app/assets/javascripts/editor/graphql/typedefs.graphql14
-rw-r--r--app/assets/javascripts/editor/schema/ci.json44
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue1
-rw-r--r--app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql1
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js19
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js4
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue1
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js74
-rw-r--r--app/assets/javascripts/gitlab_pages/new.js39
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql7
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql4
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json9
-rw-r--r--app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql3
-rw-r--r--app/assets/javascripts/group_settings/components/shared_runners_form.vue7
-rw-r--r--app/assets/javascripts/group_settings/mount_shared_runners.js4
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue16
-rw-r--r--app/assets/javascripts/groups/components/group_name_and_path.vue215
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/groups/components/transfer_group_form.vue1
-rw-r--r--app/assets/javascripts/groups/constants.js29
-rw-r--r--app/assets/javascripts/groups/create_edit_form.js10
-rw-r--r--app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql11
-rw-r--r--app/assets/javascripts/header_search/components/app.vue51
-rw-r--r--app/assets/javascripts/header_search/constants.js4
-rw-r--r--app/assets/javascripts/header_search/index.js11
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue4
-rw-r--r--app/assets/javascripts/ide/components/file_templates/bar.vue7
-rw-r--r--app/assets/javascripts/ide/components/ide_project_header.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue2
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js2
-rw-r--r--app/assets/javascripts/integrations/edit/components/dynamic_field.vue7
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue6
-rw-r--r--app/assets/javascripts/integrations/overrides/components/integration_overrides.vue1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue70
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue43
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue8
-rw-r--r--app/assets/javascripts/invite_members/constants.js13
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js9
-rw-r--r--app/assets/javascripts/issuable/components/issue_milestone.vue4
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue51
-rw-r--r--app/assets/javascripts/issuable/issuable_template_selector.js16
-rw-r--r--app/assets/javascripts/issuable/popover/components/issue_popover.vue54
-rw-r--r--app/assets/javascripts/issuable/popover/queries/issue.query.graphql9
-rw-r--r--app/assets/javascripts/issues/index.js2
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue42
-rw-r--r--app/assets/javascripts/issues/list/constants.js19
-rw-r--r--app/assets/javascripts/issues/list/queries/issue.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue2
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue9
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js5
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue117
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue157
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue (renamed from app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue)12
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue27
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue15
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js1
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue16
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue1
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue94
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_job_details_container.vue9
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql4
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue11
-rw-r--r--app/assets/javascripts/labels/labels_select.js2
-rw-r--r--app/assets/javascripts/lib/dompurify.js37
-rw-r--r--app/assets/javascripts/lib/gfm/index.js50
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/markdown_it.js11
-rw-r--r--app/assets/javascripts/lib/prosemirror_markdown_serializer.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js242
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js25
-rw-r--r--app/assets/javascripts/lib/utils/yaml.js21
-rw-r--r--app/assets/javascripts/linked_resources/index.js11
-rw-r--r--app/assets/javascripts/main.js6
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue5
-rw-r--r--app/assets/javascripts/members/constants.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/utils.js7
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue4
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js64
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js8
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue28
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue106
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue3
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js6
-rw-r--r--app/assets/javascripts/notes/utils.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/index.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue109
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue35
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue107
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue30
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue25
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js4
-rw-r--r--app/assets/javascripts/pages/groups/new/components/app.vue73
-rw-r--r--app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue56
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js11
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue2
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/ci/secure_files/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js23
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue42
-rw-r--r--app/assets/javascripts/pages/projects/init_blob.js9
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pages/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js41
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue42
-rw-r--r--app/assets/javascripts/pages/projects/tags/releases/index.js6
-rw-r--r--app/assets/javascripts/pages/registrations/new/index.js5
-rw-r--r--app/assets/javascripts/pages/sessions/new/email_format_validator.js46
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue55
-rw-r--r--app/assets/javascripts/persistent_user_callout.js4
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue53
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue20
-rw-r--r--app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue20
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js8
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql1
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql2
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js2
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue34
-rw-r--r--app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue52
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue9
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/performance_insights_modal.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_tabs.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue40
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue16
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue55
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue54
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js13
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js33
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js1
-rw-r--r--app/assets/javascripts/projects/compare/components/app.vue20
-rw-r--r--app/assets/javascripts/projects/compare/index.js12
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js2
-rw-r--r--app/assets/javascripts/projects/project_new.js24
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue87
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue59
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue46
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue52
-rw-r--r--app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue44
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue4
-rw-r--r--app/assets/javascripts/projects/settings/components/transfer_project_form.vue94
-rw-r--r--app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql24
-rw-r--r--app/assets/javascripts/projects/settings/init_transfer_project_form.js22
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue1
-rw-r--r--app/assets/javascripts/related_issues/components/add_issuable_form.vue7
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue76
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_list.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue9
-rw-r--r--app/assets/javascripts/related_issues/constants.js10
-rw-r--r--app/assets/javascripts/related_issues/index.js51
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue9
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue12
-rw-r--r--app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql15
-rw-r--r--app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql88
-rw-r--r--app/assets/javascripts/releases/util.js1
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue1
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue74
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue9
-rw-r--r--app/assets/javascripts/repository/constants.js1
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql1
-rw-r--r--app/assets/javascripts/right_sidebar.js32
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue34
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue6
-rw-r--r--app/assets/javascripts/runner/components/runner_assigned_item.vue22
-rw-r--r--app/assets/javascripts/runner/components/runner_bulk_delete.vue149
-rw-r--r--app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue59
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue9
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue25
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue8
-rw-r--r--app/assets/javascripts/runner/components/runner_jobs.vue11
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue15
-rw-r--r--app/assets/javascripts/runner/components/runner_pagination.vue67
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue22
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_count.vue7
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_single_stat.vue41
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue74
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_status_stat.vue65
-rw-r--r--app/assets/javascripts/runner/constants.js3
-rw-r--r--app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners.query.graphql13
-rw-r--r--app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql13
-rw-r--r--app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql6
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql16
-rw-r--r--app/assets/javascripts/runner/graphql/list/group_runners.query.graphql16
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql2
-rw-r--r--app/assets/javascripts/runner/graphql/list/local_state.js18
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql1
-rw-r--r--app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql4
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue32
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js71
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js1
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/attention_requested_toggle.vue105
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewers.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue64
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue22
-rw-r--r--app/assets/javascripts/sidebar/components/severity/severity.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue8
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js17
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_participants.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_reference.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/epic_todo.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/group_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_reference.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_todo.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql2
-rw-r--r--app/assets/javascripts/sidebar/queries/project_milestones.query.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql7
-rw-r--r--app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql5
-rw-r--r--app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql1
-rw-r--r--app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql2
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js23
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js12
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js87
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js34
-rw-r--r--app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql35
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.js10
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue9
-rw-r--r--app/assets/javascripts/task_list.js8
-rw-r--r--app/assets/javascripts/test_utils/index.js2
-rw-r--r--app/assets/javascripts/user_popovers.js4
-rw-r--r--app/assets/javascripts/visibility_level/constants.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue (renamed from app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue)12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue85
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue55
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue94
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue65
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue168
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue141
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue345
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue33
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue53
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue158
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/utils.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue50
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js14
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/gitlab_version_check.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue95
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_editor.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js39
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue30
-rw-r--r--app/assets/javascripts/vue_shared/constants.js27
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue12
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue24
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue7
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue2
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue2
-rw-r--r--app/assets/javascripts/whats_new/components/feature.vue29
-rw-r--r--app/assets/javascripts/work_items/components/item_state.vue10
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue5
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue55
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue95
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue10
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue150
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail_modal.vue41
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue3
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js16
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue269
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue133
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue79
-rw-r--r--app/assets/javascripts/work_items/components/work_item_state.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_title.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue44
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue40
-rw-r--r--app/assets/javascripts/work_items/constants.js44
-rw-r--r--app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql3
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/local_update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/provider.js28
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql26
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_task.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql6
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql6
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/components/avatar.scss12
-rw-r--r--app/assets/stylesheets/components/batch_comments/review_bar.scss2
-rw-r--r--app/assets/stylesheets/components/rich_content_editor.scss54
-rw-r--r--app/assets/stylesheets/framework/blocks.scss83
-rw-r--r--app/assets/stylesheets/framework/calendar.scss39
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss14
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar_header.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss28
-rw-r--r--app/assets/stylesheets/framework/files.scss17
-rw-r--r--app/assets/stylesheets/framework/highlight.scss58
-rw-r--r--app/assets/stylesheets/framework/mixins.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss19
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/sortable.scss2
-rw-r--r--app/assets/stylesheets/framework/timeline.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss29
-rw-r--r--app/assets/stylesheets/framework/variables.scss4
-rw-r--r--app/assets/stylesheets/framework/vue_transitions.scss2
-rw-r--r--app/assets/stylesheets/highlight/common.scss31
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss10
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss10
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss10
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/escalation_policies.scss16
-rw-r--r--app/assets/stylesheets/page_bundles/group.scss59
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss32
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss18
-rw-r--r--app/assets/stylesheets/page_bundles/profile.scss212
-rw-r--r--app/assets/stylesheets/page_bundles/runner_details.scss3
-rw-r--r--app/assets/stylesheets/pages/issuable.scss2
-rw-r--r--app/assets/stylesheets/pages/issues.scss2
-rw-r--r--app/assets/stylesheets/pages/login.scss14
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss9
-rw-r--r--app/assets/stylesheets/pages/notes.scss10
-rw-r--r--app/assets/stylesheets/pages/profile.scss94
-rw-r--r--app/assets/stylesheets/pages/search.scss37
-rw-r--r--app/assets/stylesheets/pages/settings_ci_cd.scss18
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss43
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss35
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss177
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss16
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss8
554 files changed, 8605 insertions, 5323 deletions
diff --git a/app/assets/images/auth_buttons/jwt_64.png b/app/assets/images/auth_buttons/jwt_64.png
index ca97ae47002..fcfecde23d3 100644
--- a/app/assets/images/auth_buttons/jwt_64.png
+++ b/app/assets/images/auth_buttons/jwt_64.png
Binary files differ
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png
index c8a86a0c515..b562e09c20f 100644
--- a/app/assets/images/auth_buttons/salesforce_64.png
+++ b/app/assets/images/auth_buttons/salesforce_64.png
Binary files differ
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 147de529eea..5516fd0daf6 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -1,7 +1,8 @@
<script>
-import { GlDatepicker, GlFormInput, GlFormGroup } from '@gitlab/ui';
+import { GlDatepicker, GlFormGroup } from '@gitlab/ui';
import { __ } from '~/locale';
+import { getDateInFuture } from '~/lib/utils/datetime_utility';
export default {
name: 'ExpiresAtField',
@@ -10,7 +11,6 @@ export default {
},
components: {
GlDatepicker,
- GlFormInput,
GlFormGroup,
MaxExpirationDateMessage: () =>
import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
@@ -32,20 +32,28 @@ export default {
default: () => null,
},
},
+ computed: {
+ in30Days() {
+ const today = new Date();
+ return getDateInFuture(today, 30);
+ },
+ },
};
</script>
<template>
<gl-form-group :label="$options.i18n.label" :label-for="inputAttrs.id">
- <gl-datepicker :target="null" :min-date="minDate" :max-date="maxDate">
- <gl-form-input
- v-bind="inputAttrs"
- class="datepicker gl-datepicker-input"
- autocomplete="off"
- inputmode="none"
- data-qa-selector="expiry_date_field"
- />
- </gl-datepicker>
+ <gl-datepicker
+ :target="null"
+ :min-date="minDate"
+ :max-date="maxDate"
+ :default-date="in30Days"
+ show-clear-button
+ :input-name="inputAttrs.name"
+ :input-id="inputAttrs.id"
+ :placeholder="inputAttrs.placeholder"
+ data-qa-selector="expiry_date_field"
+ />
<template #description>
<max-expiration-date-message :max-date="maxDate" />
</template>
diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue
deleted file mode 100644
index 066cea5e90c..00000000000
--- a/app/assets/javascripts/access_tokens/components/projects_field.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-<script>
-import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
-import ProjectsTokenSelector from './projects_token_selector.vue';
-
-export default {
- name: 'ProjectsField',
- ALL_PROJECTS: 'ALL_PROJECTS',
- SELECTED_PROJECTS: 'SELECTED_PROJECTS',
- components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
- props: {
- inputAttrs: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- selectedRadio: !this.inputAttrs.value
- ? this.$options.ALL_PROJECTS
- : this.$options.SELECTED_PROJECTS,
- selectedProjects: [],
- };
- },
- computed: {
- allProjectsRadioSelected() {
- return this.selectedRadio === this.$options.ALL_PROJECTS;
- },
- hiddenInputValue() {
- return this.allProjectsRadioSelected
- ? null
- : this.selectedProjects.map((project) => project.id).join(',');
- },
- initialProjectIds() {
- if (!this.inputAttrs.value) {
- return [];
- }
-
- return this.inputAttrs.value.split(',');
- },
- },
- methods: {
- handleTokenSelectorFocus() {
- this.selectedRadio = this.$options.SELECTED_PROJECTS;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <gl-form-group :label="__('Projects')" label-class="gl-pb-0!">
- <gl-form-text class="gl-pb-3">{{
- __('Set access permissions for this token.')
- }}</gl-form-text>
- <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{
- __('All projects')
- }}</gl-form-radio>
- <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
- __('Selected projects')
- }}</gl-form-radio>
- <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
- <projects-token-selector
- v-model="selectedProjects"
- :initial-project-ids="initialProjectIds"
- @focus="handleTokenSelectorFocus"
- />
- </gl-form-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
deleted file mode 100644
index 4843c52fcbb..00000000000
--- a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue
+++ /dev/null
@@ -1,156 +0,0 @@
-<script>
-import {
- GlTokenSelector,
- GlAvatar,
- GlAvatarLabeled,
- GlIntersectionObserver,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import produce from 'immer';
-
-import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils';
-
-import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
-
-const DEBOUNCE_DELAY = 250;
-const PROJECTS_PER_PAGE = 20;
-const GRAPHQL_ENTITY_TYPE = 'Project';
-
-export default {
- name: 'ProjectsTokenSelector',
- components: {
- GlTokenSelector,
- GlAvatar,
- GlAvatarLabeled,
- GlIntersectionObserver,
- GlLoadingIcon,
- },
- model: {
- prop: 'selectedProjects',
- },
- props: {
- selectedProjects: {
- type: Array,
- required: true,
- },
- initialProjectIds: {
- type: Array,
- required: true,
- },
- },
- apollo: {
- projects: {
- query: getProjectsQuery,
- debounce: DEBOUNCE_DELAY,
- variables() {
- return {
- search: this.searchQuery,
- after: null,
- first: PROJECTS_PER_PAGE,
- };
- },
- update({ projects }) {
- return {
- list: convertNodeIdsFromGraphQLIds(projects.nodes),
- pageInfo: projects.pageInfo,
- };
- },
- result() {
- this.isLoadingMoreProjects = false;
- this.isSearching = false;
- },
- },
- initialProjects: {
- query: getProjectsQuery,
- variables() {
- return {
- ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds),
- };
- },
- manual: true,
- skip() {
- return !this.initialProjectIds.length;
- },
- result({ data: { projects } }) {
- this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes));
- },
- },
- },
- data() {
- return {
- projects: {
- list: [],
- pageInfo: {},
- },
- searchQuery: '',
- isLoadingMoreProjects: false,
- isSearching: false,
- };
- },
- methods: {
- handleSearch(query) {
- this.isSearching = true;
- this.searchQuery = query;
- },
- loadMoreProjects() {
- this.isLoadingMoreProjects = true;
-
- this.$apollo.queries.projects.fetchMore({
- variables: {
- after: this.projects.pageInfo.endCursor,
- first: PROJECTS_PER_PAGE,
- },
- updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
- const { projects: previousProjects } = previousResult;
-
- return produce(previousResult, (draftData) => {
- draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
- draftData.projects.pageInfo = newProjects.pageInfo;
- });
- },
- });
- },
- },
-};
-</script>
-
-<template>
- <div class="gl-relative">
- <gl-token-selector
- :selected-tokens="selectedProjects"
- :dropdown-items="projects.list"
- :loading="isSearching"
- :placeholder="__('Select projects')"
- menu-class="gl-w-full! gl-max-w-full!"
- @input="$emit('input', $event)"
- @focus="$emit('focus', $event)"
- @text-input="handleSearch"
- @keydown.enter.prevent
- >
- <template #token-content="{ token: project }">
- <gl-avatar
- :entity-id="project.id"
- :entity-name="project.name"
- :src="project.avatarUrl"
- :size="16"
- />
- {{ project.nameWithNamespace }}
- </template>
- <template #dropdown-item-content="{ dropdownItem: project }">
- <gl-avatar-labeled
- :entity-id="project.id"
- :entity-name="project.name"
- :size="32"
- :src="project.avatarUrl"
- :label="project.name"
- :sub-label="project.nameWithNamespace"
- />
- </template>
- <template #dropdown-footer>
- <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
- <gl-loading-icon v-if="isLoadingMoreProjects" class="gl-mb-3" size="sm" />
- </gl-intersection-observer>
- </template>
- </gl-token-selector>
- </div>
-</template>
diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
index a5fc70b9ca6..6fb17bf0ee6 100644
--- a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
+++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql
@@ -22,7 +22,6 @@ query accessTokensGetProjects(
avatarUrl
}
pageInfo {
- __typename
...PageInfo
}
}
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index a7a03523e7f..9801aa08e28 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
-import createFlash from '~/flash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { parseRailsFormFields } from '~/lib/utils/forms';
import { __, sprintf } from '~/locale';
@@ -99,62 +98,6 @@ export const initNewAccessTokenApp = () => {
});
};
-export const initProjectsField = () => {
- const el = document.querySelector('.js-access-tokens-projects');
-
- if (!el) {
- return null;
- }
-
- const { projects: inputAttrs } = parseRailsFormFields(el);
-
- if (window.gon.features.personalAccessTokensScopedToProjects) {
- return new Promise((resolve) => {
- Promise.all([
- import('./components/projects_field.vue'),
- import('vue-apollo'),
- import('~/lib/graphql'),
- ])
- .then(
- ([
- { default: ProjectsField },
- { default: VueApollo },
- { default: createDefaultClient },
- ]) => {
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
- Vue.use(VueApollo);
-
- resolve(
- new Vue({
- el,
- apolloProvider,
- render(h) {
- return h(ProjectsField, {
- props: {
- inputAttrs,
- },
- });
- },
- }),
- );
- },
- )
- .catch(() => {
- createFlash({
- message: __(
- 'An error occurred while loading the access tokens form, please try again.',
- ),
- });
- });
- });
- }
-
- return null;
-};
-
export const initTokensApp = () => {
const el = document.getElementById('js-tokens-app');
diff --git a/app/assets/javascripts/admin/deploy_keys/components/table.vue b/app/assets/javascripts/admin/deploy_keys/components/table.vue
index 46e7ac3cf28..6b140590938 100644
--- a/app/assets/javascripts/admin/deploy_keys/components/table.vue
+++ b/app/assets/javascripts/admin/deploy_keys/components/table.vue
@@ -139,15 +139,15 @@ export default {
title,
fingerprint,
fingerprint_sha256,
- projects_with_write_access,
- created_at,
+ projects_with_write_access: projects,
+ created_at: created,
}) => ({
id,
title,
fingerprint,
fingerprint_sha256,
- projects: projects_with_write_access,
- created: created_at,
+ projects,
+ created,
}),
);
} catch (error) {
diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
index ac9304391f9..3cd3f2d92f8 100644
--- a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
+++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql
@@ -5,7 +5,6 @@ query getIntegrations($projectPath: ID!) {
id
alertManagementIntegrations {
nodes {
- __typename
...IntegrationItem
}
}
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
index b151e1605da..b2e554bc913 100644
--- a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -285,7 +285,7 @@ export default {
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
<div>
- <div data-testid="project-name">{{ project.name }}</div>
+ <div data-testid="project-name" data-qa-selector="project_name">{{ project.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
{{ project.fullPath }}
</div>
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 71b7ca29bad..1887f2affc3 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -19,24 +19,22 @@ export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
* @returns {Object}
*/
export const extractFilterQueryParameters = (url = '') => {
- /* eslint-disable camelcase */
const {
- source_branch_name = null,
- target_branch_name = null,
- author_username = null,
- milestone_title = null,
- assignee_username = [],
- label_name = [],
+ source_branch_name: selectedSourceBranch = null,
+ target_branch_name: selectedTargetBranch = null,
+ author_username: selectedAuthor = null,
+ milestone_title: selectedMilestone = null,
+ assignee_username: selectedAssigneeList = [],
+ label_name: selectedLabelList = [],
} = urlQueryToFilter(url);
- /* eslint-enable camelcase */
return {
- selectedSourceBranch: source_branch_name,
- selectedTargetBranch: target_branch_name,
- selectedAuthor: author_username,
- selectedMilestone: milestone_title,
- selectedAssigneeList: assignee_username,
- selectedLabelList: label_name,
+ selectedSourceBranch,
+ selectedTargetBranch,
+ selectedAuthor,
+ selectedMilestone,
+ selectedAssigneeList,
+ selectedLabelList,
};
};
diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
index b353bcdfd0e..2bde5973600 100644
--- a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
+++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql
@@ -1,5 +1,4 @@
fragment Count on UsageTrendsMeasurement {
- __typename
count
recordedAt
}
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index c7a53288ae4..15457f28eff 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -43,9 +43,6 @@ export const getProjectValueStreamStages = (requestPath, valueStreamId) => {
export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) =>
axios.get(joinPaths(requestPath, 'events', stageId), { params });
-export const getProjectValueStreamMetrics = (requestPath, params) =>
- axios.get(requestPath, { params });
-
/**
* Dedicated project VSA paths
*/
diff --git a/app/assets/javascripts/api/groups_api.js b/app/assets/javascripts/api/groups_api.js
index a563afc6abb..48cf346d0e6 100644
--- a/app/assets/javascripts/api/groups_api.js
+++ b/app/assets/javascripts/api/groups_api.js
@@ -2,6 +2,7 @@ import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
+const GROUP_PATH = '/api/:version/groups/:id';
const GROUPS_PATH = '/api/:version/groups.json';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
@@ -30,3 +31,9 @@ export function getDescendentGroups(parentGroupId, query, options, callback = ()
const url = buildApiUrl(DESCENDANT_GROUPS_PATH.replace(':id', parentGroupId));
return axiosGet(url, query, options, callback);
}
+
+export function updateGroup(groupId, data = {}) {
+ const url = buildApiUrl(GROUP_PATH).replace(':id', groupId);
+
+ return axios.put(url, data);
+}
diff --git a/app/assets/javascripts/attention_requests/components/navigation_popover.vue b/app/assets/javascripts/attention_requests/components/navigation_popover.vue
deleted file mode 100644
index 804eda8f321..00000000000
--- a/app/assets/javascripts/attention_requests/components/navigation_popover.vue
+++ /dev/null
@@ -1,122 +0,0 @@
-<script>
-import { GlPopover, GlSprintf, GlButton, GlLink, GlIcon } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
-
-export default {
- components: {
- GlPopover,
- GlSprintf,
- GlButton,
- GlLink,
- GlIcon,
- UserCalloutDismisser,
- },
- inject: {
- message: {
- default: '',
- },
- observerElSelector: {
- default: '',
- },
- observerElToggledClass: {
- default: '',
- },
- featureName: {
- default: '',
- },
- popoverTarget: {
- default: '',
- },
- showAttentionIcon: {
- default: false,
- },
- delay: {
- default: 0,
- },
- popoverCssClass: {
- default: '',
- },
- },
- data() {
- return {
- showPopover: false,
- popoverPlacement: this.popoverPosition(),
- };
- },
- mounted() {
- this.observeEl = document.querySelector(this.observerElSelector);
- this.observer = new MutationObserver(this.callback);
- this.observer.observe(this.observeEl, {
- attributes: true,
- });
- this.callback();
-
- window.addEventListener('resize', () => {
- this.popoverPlacement = this.popoverPosition();
- });
- },
- beforeDestroy() {
- this.observer.disconnect();
- },
- methods: {
- callback() {
- if (this.showPopover) {
- this.$root.$emit('bv::hide::popover');
- }
-
- setTimeout(() => this.toggleShowPopover(), this.delay);
- },
- toggleShowPopover() {
- this.showPopover = this.observeEl.classList.contains(this.observerElToggledClass);
- },
- getPopoverTarget() {
- return document.querySelector(this.popoverTarget);
- },
- popoverPosition() {
- if (bp.isDesktop()) {
- return 'left';
- }
-
- return 'bottom';
- },
- },
- docsPage: helpPagePath('user/project/merge_requests/index.md', {
- anchor: 'request-attention-to-a-merge-request',
- }),
-};
-</script>
-
-<template>
- <user-callout-dismisser :feature-name="featureName">
- <template #default="{ shouldShowCallout, dismiss }">
- <gl-popover
- v-if="shouldShowCallout"
- :show-close-button="false"
- :target="() => getPopoverTarget()"
- :show="showPopover"
- :delay="0"
- triggers="manual"
- :placement="popoverPlacement"
- boundary="window"
- no-fade
- :css-classes="[popoverCssClass]"
- >
- <p v-for="(m, index) in message" :key="index" class="gl-mb-5">
- <gl-sprintf :message="m">
- <template #strong="{ content }">
- <strong><gl-icon v-if="showAttentionIcon" name="attention" /> {{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <div class="gl-display-flex gl-align-items-center">
- <gl-button size="small" variant="confirm" class="gl-mr-5" @click.prevent.stop="dismiss">
- {{ __('Got it!') }}
- </gl-button>
- <gl-link :href="$options.docsPage" target="_blank">{{ __('Learn more') }}</gl-link>
- </div>
- </gl-popover>
- </template>
- </user-callout-dismisser>
-</template>
diff --git a/app/assets/javascripts/attention_requests/index.js b/app/assets/javascripts/attention_requests/index.js
deleted file mode 100644
index 2a142ab46e5..00000000000
--- a/app/assets/javascripts/attention_requests/index.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { __ } from '~/locale';
-import createDefaultClient from '~/lib/graphql';
-import NavigationPopover from './components/navigation_popover.vue';
-
-Vue.use(VueApollo);
-
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-export const initTopNavPopover = () => {
- const el = document.getElementById('js-need-attention-nav-onboarding');
-
- if (!el) return;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- apolloProvider,
- provide: {
- observerElSelector: '.user-counter.dropdown',
- observerElToggledClass: 'show',
- message: [
- __(
- '%{strongStart}Need your attention%{strongEnd} are the merge requests that need your help to move forward, as an assignee or reviewer.',
- ),
- ],
- featureName: 'attention_requests_top_nav',
- popoverTarget: '#js-need-attention-nav',
- },
- render(h) {
- return h(NavigationPopover);
- },
- });
-};
-
-export const initSideNavPopover = () => {
- const el = document.getElementById('js-need-attention-sidebar-onboarding');
-
- if (!el) return;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- apolloProvider,
- provide: {
- observerElSelector: '.js-right-sidebar',
- observerElToggledClass: 'right-sidebar-expanded',
- message: [
- __(
- 'To ask someone to look at a merge request, select %{strongStart}Request attention%{strongEnd}. Select again to remove the request.',
- ),
- __(
- 'Some actions remove attention requests, like a reviewer approving or anyone merging the merge request.',
- ),
- ],
- featureName: 'attention_requests_side_nav',
- popoverTarget: '.js-attention-request-toggle',
- showAttentionIcon: true,
- delay: 500,
- popoverCssClass: 'attention-request-sidebar-popover',
- },
- render(h) {
- return h(NavigationPopover);
- },
- });
-};
-
-export default () => {
- initTopNavPopover();
-};
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 300a81caa5c..e5408d0734a 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -116,11 +116,7 @@ export default {
class="referenced-commands draft-note-commands"
></div>
- <p
- v-if="!glFeatures.mrReviewSubmitComment"
- class="draft-note-actions d-flex"
- data-qa-selector="draft_note_content"
- >
+ <p v-if="!glFeatures.mrReviewSubmitComment" class="draft-note-actions d-flex">
<publish-button
:show-count="true"
:should-publish="false"
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 3cd1a2525e9..111b670596b 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -2,10 +2,20 @@
import { mapActions, mapGetters } from 'vuex';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants';
+import { PREVENT_LEAVING_PENDING_REVIEW } from '../i18n';
import PreviewDropdown from './preview_dropdown.vue';
import PublishButton from './publish_button.vue';
import SubmitDropdown from './submit_dropdown.vue';
+function closeInterrupt(event) {
+ event.preventDefault();
+
+ // This is the correct way to write backwards-compatible beforeunload listeners
+ // https://developer.chrome.com/blog/page-lifecycle-api/#the-beforeunload-event
+ /* eslint-disable-next-line no-return-assign, no-param-reassign */
+ return (event.returnValue = PREVENT_LEAVING_PENDING_REVIEW);
+}
+
export default {
components: {
PreviewDropdown,
@@ -25,8 +35,26 @@ export default {
},
mounted() {
document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME);
+ /*
+ * This stuff is a lot trickier than it looks.
+ *
+ * Mandatory reading: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
+ * Some notable sentences:
+ * - "[...] browsers may not display prompts created in beforeunload event handlers unless the
+ * page has been interacted with, or may even not display them at all."
+ * - "Especially on mobile, the beforeunload event is not reliably fired."
+ * - "The beforeunload event is not compatible with the back/forward cache (bfcache) [...]
+ * It is recommended that developers listen for beforeunload only in this scenario, and only
+ * when they actually have unsaved changes, so as to minimize the effect on performance."
+ *
+ * Please ensure that this is really not working before you modify it, because there are a LOT
+ * of scenarios where browser behavior will make it _seem_ like it's not working, but it actually
+ * is under the right combination of contexts.
+ */
+ window.addEventListener('beforeunload', closeInterrupt, { capture: true });
},
beforeDestroy() {
+ window.removeEventListener('beforeunload', closeInterrupt, { capture: true });
document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME);
},
methods: {
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index b070848cae9..54b9953270b 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,8 +1,11 @@
<script>
-import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup } from '@gitlab/ui';
+import $ from 'jquery';
+import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlLink } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
+import Autosave from '~/autosave';
+import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
@@ -11,6 +14,7 @@ export default {
GlIcon,
GlForm,
GlFormGroup,
+ GlLink,
MarkdownField,
},
data() {
@@ -23,6 +27,11 @@ export default {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
},
mounted() {
+ this.autosave = new Autosave(
+ $(this.$refs.textarea),
+ `submit_review_dropdown/${this.getNoteableData.id}`,
+ );
+
// We override the Bootstrap Vue click outside behaviour
// to allow for clicking in the autocomplete dropdowns
// without this override the submit dropdown will close
@@ -47,6 +56,8 @@ export default {
await this.publishReview(noteData);
+ this.autosave.reset();
+
if (window.mrTabs && this.note) {
window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
window.mrTabs.tabShown('show');
@@ -60,6 +71,9 @@ export default {
},
},
restrictedToolbarItems: ['full-screen'],
+ helpPagePath: helpPagePath('user/project/merge_requests/reviews/index.html', {
+ anchor: 'submit-a-review',
+ }),
};
</script>
@@ -68,19 +82,27 @@ export default {
ref="dropdown"
right
class="submit-review-dropdown"
+ data-qa-selector="submit_review_dropdown"
variant="info"
- category="secondary"
+ category="primary"
>
<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"
- >
+ <gl-form-group label-for="review-note-body" label-class="gl-mb-2">
+ <template #label>
+ {{ __('Summary comment (optional)') }}
+ <gl-link
+ :href="$options.helpPagePath"
+ :aria-label="__('More information')"
+ target="_blank"
+ class="gl-ml-2"
+ >
+ <gl-icon name="question-o" />
+ </gl-link>
+ </template>
<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"
@@ -117,13 +139,14 @@ export default {
</div>
</div>
</gl-form-group>
- <div class="gl-display-flex gl-justify-content-end gl-mt-5">
+ <div class="gl-display-flex gl-justify-content-start gl-mt-5">
<gl-button
:loading="isSubmitting"
variant="confirm"
type="submit"
class="js-no-auto-disable"
data-testid="submit-review-button"
+ data-qa-selector="submit_review_button"
>
{{ __('Submit review') }}
</gl-button>
diff --git a/app/assets/javascripts/batch_comments/i18n.js b/app/assets/javascripts/batch_comments/i18n.js
new file mode 100644
index 00000000000..6cdbf00f9ca
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/i18n.js
@@ -0,0 +1,3 @@
+import { __ } from '~/locale';
+
+export const PREVENT_LEAVING_PENDING_REVIEW = __('There are unsubmitted review comments.');
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 a44b9827fe9..863d2a99972 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
@@ -1,9 +1,12 @@
import { isEmpty } from 'lodash';
+
import createFlash from '~/flash';
import { scrollToElement } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+
import { CHANGES_TAB, DISCUSSION_TAB, SHOW_TAB } from '../../../constants';
import service from '../../../services/drafts_service';
+
import * as types from './mutation_types';
export const saveDraft = ({ dispatch }, draft) =>
@@ -15,6 +18,7 @@ export const addDraftToDiscussion = ({ commit }, { endpoint, data }) =>
.then((res) => res.data)
.then((res) => {
commit(types.ADD_NEW_DRAFT, res);
+
return res;
})
.catch(() => {
@@ -29,6 +33,7 @@ export const createNewDraft = ({ commit }, { endpoint, data }) =>
.then((res) => res.data)
.then((res) => {
commit(types.ADD_NEW_DRAFT, res);
+
return res;
})
.catch(() => {
diff --git a/app/assets/javascripts/behaviors/components/json_table.vue b/app/assets/javascripts/behaviors/components/json_table.vue
new file mode 100644
index 00000000000..bb38d80c1b5
--- /dev/null
+++ b/app/assets/javascripts/behaviors/components/json_table.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlTable, GlFormInput } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlTable,
+ GlFormInput,
+ },
+ props: {
+ fields: {
+ type: Array,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: true,
+ },
+ hasFilter: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ caption: {
+ type: String,
+ required: false,
+ default: __('Generated with JSON data'),
+ },
+ },
+ data() {
+ return {
+ filterInput: '',
+ };
+ },
+ computed: {
+ cleanedFields() {
+ return this.fields.map((field) => {
+ if (typeof field === 'string') {
+ return field;
+ }
+ return {
+ key: field.key,
+ label: field.label,
+ sortable: field.sortable || false,
+ };
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-inline-block">
+ <gl-form-input
+ v-if="hasFilter"
+ v-model="filterInput"
+ :placeholder="__('Type to search')"
+ class="gl-mb-2!"
+ />
+ <gl-table
+ :fields="cleanedFields"
+ :items="items"
+ :filter="filterInput"
+ show-empty
+ class="gl-mt-0!"
+ >
+ <template v-if="caption" #table-caption>
+ <small>{{ caption }}</small>
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js
index 967c0a120cd..afab266b645 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/strike.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js
@@ -2,16 +2,35 @@
export default () => ({
name: 'strike',
schema: {
- parseDOM: [
- {
- tag: 'del',
+ attrs: {
+ strike: {
+ default: false,
+ },
+ inapplicable: {
+ default: false,
},
+ },
+ parseDOM: [
+ { tag: 'li.inapplicable > s', attrs: { inapplicable: true } },
+ { tag: 'li.inapplicable > p:first-of-type > s', attrs: { inapplicable: true } },
+ { tag: 's', attrs: { strike: true } },
+ { tag: 'del' },
],
toDOM: () => ['s', 0],
},
toMarkdown: {
- open: '~~',
- close: '~~',
+ open(_, mark) {
+ if (mark.attrs.strike) {
+ return '<s>';
+ }
+ return mark.attrs.inapplicable ? '' : '~~';
+ },
+ close(_, mark) {
+ if (mark.attrs.strike) {
+ return '</s>';
+ }
+ return mark.attrs.inapplicable ? '' : '~~';
+ },
mixable: true,
expelEnclosingWhitespace: true,
},
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
index 0ff59779e7d..b862d111de7 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
@@ -37,7 +37,7 @@ export default () => ({
attrs: { lang: 'math' },
},
// Matches HTML generated by Banzai::Filter::MermaidFilter,
- // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js
{
tag: 'svg.mermaid',
preserveWhitespace: 'full',
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
index 10ffce9b1b8..095634340c1 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -5,8 +5,8 @@ export default () => ({
name: 'task_list_item',
schema: {
attrs: {
- done: {
- default: false,
+ state: {
+ default: null,
},
},
defining: true,
@@ -18,21 +18,53 @@ export default () => ({
tag: 'li.task-list-item',
getAttrs: (el) => {
const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
- return { done: checkbox && checkbox.checked };
+ if (checkbox?.matches('[data-inapplicable]')) {
+ return { state: 'inapplicable' };
+ } else if (checkbox?.checked) {
+ return { state: 'done' };
+ }
+
+ return {};
},
},
],
toDOM(node) {
return [
'li',
- { class: 'task-list-item' },
- ['input', { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done }],
+ {
+ class: () => {
+ if (node.attrs.state === 'inapplicable') {
+ return 'task-list-item inapplicable';
+ }
+
+ return 'task-list-item';
+ },
+ },
+ [
+ 'input',
+ {
+ type: 'checkbox',
+ class: 'task-list-item-checkbox',
+ checked: node.attrs.state === 'done',
+ 'data-inapplicable': node.attrs.state === 'inapplicable',
+ },
+ ],
['div', { class: 'todo-content' }, 0],
];
},
},
toMarkdown(state, node) {
- state.write(`[${node.attrs.done ? 'x' : ' '}] `);
+ switch (node.attrs.state) {
+ case 'done':
+ state.write('[x] ');
+ break;
+ case 'inapplicable':
+ state.write('[~] ');
+ break;
+ default:
+ state.write('[ ] ');
+ break;
+ }
state.renderContent(node);
},
});
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index c9ae3706383..ee5c0fe5ef3 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -5,6 +5,7 @@ import { renderKroki } from './render_kroki';
import renderMath from './render_math';
import renderSandboxedMermaid from './render_sandboxed_mermaid';
import renderMetrics from './render_metrics';
+import { renderJSONTable } from './render_json_table';
// Render GitLab flavoured Markdown
//
@@ -15,6 +16,9 @@ $.fn.renderGFM = function renderGFM() {
renderKroki(this.find('.js-render-kroki[hidden]').get());
renderMath(this.find('.js-render-math'));
renderSandboxedMermaid(this.find('.js-render-mermaid'));
+ renderJSONTable(
+ Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode),
+ );
highlightCurrentUser(this.find('.gfm-project_member').get());
diff --git a/app/assets/javascripts/behaviors/markdown/render_json_table.js b/app/assets/javascripts/behaviors/markdown/render_json_table.js
new file mode 100644
index 00000000000..4d9ac1d266b
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_json_table.js
@@ -0,0 +1,70 @@
+import { memoize } from 'lodash';
+import Vue from 'vue';
+import { __ } from '~/locale';
+import { createAlert } from '~/flash';
+
+// Async import component since we might not need it...
+const JSONTable = memoize(() =>
+ import(/* webpackChunkName: 'gfm_json_table' */ '../components/json_table.vue'),
+);
+
+const mountParseError = (element) => {
+ // Let the error container be a sibling to the element.
+ // Otherwise, dismissing the alert causes the copy button to be misplaced.
+ const container = document.createElement('div');
+ element.insertAdjacentElement('beforebegin', container);
+
+ // We need to create a child element with a known selector for `createAlert`
+ const el = document.createElement('div');
+ el.classList.add('js-json-table-error');
+
+ container.insertAdjacentElement('afterbegin', el);
+
+ return createAlert({
+ message: __('Unable to parse JSON'),
+ variant: 'warning',
+ parent: container,
+ containerSelector: '.js-json-table-error',
+ });
+};
+
+const mountJSONTableVueComponent = (userData, element) => {
+ const { fields = [], items = [], filter, caption } = userData;
+
+ const container = document.createElement('div');
+ element.innerHTML = '';
+ element.appendChild(container);
+
+ return new Vue({
+ el: container,
+ render(h) {
+ return h(JSONTable, {
+ props: {
+ fields,
+ items,
+ hasFilter: filter,
+ caption,
+ },
+ });
+ },
+ });
+};
+
+const renderTable = (element) => {
+ // Avoid rendering multiple times
+ if (!element || element.classList.contains('js-json-table')) {
+ return;
+ }
+
+ element.classList.add('js-json-table');
+
+ try {
+ mountJSONTableVueComponent(JSON.parse(element.textContent), element);
+ } catch (e) {
+ mountParseError(element);
+ }
+};
+
+export const renderJSONTable = (elements) => {
+ elements.forEach(renderTable);
+};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
deleted file mode 100644
index 2df0f7387fb..00000000000
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ /dev/null
@@ -1,231 +0,0 @@
-import $ from 'jquery';
-import { once, countBy } from 'lodash';
-import createFlash from '~/flash';
-import { darkModeEnabled } from '~/lib/utils/color_utils';
-import { __, sprintf } from '~/locale';
-import { unrestrictedPages } from './constants';
-
-// Renders diagrams and flowcharts from text using Mermaid in any element with the
-// `js-render-mermaid` class.
-//
-// Example markup:
-//
-// <pre class="js-render-mermaid">
-// graph TD;
-// A-- > B;
-// A-- > C;
-// B-- > D;
-// C-- > D;
-// </pre>
-//
-
-// This is an arbitrary number; Can be iterated upon when suitable.
-const MAX_CHAR_LIMIT = 2000;
-// Max # of mermaid blocks that can be rendered in a page.
-const MAX_MERMAID_BLOCK_LIMIT = 50;
-// Max # of `&` allowed in Chaining of links syntax
-const MAX_CHAINING_OF_LINKS_LIMIT = 30;
-// Keep a map of mermaid blocks we've already rendered.
-const elsProcessingMap = new WeakMap();
-let renderedMermaidBlocks = 0;
-
-let mermaidModule = {};
-
-export function initMermaid(mermaid) {
- let theme = 'neutral';
-
- if (darkModeEnabled()) {
- theme = 'dark';
- }
-
- mermaid.initialize({
- // mermaid core options
- mermaid: {
- startOnLoad: false,
- },
- // mermaidAPI options
- theme,
- flowchart: {
- useMaxWidth: true,
- htmlLabels: true,
- },
- secure: ['secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'htmlLabels'],
- securityLevel: 'strict',
- });
-
- return mermaid;
-}
-
-function importMermaidModule() {
- return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
- .then(({ default: mermaid }) => {
- mermaidModule = initMermaid(mermaid);
- })
- .catch((err) => {
- createFlash({
- message: sprintf(__("Can't load mermaid module: %{err}"), { err }),
- });
- // eslint-disable-next-line no-console
- console.error(err);
- });
-}
-
-function shouldLazyLoadMermaidBlock(source) {
- /**
- * If source contains `&`, which means that it might
- * contain Chaining of links a new syntax in Mermaid.
- */
- if (countBy(source)['&'] > MAX_CHAINING_OF_LINKS_LIMIT) {
- return true;
- }
-
- return false;
-}
-
-function fixElementSource(el) {
- // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly.
- const source = el.textContent.replace(/<br\s*\/>/g, '<br>');
-
- // Remove any extra spans added by the backend syntax highlighting.
- Object.assign(el, { textContent: source });
-
- return { source };
-}
-
-function renderMermaidEl(el) {
- mermaidModule.init(undefined, el, (id) => {
- const source = el.textContent;
- const svg = document.getElementById(id);
-
- // As of https://github.com/knsv/mermaid/commit/57b780a0d,
- // Mermaid will make two init callbacks:one to initialize the
- // flow charts, and another to initialize the Gannt charts.
- // Guard against an error caused by double initialization.
- if (svg.classList.contains('mermaid')) {
- return;
- }
-
- svg.classList.add('mermaid');
-
- // pre > code > svg
- svg.closest('pre').replaceWith(svg);
-
- // We need to add the original source into the DOM to allow Copy-as-GFM
- // to access it.
- const sourceEl = document.createElement('text');
- sourceEl.classList.add('source');
- sourceEl.setAttribute('display', 'none');
- sourceEl.textContent = source;
-
- svg.appendChild(sourceEl);
- });
-}
-
-function renderMermaids($els) {
- if (!$els.length) return;
-
- const pageName = document.querySelector('body').dataset.page;
-
- // A diagram may have been truncated in search results which will cause errors, so abort the render.
- if (pageName === 'search:show') return;
-
- importMermaidModule()
- .then(() => {
- let renderedChars = 0;
-
- $els.each((i, el) => {
- // Skipping all the elements which we've already queued in requestIdleCallback
- if (elsProcessingMap.has(el)) {
- return;
- }
-
- const { source } = fixElementSource(el);
- /**
- * Restrict the rendering to a certain amount of character
- * and mermaid blocks to prevent mermaidjs from hanging
- * up the entire thread and causing a DoS.
- */
- if (
- !unrestrictedPages.includes(pageName) &&
- ((source && source.length > MAX_CHAR_LIMIT) ||
- renderedChars > MAX_CHAR_LIMIT ||
- renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT ||
- shouldLazyLoadMermaidBlock(source))
- ) {
- const html = `
- <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert">
- <div>
- <div class="display-flex">
- <div>${__(
- '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-confirm btn-md gl-button">Display</button>
- </div>
- </div>
- <button type="button" class="close" data-dismiss="alert" aria-label="Close">
- <span aria-hidden="true">&times;</span>
- </button>
- </div>
- </div>
- `;
-
- const $parent = $(el).parent();
-
- if (!$parent.hasClass('lazy-alert-shown')) {
- $parent.after(html);
- $parent.addClass('lazy-alert-shown');
- }
-
- return;
- }
-
- renderedChars += source.length;
- renderedMermaidBlocks += 1;
-
- const requestId = window.requestIdleCallback(() => {
- renderMermaidEl(el);
- });
-
- elsProcessingMap.set(el, requestId);
- });
- })
- .catch((err) => {
- createFlash({
- message: sprintf(__('Encountered an error while rendering: %{err}'), { err }),
- });
- // eslint-disable-next-line no-console
- console.error(err);
- });
-}
-
-const hookLazyRenderMermaidEvent = once(() => {
- $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() {
- const parent = $(this).closest('.js-lazy-render-mermaid-container');
- const pre = parent.prev();
-
- const el = pre.find('.js-render-mermaid');
-
- parent.remove();
-
- renderMermaidEl(el);
- });
-});
-
-export default function renderMermaid($els) {
- if (!$els.length) return;
-
- const visibleMermaids = $els.filter(function filter() {
- return $(this).closest('details').length === 0 && $(this).is(':visible');
- });
-
- renderMermaids(visibleMermaids);
-
- $els.closest('details').one('toggle', function toggle() {
- if (this.open) {
- renderMermaids($(this).find('.js-render-mermaid'));
- }
- });
-
- hookLazyRenderMermaidEvent();
-}
diff --git a/app/assets/javascripts/blob/blob_links_tracking.js b/app/assets/javascripts/blob/blob_links_tracking.js
new file mode 100644
index 00000000000..9a49aa8b0fc
--- /dev/null
+++ b/app/assets/javascripts/blob/blob_links_tracking.js
@@ -0,0 +1,25 @@
+import Tracking from '~/tracking';
+
+function addBlobLinksTracking(containerSelector, eventsToTrack) {
+ const containerEl = document.querySelector(containerSelector);
+
+ if (!containerEl) {
+ return;
+ }
+
+ const eventName = 'click_link';
+ const label = 'file_line_action';
+
+ containerEl.addEventListener('click', (e) => {
+ eventsToTrack.forEach((event) => {
+ if (e.target.matches(event.selector)) {
+ Tracking.event(undefined, eventName, {
+ label,
+ property: event.property,
+ });
+ }
+ });
+ });
+}
+
+export default addBlobLinksTracking;
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 9fca9860282..8062460f052 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -38,10 +38,8 @@ export function formatIssue(issue) {
export function formatListIssues(listIssues) {
const boardItems = {};
- let listItemsCount;
const listData = listIssues.nodes.reduce((map, list) => {
- listItemsCount = list.issuesCount;
let sortedIssues = list.issues.edges.map((issueNode) => ({
...issueNode.node,
}));
@@ -67,7 +65,7 @@ export function formatListIssues(listIssues) {
};
}, {});
- return { listData, boardItems, listItemsCount };
+ return { listData, boardItems };
}
export function formatListsPageInfo(lists) {
diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
index 10c7a3db2d3..c4a2f83ab50 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue
@@ -19,6 +19,7 @@ export default {
scope: __('Scope'),
scopeDescription: __('Issues must match this scope to appear in this list.'),
selected: __('Selected'),
+ requiredFieldFeedback: __('This field is required.'),
},
components: {
GlButton,
@@ -55,12 +56,21 @@ export default {
data() {
return {
searchValue: '',
+ selectedIdValid: true,
};
},
+ computed: {
+ toggleClassList() {
+ return `gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate ${
+ this.selectedIdValid ? '' : 'gl-inset-border-1-red-400!'
+ }`;
+ },
+ },
watch: {
selectedId(val) {
if (val) {
this.$refs.dropdown.hide(true);
+ this.selectedIdValid = true;
}
},
},
@@ -74,6 +84,13 @@ export default {
this.$emit('filter-items', '');
this.$emit('hide');
},
+ onSubmit() {
+ if (!this.selectedId) {
+ this.selectedIdValid = false;
+ } else {
+ this.$emit('add-list');
+ }
+ },
},
};
</script>
@@ -103,11 +120,16 @@ export default {
<slot name="select-list-type"></slot>
- <gl-form-group class="gl-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel">
+ <gl-form-group
+ class="gl-px-5 lg-mb-3 gl-max-w-full"
+ :label="searchLabel"
+ :state="selectedIdValid"
+ :invalid-feedback="$options.i18n.requiredFieldFeedback"
+ >
<gl-dropdown
ref="dropdown"
class="gl-mb-3 gl-max-w-full"
- toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate"
+ :toggle-class="toggleClassList"
boundary="viewport"
@shown="setFocus"
@hide="onHide"
@@ -147,10 +169,9 @@ export default {
<div class="gl-display-flex gl-mb-4">
<gl-button
data-testid="addNewColumnButton"
- :disabled="!selectedId"
variant="confirm"
class="gl-mr-3 gl-ml-4"
- @click="$emit('add-list')"
+ @click="onSubmit"
>{{ $options.i18n.add }}</gl-button
>
<gl-button data-testid="cancelAddNewColumn" @click="setAddColumnFormVisibility(false)">{{
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index a632f5ae0ed..8dc521317cd 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -147,6 +147,9 @@ export default {
showReferencePath() {
return !this.isProjectBoard && this.itemReferencePath;
},
+ avatarSize() {
+ return { default: 16, lg: 24 };
+ },
},
methods: {
...mapActions(['performSearch', 'setError']),
@@ -359,16 +362,17 @@ export default {
</span>
</span>
</div>
- <div class="board-card-assignee gl-display-flex">
+ <div class="board-card-assignee gl-display-flex gl-gap-3">
<user-avatar-link
v-for="assignee in cappedAssignees"
:key="assignee.id"
:link-href="assigneeUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-src="avatarUrl(assignee)"
- :img-size="24"
+ :img-size="avatarSize"
class="js-no-trigger"
tooltip-placement="bottom"
+ :enforce-gl-avatar="true"
>
<span class="js-assignee-tooltip">
<span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 0320b4d925e..d25169b5b9d 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -138,9 +138,8 @@ export default {
<template>
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-content-sidebar" append>
<gl-drawer
- v-if="showSidebar"
v-bind="$attrs"
- :open="isSidebarOpen"
+ :open="showSidebar"
class="boards-sidebar gl-absolute"
variant="sidebar"
@close="handleClose"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index a65269de743..e3012f5b36d 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -117,7 +117,7 @@ export default {
return 'issues';
},
itemsTooltipLabel() {
- return n__(`%d issue`, `%d issues`, this.boardLists?.issuesCount);
+ return n__(`%d issue`, `%d issues`, this.boardList?.issuesCount);
},
chevronTooltip() {
return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index c559e4cdbd3..e93edad675c 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -58,7 +58,7 @@ export default {
return ListTypeTitles[ListType.label];
},
showSidebar() {
- return this.sidebarType === LIST;
+ return this.sidebarType === LIST && this.isSidebarOpen;
},
},
created() {
@@ -87,10 +87,9 @@ export default {
<template>
<mounting-portal mount-to="#js-right-sidebar-portal" name="board-settings-sidebar" append>
<gl-drawer
- v-if="showSidebar"
v-bind="$attrs"
class="js-board-settings-sidebar gl-absolute"
- :open="isSidebarOpen"
+ :open="showSidebar"
variant="sidebar"
@close="unsetActiveId"
>
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
index 4dc245660a4..01fab571733 100644
--- a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql
@@ -1,9 +1,7 @@
query BoardBlockingIssues($id: IssueID!) {
issuable: issue(id: $id) {
- __typename
id
blockingIssuables: blockedByIssues {
- __typename
nodes {
id
iid
diff --git a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
index aec674eb006..252e8c1ab06 100644
--- a/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_board_members.query.graphql
@@ -2,10 +2,8 @@
query GroupBoardMembers($fullPath: ID!, $search: String) {
workspace: group(fullPath: $fullPath) {
- __typename
id
assignees: groupMembers(search: $search, relations: [DIRECT, DESCENDANTS, INHERITED]) {
- __typename
nodes {
id
user {
diff --git a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index bf5329c4a8d..ae6394f9a2f 100644
--- a/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -17,7 +17,6 @@ query BoardListsEE(
lists(id: $id, issueFilters: $filters) {
nodes {
id
- issuesCount
listType
issues(first: $first, filters: $filters, after: $after) {
edges {
@@ -41,7 +40,6 @@ query BoardListsEE(
lists(id: $id, issueFilters: $filters) {
nodes {
id
- issuesCount
listType
issues(first: $first, filters: $filters, after: $after) {
edges {
diff --git a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
index 45bec5e574b..5279680b03c 100644
--- a/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_board_members.query.graphql
@@ -2,10 +2,8 @@
query ProjectBoardMembers($fullPath: ID!, $search: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
assignees: projectMembers(search: $search) {
- __typename
nodes {
id
user {
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 04e7d3643e7..26a98a645b3 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -11,7 +11,7 @@ const updateListItemsCount = ({ state, listId, value }) => {
if (state.issuableType === issuableTypes.epic) {
Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value });
} else {
- Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value });
+ Vue.set(state.boardLists, listId, { ...list });
}
};
diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
index 5e5d799d627..fea4b56153f 100644
--- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
+++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue
@@ -79,7 +79,7 @@ export default {
:title="$options.i18n.copyTrigger"
css-class="gl-border-none gl-py-0 gl-px-2"
/>
- <div class="label-container">
+ <div class="gl-display-inline-block gl-ml-3">
<gl-badge v-if="!item.canAccessProject" variant="danger">
<span
v-gl-tooltip.viewport
@@ -95,7 +95,7 @@ export default {
:title="item.description"
truncate-target="child"
placement="top"
- class="trigger-description gl-display-flex"
+ class="gl-max-w-15 gl-display-flex"
>
<div class="gl-flex-grow-1 gl-text-truncate">{{ item.description }}</div>
</tooltip-on-truncate>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
new file mode 100644
index 00000000000..83bad9eb518
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
@@ -0,0 +1,101 @@
+<script>
+import createFlash from '~/flash';
+import getAdminVariables from '../graphql/queries/variables.query.graphql';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ UPDATE_MUTATION_ACTION,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import addAdminVariable from '../graphql/mutations/admin_add_variable.mutation.graphql';
+import deleteAdminVariable from '../graphql/mutations/admin_delete_variable.mutation.graphql';
+import updateAdminVariable from '../graphql/mutations/admin_update_variable.mutation.graphql';
+import ciVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ ciVariableSettings,
+ },
+ inject: ['endpoint'],
+ data() {
+ return {
+ adminVariables: [],
+ isInitialLoading: true,
+ };
+ },
+ apollo: {
+ adminVariables: {
+ query: getAdminVariables,
+ update(data) {
+ return data?.ciVariables?.nodes || [];
+ },
+ error() {
+ createFlash({ message: variableFetchErrorText });
+ },
+ watchLoading(flag) {
+ if (!flag) {
+ this.isInitialLoading = false;
+ }
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.adminVariables.loading && this.isInitialLoading;
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.$options.mutationData[mutationAction];
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation.action,
+ variables: {
+ endpoint: this.endpoint,
+ variable,
+ },
+ });
+
+ const { errors } = data[currentMutation.name];
+
+ if (errors.length > 0) {
+ createFlash({ message: errors[0] });
+ } else {
+ // The writing to cache for admin variable is not working
+ // because there is no ID in the cache at the top level.
+ // We therefore need to manually refetch.
+ this.$apollo.queries.adminVariables.refetch();
+ }
+ } catch {
+ createFlash({ message: genericMutationErrorText });
+ }
+ },
+ },
+ mutationData: {
+ [ADD_MUTATION_ACTION]: { action: addAdminVariable, name: 'addAdminVariable' },
+ [UPDATE_MUTATION_ACTION]: { action: updateAdminVariable, name: 'updateAdminVariable' },
+ [DELETE_MUTATION_ACTION]: { action: deleteAdminVariable, name: 'deleteAdminVariable' },
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="false"
+ :is-loading="isLoading"
+ :variables="adminVariables"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
index ecb39f214ec..c9002edc1ab 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
+import { convertEnvironmentScope } from '../utils';
export default {
name: 'CiEnvironmentsDropdown',
@@ -12,7 +12,11 @@ export default {
GlSearchBoxByType,
},
props: {
- value: {
+ environments: {
+ type: Array,
+ required: true,
+ },
+ selectedEnvironmentScope: {
type: String,
required: false,
default: '',
@@ -24,31 +28,36 @@ export default {
};
},
computed: {
- ...mapGetters(['joinedEnvironments']),
composedCreateButtonLabel() {
return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
},
+ filteredEnvironments() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.environments.filter((environment) => {
+ return environment.toLowerCase().includes(lowerCasedSearchTerm);
+ });
+ },
shouldRenderCreateButton() {
- return this.searchTerm && !this.joinedEnvironments.includes(this.searchTerm);
+ return this.searchTerm && !this.environments.includes(this.searchTerm);
},
- filteredResults() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.joinedEnvironments.filter((resultString) =>
- resultString.toLowerCase().includes(lowerCasedSearchTerm),
- );
+ environmentScopeLabel() {
+ return convertEnvironmentScope(this.selectedEnvironmentScope);
},
},
methods: {
selectEnvironment(selected) {
- this.$emit('selectEnvironment', selected);
- this.searchTerm = '';
+ this.$emit('select-environment', selected);
+ this.clearSearch();
},
- createClicked() {
- this.$emit('createClicked', this.searchTerm);
- this.searchTerm = '';
+ convertEnvironmentScopeValue(scope) {
+ return convertEnvironmentScope(scope);
+ },
+ createEnvironmentScope() {
+ this.$emit('create-environment-scope', this.searchTerm);
+ this.selectEnvironment(this.searchTerm);
},
isSelected(env) {
- return this.value === env;
+ return this.selectedEnvironmentScope === env;
},
clearSearch() {
this.searchTerm = '';
@@ -57,23 +66,23 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="value" @show="clearSearch">
+ <gl-dropdown :text="environmentScopeLabel" @show="clearSearch">
<gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" />
<gl-dropdown-item
- v-for="environment in filteredResults"
+ v-for="environment in filteredEnvironments"
:key="environment"
:is-checked="isSelected(environment)"
is-check-item
@click="selectEnvironment(environment)"
>
- {{ environment }}
+ {{ convertEnvironmentScopeValue(environment) }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{
+ <gl-dropdown-item v-if="!filteredEnvironments.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">
+ <gl-dropdown-item data-testid="create-wildcard-button" @click="createEnvironmentScope">
{{ composedCreateButtonLabel }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
new file mode 100644
index 00000000000..3af83ffa8ed
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
@@ -0,0 +1,104 @@
+<script>
+import createFlash from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ GRAPHQL_GROUP_TYPE,
+ UPDATE_MUTATION_ACTION,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
+import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
+import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql';
+import ciVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ ciVariableSettings,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: ['endpoint', 'groupPath', 'groupId'],
+ data() {
+ return {
+ groupVariables: [],
+ };
+ },
+ apollo: {
+ groupVariables: {
+ query: getGroupVariables,
+ variables() {
+ return {
+ fullPath: this.groupPath,
+ };
+ },
+ update(data) {
+ return data?.group?.ciVariables?.nodes || [];
+ },
+ error() {
+ createFlash({ message: variableFetchErrorText });
+ },
+ },
+ },
+ computed: {
+ areScopedVariablesAvailable() {
+ return this.glFeatures.groupScopedCiVariables;
+ },
+ isLoading() {
+ return this.$apollo.queries.groupVariables.loading;
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.variableMutation(ADD_MUTATION_ACTION, variable);
+ },
+ deleteVariable(variable) {
+ this.variableMutation(DELETE_MUTATION_ACTION, variable);
+ },
+ updateVariable(variable) {
+ this.variableMutation(UPDATE_MUTATION_ACTION, variable);
+ },
+ async variableMutation(mutationAction, variable) {
+ try {
+ const currentMutation = this.$options.mutationData[mutationAction];
+ const { data } = await this.$apollo.mutate({
+ mutation: currentMutation.action,
+ variables: {
+ endpoint: this.endpoint,
+ fullPath: this.groupPath,
+ groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId),
+ variable,
+ },
+ });
+
+ const { errors } = data[currentMutation.name];
+
+ if (errors.length > 0) {
+ createFlash({ message: errors[0] });
+ }
+ } catch {
+ createFlash({ message: genericMutationErrorText });
+ }
+ },
+ },
+ mutationData: {
+ [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' },
+ [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' },
+ [DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' },
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="areScopedVariablesAvailable"
+ :is-loading="isLoading"
+ :variables="groupVariables"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @update-variable="updateVariable"
+ />
+</template>
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 557a8d6b5ba..5ba63de8c96 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
@@ -14,22 +14,26 @@ import {
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 {
+ allEnvironments,
AWS_TOKEN_CONSTANTS,
ADD_CI_VARIABLE_MODAL_ID,
AWS_TIP_DISMISSED_COOKIE_NAME,
AWS_TIP_MESSAGE,
CONTAINS_VARIABLE_REFERENCE_MESSAGE,
+ defaultVariableState,
ENVIRONMENT_SCOPE_LINK_TITLE,
EVENT_LABEL,
EVENT_ACTION,
+ EDIT_VARIABLE_ACTION,
+ VARIABLE_ACTIONS,
+ variableOptions,
} from '../constants';
+import { createJoinedEnvironments } from '../utils';
import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
@@ -58,66 +62,84 @@ export default {
GlModal,
GlSprintf,
},
- mixins: [glFeatureFlagsMixin(), trackingMixin],
+ mixins: [trackingMixin],
+ inject: [
+ 'awsLogoSvgPath',
+ 'awsTipCommandsLink',
+ 'awsTipDeployLink',
+ 'awsTipLearnLink',
+ 'containsVariableReferenceLink',
+ 'environmentScopeLink',
+ 'isProtectedByDefault',
+ 'maskedEnvironmentVariablesLink',
+ 'maskableRegex',
+ 'protectedEnvironmentVariablesLink',
+ ],
+ props: {
+ areScopedVariablesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ environments: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ mode: {
+ type: String,
+ required: true,
+ validator(val) {
+ return VARIABLE_ACTIONS.includes(val);
+ },
+ },
+ selectedVariable: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ variables: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
data() {
return {
+ newEnvironments: [],
isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
+ typeOptions: variableOptions,
validationErrorEventProperty: '',
+ variable: { ...defaultVariableState, ...this.selectedVariable },
};
},
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);
+ return regex.test(this.variable.value);
+ },
+ canSubmit() {
+ return this.variableValidationState && this.variable.key !== '' && this.variable.value !== '';
},
containsVariableReference() {
const regex = /\$/;
- return regex.test(this.variable.secret_value);
+ return regex.test(this.variable.value);
},
displayMaskedError() {
return !this.canMask && this.variable.masked;
},
+ isEditing() {
+ return this.mode === EDIT_VARIABLE_ACTION;
+ },
+ isTipVisible() {
+ return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key);
+ },
+ joinedEnvironments() {
+ return createJoinedEnvironments(this.variables, this.environments, this.newEnvironments);
+ },
+ maskedFeedback() {
+ return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ },
maskedState() {
if (this.displayMaskedError) {
return false;
@@ -125,10 +147,7 @@ export default {
return true;
},
modalActionText() {
- return this.variableBeingEdited ? __('Update variable') : __('Add variable');
- },
- maskedFeedback() {
- return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ return this.isEditing ? __('Update variable') : __('Add variable');
},
tokenValidationFeedback() {
const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage;
@@ -141,19 +160,16 @@ export default {
const validator = this.$options.tokens?.[this.variable.key]?.validation;
if (validator) {
- return validator(this.variable.secret_value);
+ return validator(this.variable.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);
+ return this.variable.value === '' || (this.tokenValidationState && this.maskedState);
},
},
watch: {
@@ -165,19 +181,18 @@ export default {
},
},
methods: {
- ...mapActions([
- 'addVariable',
- 'updateVariable',
- 'resetEditing',
- 'displayInputValue',
- 'clearModal',
- 'deleteVariable',
- 'setEnvironmentScope',
- 'addWildCardScope',
- 'resetSelectedEnvironment',
- 'setSelectedEnvironment',
- 'setVariableProtected',
- ]),
+ addVariable() {
+ this.$emit('add-variable', this.variable);
+ },
+ createEnvironmentScope(env) {
+ this.newEnvironments.push(env);
+ },
+ deleteVariable() {
+ this.$emit('delete-variable', this.variable);
+ },
+ updateVariable() {
+ this.$emit('update-variable', this.variable);
+ },
dismissTip() {
setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 });
this.isTipDismissed = true;
@@ -190,16 +205,22 @@ export default {
this.$refs.modal.hide();
},
resetModalHandler() {
- if (this.variableBeingEdited) {
- this.resetEditing();
- }
-
- this.clearModal();
- this.resetSelectedEnvironment();
+ this.resetVariableData();
this.resetValidationErrorEvents();
+
+ this.$emit('hideModal');
+ },
+ resetVariableData() {
+ this.variable = { ...defaultVariableState };
+ },
+ setEnvironmentScope(scope) {
+ this.variable = { ...this.variable, environmentScope: scope };
+ },
+ setVariableProtected() {
+ this.variable = { ...this.variable, protected: true };
},
updateOrAddVariable() {
- if (this.variableBeingEdited) {
+ if (this.isEditing) {
this.updateVariable();
} else {
this.addVariable();
@@ -207,7 +228,7 @@ export default {
this.hideModal();
},
setVariableProtectedByDefault() {
- if (this.isProtectedByDefault && !this.variableBeingEdited) {
+ if (this.isProtectedByDefault && !this.isEditing) {
this.setVariableProtected();
}
},
@@ -220,11 +241,11 @@ export default {
},
getTrackingErrorProperty() {
let property;
- if (this.variable.secret_value?.length && !property) {
+ if (this.variable.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, '');
+ property = this.variable.value.replace(regex, '');
}
if (this.containsVariableReference) {
property = '$';
@@ -237,6 +258,7 @@ export default {
this.validationErrorEventProperty = '';
},
},
+ defaultScope: allEnvironments.text,
};
</script>
@@ -252,7 +274,7 @@ export default {
>
<form>
<gl-form-combobox
- v-model="key"
+ v-model="variable.key"
:token-list="$options.tokenList"
:label-text="__('Key')"
data-qa-selector="ci_variable_key_field"
@@ -267,7 +289,7 @@ export default {
<gl-form-textarea
id="ci-variable-value"
ref="valueField"
- v-model="secret_value"
+ v-model="variable.value"
:state="variableValidationState"
rows="3"
max-rows="6"
@@ -278,7 +300,11 @@ export default {
<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-select
+ id="ci-variable-type"
+ v-model="variable.variableType"
+ :options="typeOptions"
+ />
</gl-form-group>
<gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope">
@@ -294,22 +320,24 @@ export default {
</gl-link>
</template>
<ci-environments-dropdown
- v-if="scopedVariablesAvailable"
- class="w-100"
- :value="environment_scope"
- @selectEnvironment="setEnvironmentScope"
- @createClicked="addWildCardScope"
+ v-if="areScopedVariablesAvailable"
+ class="gl-w-full"
+ :selected-environment-scope="variable.environmentScope"
+ :environments="joinedEnvironments"
+ @select-environment="setEnvironmentScope"
+ @create-environment-scope="createEnvironmentScope"
/>
- <gl-form-input v-else v-model="environment_scope" class="w-100" readonly />
+ <gl-form-input v-else :value="$options.defaultScope" class="gl-w-full" 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"
+ v-model="variable.protected"
+ class="gl-mb-0"
data-testid="ci-variable-protected-checkbox"
+ :data-is-protected-checked="variable.protected"
>
{{ __('Protect variable') }}
<gl-link target="_blank" :href="protectedEnvironmentVariablesLink">
@@ -322,7 +350,7 @@ export default {
<gl-form-checkbox
ref="masked-ci-variable"
- v-model="masked"
+ v-model="variable.masked"
data-testid="ci-variable-masked-checkbox"
>
{{ __('Mask variable') }}
@@ -403,7 +431,7 @@ export default {
<template #modal-footer>
<gl-button @click="hideModal">{{ __('Cancel') }}</gl-button>
<gl-button
- v-if="variableBeingEdited"
+ v-if="isEditing"
ref="deleteCiVariable"
variant="danger"
category="secondary"
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 4cc00eb01d9..81e3a983ea3 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,9 +1,91 @@
<script>
-export default {};
+import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants';
+import CiVariableTable from './ci_variable_table.vue';
+import CiVariableModal from './ci_variable_modal.vue';
+
+export default {
+ components: {
+ CiVariableTable,
+ CiVariableModal,
+ },
+ props: {
+ areScopedVariablesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ environments: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variables: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedVariable: {},
+ mode: null,
+ };
+ },
+ computed: {
+ showModal() {
+ return VARIABLE_ACTIONS.includes(this.mode);
+ },
+ },
+ methods: {
+ addVariable(variable) {
+ this.$emit('add-variable', variable);
+ },
+ deleteVariable(variable) {
+ this.$emit('delete-variable', variable);
+ },
+ updateVariable(variable) {
+ this.$emit('update-variable', variable);
+ },
+ hideModal() {
+ this.mode = null;
+ },
+ setSelectedVariable(variable = null) {
+ if (!variable) {
+ this.selectedVariable = {};
+ this.mode = ADD_VARIABLE_ACTION;
+ } else {
+ this.selectedVariable = variable;
+ this.mode = EDIT_VARIABLE_ACTION;
+ }
+ },
+ },
+};
</script>
<template>
<div class="row">
- <div class="col-lg-12"></div>
+ <div class="col-lg-12">
+ <ci-variable-table
+ :is-loading="isLoading"
+ :variables="variables"
+ @set-selected-variable="setSelectedVariable"
+ />
+ <ci-variable-modal
+ v-if="showModal"
+ :are-scoped-variables-available="areScopedVariablesAvailable"
+ :environments="environments"
+ :variables="variables"
+ :mode="mode"
+ :selected-variable="selectedVariable"
+ @add-variable="addVariable"
+ @delete-variable="deleteVariable"
+ @hideModal="hideModal"
+ @update-variable="updateVariable"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
index f078234829a..1bb94080694 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue
@@ -1,10 +1,17 @@
<script>
-import { GlTable, GlButton, GlModalDirective, GlIcon, GlTooltipDirective } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
+import {
+ GlButton,
+ GlIcon,
+ GlLoadingIcon,
+ GlModalDirective,
+ GlTable,
+ GlTooltipDirective,
+} from '@gitlab/ui';
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 { ADD_CI_VARIABLE_MODAL_ID, variableText } from '../constants';
+import { convertEnvironmentScope } from '../utils';
import CiVariablePopover from './ci_variable_popover.vue';
export default {
@@ -14,7 +21,7 @@ export default {
iconSize: 16,
fields: [
{
- key: 'variable_type',
+ key: 'variableType',
label: s__('CiVariables|Type'),
customStyle: { width: '70px' },
},
@@ -41,7 +48,7 @@ export default {
customStyle: { width: '100px' },
},
{
- key: 'environment_scope',
+ key: 'environmentScope',
label: s__('CiVariables|Environments'),
customStyle: { width: '20%' },
},
@@ -56,6 +63,7 @@ export default {
CiVariablePopover,
GlButton,
GlIcon,
+ GlLoadingIcon,
GlTable,
TooltipOnTruncate,
},
@@ -64,10 +72,25 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [glFeatureFlagsMixin()],
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variables: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ areValuesHidden: true,
+ };
+ },
computed: {
- ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']),
valuesButtonText() {
- return this.valuesHidden ? __('Reveal values') : __('Hide values');
+ return this.areValuesHidden ? __('Reveal values') : __('Hide values');
},
isTableEmpty() {
return !this.variables || this.variables.length === 0;
@@ -76,18 +99,28 @@ export default {
return this.$options.fields;
},
},
- mounted() {
- this.fetchVariables();
- },
methods: {
- ...mapActions(['fetchVariables', 'toggleValues', 'editVariable']),
+ convertEnvironmentScopeValue(env) {
+ return convertEnvironmentScope(env);
+ },
+ generateTypeText(item) {
+ return variableText[item.variableType];
+ },
+ toggleHiddenState() {
+ this.areValuesHidden = !this.areValuesHidden;
+ },
+ setSelectedVariable(variable = null) {
+ this.$emit('set-selected-variable', variable);
+ },
},
};
</script>
<template>
<div class="ci-variable-table" data-testid="ci-variable-table">
+ <gl-loading-icon v-if="isLoading" />
<gl-table
+ v-else
:fields="fields"
:items="variables"
tbody-tr-class="js-ci-variable-row"
@@ -104,6 +137,11 @@ export default {
<template #table-colgroup="scope">
<col v-for="field in scope.fields" :key="field.key" :style="field.customStyle" />
</template>
+ <template #cell(variableType)="{ item }">
+ <div class="gl-pt-2">
+ {{ generateTypeText(item) }}
+ </div>
+ </template>
<template #cell(key)="{ item }">
<div class="gl-display-flex gl-align-items-center">
<tooltip-on-truncate :title="item.key" truncate-target="child">
@@ -125,11 +163,12 @@ export default {
</template>
<template #cell(value)="{ item }">
<div class="gl-display-flex gl-align-items-center">
- <span v-if="valuesHidden">*********************</span>
+ <span v-if="areValuesHidden" data-testid="hiddenValue">*********************</span>
<span
v-else
:id="`ci-variable-value-${item.id}`"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
+ data-testid="revealedValue"
>{{ item.value }}</span
>
<gl-button
@@ -150,16 +189,16 @@ export default {
<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 }">
+ <template #cell(environmentScope)="{ 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
+ >{{ convertEnvironmentScopeValue(item.environmentScope) }}</span
>
<ci-variable-popover
:target="`ci-variable-env-${item.id}`"
- :value="item.environment_scope"
+ :value="convertEnvironmentScopeValue(item.environmentScope)"
:tooltip-text="__('Copy environment')"
/>
</div>
@@ -170,7 +209,7 @@ export default {
icon="pencil"
:aria-label="__('Edit')"
data-qa-selector="edit_ci_variable_button"
- @click="editVariable(item)"
+ @click="setSelectedVariable(item)"
/>
</template>
<template #empty>
@@ -186,12 +225,14 @@ export default {
data-qa-selector="add_ci_variable_button"
variant="confirm"
category="primary"
+ :aria-label="__('Add')"
+ @click="setSelectedVariable()"
>{{ __('Add variable') }}</gl-button
>
<gl-button
v-if="!isTableEmpty"
data-qa-selector="reveal_ci_variable_value_button"
- @click="toggleValues(!valuesHidden)"
+ @click="toggleHiddenState"
>{{ valuesButtonText }}</gl-button
>
</div>
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
index 7dcc5ce42d7..cebb7eb85ac 100644
--- 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
@@ -30,7 +30,7 @@ import {
EVENT_LABEL,
EVENT_ACTION,
} from '../constants';
-import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
+import LegacyCiEnvironmentsDropdown from './legacy_ci_environments_dropdown.vue';
import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
@@ -43,7 +43,7 @@ export default {
containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE,
environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE,
components: {
- CiEnvironmentsDropdown,
+ LegacyCiEnvironmentsDropdown,
GlAlert,
GlButton,
GlCollapse,
@@ -293,7 +293,7 @@ export default {
<gl-icon name="question" :size="12" />
</gl-link>
</template>
- <ci-environments-dropdown
+ <legacy-ci-environments-dropdown
v-if="scopedVariablesAvailable"
class="w-100"
:value="environment_scope"
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index fa55b4d9e77..5d22974ffbb 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -2,18 +2,58 @@ import { __ } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
+// This const will be deprecated once we remove VueX from the section
export const displayText = {
variableText: __('Variable'),
fileText: __('File'),
allEnvironmentsText: __('All (default)'),
};
+export const variableTypes = {
+ variableType: 'ENV_VAR',
+ fileType: 'FILE',
+};
+
+// Once REST is removed, we won't need `types`
export const types = {
variableType: 'env_var',
fileType: 'file',
- allEnvironmentsType: '*',
};
+export const allEnvironments = {
+ type: '*',
+ text: __('All (default)'),
+};
+
+// Once REST is removed, we won't need `types` key
+export const variableText = {
+ [types.variableType]: __('Variable'),
+ [types.fileType]: __('File'),
+ [variableTypes.variableType]: __('Variable'),
+ [variableTypes.fileType]: __('File'),
+};
+
+export const variableOptions = [
+ { value: types.variableType, text: variableText[types.variableType] },
+ { value: types.fileType, text: variableText[types.fileType] },
+];
+
+export const defaultVariableState = {
+ environmentScope: allEnvironments.type,
+ key: '',
+ masked: false,
+ protected: false,
+ value: '',
+ variableType: types.variableType,
+};
+
+// eslint-disable-next-line @gitlab/require-i18n-strings
+export const groupString = 'Group';
+// eslint-disable-next-line @gitlab/require-i18n-strings
+export const instanceString = 'Instance';
+// eslint-disable-next-line @gitlab/require-i18n-strings
+export const projectString = 'Instance';
+
export const AWS_TIP_DISMISSED_COOKIE_NAME = 'ci_variable_list_constants_aws_tip_dismissed';
export const AWS_TIP_MESSAGE = __(
'%{deployLinkStart}Use a template to deploy to ECS%{deployLinkEnd}, or use a docker image to %{commandsLinkStart}run AWS commands in GitLab CI/CD%{commandsLinkEnd}.',
@@ -33,3 +73,20 @@ export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __(
);
export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more');
+
+export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE';
+export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE';
+export const VARIABLE_ACTIONS = [ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION];
+
+export const GRAPHQL_PROJECT_TYPE = 'Project';
+export const GRAPHQL_GROUP_TYPE = 'Group';
+
+export const ADD_MUTATION_ACTION = 'add';
+export const UPDATE_MUTATION_ACTION = 'update';
+export const DELETE_MUTATION_ACTION = 'delete';
+
+export const environmentFetchErrorText = __(
+ 'There was an error fetching the environments information.',
+);
+export const genericMutationErrorText = __('Something went wrong on our end. Please try again.');
+export const variableFetchErrorText = __('There was an error fetching the variables.');
diff --git a/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
new file mode 100644
index 00000000000..a28ca4eebc9
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql
@@ -0,0 +1,7 @@
+fragment BaseCiVariable on CiVariable {
+ __typename
+ id
+ key
+ value
+ variableType
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
new file mode 100644
index 00000000000..eba4b0c32f8
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_add_variable.mutation.graphql
@@ -0,0 +1,16 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation addAdminVariable($variable: CiVariable!, $endpoint: String!) {
+ addAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ protected
+ masked
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
new file mode 100644
index 00000000000..96eb8c794bc
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_delete_variable.mutation.graphql
@@ -0,0 +1,16 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation deleteAdminVariable($variable: CiVariable!, $endpoint: String!) {
+ deleteAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ protected
+ masked
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
new file mode 100644
index 00000000000..c0388507bb8
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/admin_update_variable.mutation.graphql
@@ -0,0 +1,16 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation updateAdminVariable($variable: CiVariable!, $endpoint: String!) {
+ updateAdminVariable(variable: $variable, endpoint: $endpoint) @client {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ protected
+ masked
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
new file mode 100644
index 00000000000..f8e4dc55fa4
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation addGroupVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $groupId: ID!
+) {
+ addGroupVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ groupId: $groupId
+ ) @client {
+ group {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
new file mode 100644
index 00000000000..310e4a6e551
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation deleteGroupVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $groupId: ID!
+) {
+ deleteGroupVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ groupId: $groupId
+ ) @client {
+ group {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
new file mode 100644
index 00000000000..5291942eb87
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation updateGroupVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $groupId: ID!
+) {
+ updateGroupVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ groupId: $groupId
+ ) @client {
+ group {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql
new file mode 100644
index 00000000000..c6dd6d4faaf
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql
@@ -0,0 +1,17 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+query getGroupVariables($fullPath: ID!) {
+ group(fullPath: $fullPath) {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiGroupVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql
new file mode 100644
index 00000000000..95056842b49
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql
@@ -0,0 +1,13 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+query getVariables {
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiInstanceVariable {
+ masked
+ protected
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
new file mode 100644
index 00000000000..be7e3f88cfd
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
@@ -0,0 +1,113 @@
+import axios from 'axios';
+import {
+ convertObjectPropsToCamelCase,
+ convertObjectPropsToSnakeCase,
+} from '../../lib/utils/common_utils';
+import { getIdFromGraphQLId } from '../../graphql_shared/utils';
+import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants';
+import getAdminVariables from './queries/variables.query.graphql';
+import getGroupVariables from './queries/group_variables.query.graphql';
+
+const prepareVariableForApi = ({ variable, destroy = false }) => {
+ return {
+ ...convertObjectPropsToSnakeCase(variable),
+ id: getIdFromGraphQLId(variable?.id),
+ variable_type: variable.variableType.toLowerCase(),
+ secret_value: variable.value,
+ _destroy: destroy,
+ };
+};
+
+const mapVariableTypes = (variables = [], kind) => {
+ return variables.map((ciVar) => {
+ return {
+ __typename: `Ci${kind}Variable`,
+ ...convertObjectPropsToCamelCase(ciVar),
+ variableType: ciVar.variable_type ? ciVar.variable_type.toUpperCase() : ciVar.variableType,
+ };
+ });
+};
+
+const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
+ return {
+ errors,
+ group: {
+ __typename: GRAPHQL_GROUP_TYPE,
+ id: groupId,
+ ciVariables: {
+ __typename: 'CiVariableConnection',
+ nodes: mapVariableTypes(data.variables, groupString),
+ },
+ },
+ };
+};
+
+const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
+ return {
+ errors,
+ ciVariables: {
+ __typename: `Ci${instanceString}VariableConnection`,
+ nodes: mapVariableTypes(data.variables, instanceString),
+ },
+ };
+};
+
+const callGroupEndpoint = async ({
+ endpoint,
+ fullPath,
+ variable,
+ groupId,
+ cache,
+ destroy = false,
+}) => {
+ try {
+ const { data } = await axios.patch(endpoint, {
+ variables_attributes: [prepareVariableForApi({ variable, destroy })],
+ });
+ return prepareGroupGraphQLResponse({ data, groupId });
+ } catch (e) {
+ return prepareGroupGraphQLResponse({
+ data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }),
+ groupId,
+ errors: [...e.response.data],
+ });
+ }
+};
+
+const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) => {
+ try {
+ const { data } = await axios.patch(endpoint, {
+ variables_attributes: [prepareVariableForApi({ variable, destroy })],
+ });
+
+ return prepareAdminGraphQLResponse({ data });
+ } catch (e) {
+ return prepareAdminGraphQLResponse({
+ data: cache.readQuery({ query: getAdminVariables }),
+ errors: [...e.response.data],
+ });
+ }
+};
+
+export const resolvers = {
+ Mutation: {
+ addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ },
+ updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
+ },
+ deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
+ return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true });
+ },
+ addAdminVariable: async (_, { endpoint, variable }, { cache }) => {
+ return callAdminEndpoint({ endpoint, variable, cache });
+ },
+ updateAdminVariable: async (_, { endpoint, variable }, { cache }) => {
+ return callAdminEndpoint({ endpoint, variable, cache });
+ },
+ deleteAdminVariable: async (_, { endpoint, variable }, { cache }) => {
+ return callAdminEndpoint({ endpoint, variable, cache, destroy: true });
+ },
+ },
+};
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 2b54af6a2a4..a74af8aed12 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -2,8 +2,10 @@ 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 CiAdminVariables from './components/ci_admin_variables.vue';
+import CiGroupVariables from './components/ci_group_variables.vue';
import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
+import { resolvers } from './graphql/resolvers';
import createStore from './store';
const mountCiVariableListApp = (containerEl) => {
@@ -13,8 +15,12 @@ const mountCiVariableListApp = (containerEl) => {
awsTipDeployLink,
awsTipLearnLink,
containsVariableReferenceLink,
+ endpoint,
environmentScopeLink,
- group,
+ groupId,
+ groupPath,
+ isGroup,
+ isProject,
maskedEnvironmentVariablesLink,
maskableRegex,
projectFullPath,
@@ -23,13 +29,20 @@ const mountCiVariableListApp = (containerEl) => {
protectedEnvironmentVariablesLink,
} = containerEl.dataset;
- const isGroup = parseBoolean(group);
+ const parsedIsProject = parseBoolean(isProject);
+ const parsedIsGroup = parseBoolean(isGroup);
const isProtectedByDefault = parseBoolean(protectedByDefault);
+ let component = CiAdminVariables;
+
+ if (parsedIsGroup) {
+ component = CiGroupVariables;
+ }
+
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(resolvers),
});
return new Vue({
@@ -41,8 +54,12 @@ const mountCiVariableListApp = (containerEl) => {
awsTipDeployLink,
awsTipLearnLink,
containsVariableReferenceLink,
+ endpoint,
environmentScopeLink,
- isGroup,
+ groupId,
+ groupPath,
+ isGroup: parsedIsGroup,
+ isProject: parsedIsProject,
isProtectedByDefault,
maskedEnvironmentVariablesLink,
maskableRegex,
@@ -51,7 +68,7 @@ const mountCiVariableListApp = (containerEl) => {
protectedEnvironmentVariablesLink,
},
render(createElement) {
- return createElement(CiVariableSettings);
+ return createElement(component);
},
});
};
diff --git a/app/assets/javascripts/ci_variable_list/store/utils.js b/app/assets/javascripts/ci_variable_list/store/utils.js
index d9ca460a8e1..f46a671ae7b 100644
--- a/app/assets/javascripts/ci_variable_list/store/utils.js
+++ b/app/assets/javascripts/ci_variable_list/store/utils.js
@@ -1,5 +1,5 @@
import { cloneDeep } from 'lodash';
-import { displayText, types } from '../constants';
+import { displayText, types, allEnvironments } from '../constants';
const variableTypeHandler = (type) =>
type === displayText.variableText ? types.variableType : types.fileType;
@@ -15,7 +15,7 @@ export const prepareDataForDisplay = (variables) => {
}
variableCopy.secret_value = variableCopy.value;
- if (variableCopy.environment_scope === types.allEnvironmentsType) {
+ if (variableCopy.environment_scope === allEnvironments.type) {
variableCopy.environment_scope = displayText.allEnvironmentsText;
}
variableCopy.protected_variable = variableCopy.protected;
@@ -31,7 +31,7 @@ export const prepareDataForApi = (variable, destroy = false) => {
variableCopy.masked = variableCopy.masked.toString();
variableCopy.variable_type = variableTypeHandler(variableCopy.variable_type);
if (variableCopy.environment_scope === displayText.allEnvironmentsText) {
- variableCopy.environment_scope = types.allEnvironmentsType;
+ variableCopy.environment_scope = allEnvironments.type;
}
if (destroy) {
diff --git a/app/assets/javascripts/ci_variable_list/utils.js b/app/assets/javascripts/ci_variable_list/utils.js
new file mode 100644
index 00000000000..1faa97a5f73
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/utils.js
@@ -0,0 +1,50 @@
+import { uniq } from 'lodash';
+import { allEnvironments } from './constants';
+
+/**
+ * This function takes a list of variable, environments and
+ * new environments added through the scope dropdown
+ * and create a new Array that concatenate the environment list
+ * with the environment scopes find in the variable list. This is
+ * useful for variable settings so that we can render a list of all
+ * environment scopes available based on the list of envs, the ones the user
+ * added explictly and what is found under each variable.
+ * @param {Array} variables
+ * @param {Array} environments
+ * @returns {Array} - Array of environments
+ */
+
+export const createJoinedEnvironments = (
+ variables = [],
+ environments = [],
+ newEnvironments = [],
+) => {
+ const scopesFromVariables = variables.map((variable) => variable.environmentScope);
+ return uniq([...environments, ...newEnvironments, ...scopesFromVariables]).sort();
+};
+
+/**
+ * This function job is to convert the * wildcard to text when applicable
+ * in the UI. It uses a constants to compare the incoming value to that
+ * of the * and then apply the corresponding label if applicable. If there
+ * is no scope, then we return the default value as well.
+ * @param {String} scope
+ * @returns {String} - Converted value if applicable
+ */
+
+export const convertEnvironmentScope = (environmentScope = '') => {
+ if (environmentScope === allEnvironments.type || !environmentScope) {
+ return allEnvironments.text;
+ }
+
+ return environmentScope;
+};
+
+/**
+ * Gives us an array of all the environments by name
+ * @param {Array} nodes
+ * @return {Array<String>} - Array of environments strings
+ */
+export const mapEnvironmentNames = (nodes = []) => {
+ return nodes.map((env) => env.name);
+};
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 1ea5eff35d4..4b85ca2b508 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -235,7 +235,7 @@ export default {
:fields="fields"
fixed
stacked="md"
- class="qa-clusters-table gl-mb-4!"
+ class="gl-mb-4!"
data-testid="cluster_list_table"
>
<template #cell(name)="{ item }">
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 4ff49433749..95ee3a0d90e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,5 +1,6 @@
<script>
-import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui';
+import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { getParameterByName } from '~/lib/utils/url_utility';
import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import { PipelineKeyOptions } from '~/pipelines/constants';
@@ -19,6 +20,7 @@ export default {
GlLink,
GlLoadingIcon,
GlModal,
+ GlSprintf,
PipelinesTableComponent,
TablePagination,
},
@@ -32,6 +34,10 @@ export default {
type: String,
required: true,
},
+ emptyStateSvgPath: {
+ type: String,
+ required: true,
+ },
viewType: {
type: String,
required: false,
@@ -83,6 +89,9 @@ export default {
shouldRenderErrorState() {
return this.hasError && !this.isLoading;
},
+ shouldRenderEmptyState() {
+ return this.state.pipelines.length === 0 && !this.shouldRenderErrorState;
+ },
/**
* The "Run pipeline" button can only be rendered when:
* - In MR view - we use `canCreatePipelineInTargetProject` for that purpose
@@ -185,6 +194,17 @@ export default {
},
},
},
+ i18n: {
+ runPipelinePopoverTitle: s__('Pipeline|Run merge request pipeline'),
+ runPipelinePopoverDescription: s__(
+ 'Pipeline|To run a merge request pipeline, the jobs in the CI/CD configuration file %{linkStart}must be configured%{linkEnd} to run in merge request pipelines.',
+ ),
+ runPipelineText: s__('Pipeline|Run pipeline'),
+ emptyStateTitle: s__('Pipelines|There are currently no pipelines.'),
+ },
+ mrPipelinesDocsPath: helpPagePath('ci/pipelines/merge_request_pipelines.md', {
+ anchor: 'prerequisites',
+ }),
};
</script>
<template>
@@ -203,7 +223,41 @@ export default {
s__(`Pipelines|There was an error fetching the pipelines.
Try again in a few moments or contact your support team.`)
"
+ data-testid="pipeline-error-empty-state"
/>
+ <template v-else-if="shouldRenderEmptyState">
+ <gl-empty-state
+ :svg-path="emptyStateSvgPath"
+ :title="$options.i18n.emptyStateTitle"
+ data-testid="pipeline-empty-state"
+ >
+ <template #description>
+ <gl-sprintf :message="$options.i18n.runPipelinePopoverDescription">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.mrPipelinesDocsPath"
+ target="_blank"
+ data-testid="mr-pipelines-docs-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <template #actions>
+ <div class="gl-vertical-align-middle">
+ <gl-button
+ variant="confirm"
+ :loading="state.isRunningMergeRequestPipeline"
+ data-testid="run_pipeline_button"
+ @click="tryRunPipeline"
+ >
+ {{ $options.i18n.runPipelineText }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-empty-state>
+ </template>
<div v-else-if="shouldRenderTable">
<gl-button
@@ -215,7 +269,7 @@ export default {
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipeline|Run pipeline') }}
+ {{ $options.i18n.runPipelineText }}
</gl-button>
<pipelines-table-component
@@ -231,7 +285,7 @@ export default {
:loading="state.isRunningMergeRequestPipeline"
@click="tryRunPipeline"
>
- {{ s__('Pipeline|Run pipeline') }}
+ {{ $options.i18n.runPipelineText }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
index 784e9cb2faa..b105273ece7 100644
--- a/app/assets/javascripts/commons/nav/user_merge_requests.js
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -26,39 +26,20 @@ function updateMergeRequestCounts(newCount) {
mergeRequestsCountEl.classList.toggle('gl-display-none', Number(newCount) === 0);
}
-function updateAttentionRequestsCount(count) {
- const attentionCountEl = document.querySelector('.js-attention-count');
- attentionCountEl.textContent = count.toLocaleString();
-
- if (Number(count) === 0) {
- attentionCountEl.classList.replace('badge-warning', 'badge-neutral');
- } else {
- attentionCountEl.classList.replace('badge-neutral', 'badge-warning');
- }
-}
-
/**
* Refresh user counts (and broadcast if open)
*/
export function refreshUserMergeRequestCounts() {
return getUserCounts()
.then(({ data }) => {
- const attentionRequestsEnabled = window.gon?.features?.mrAttentionRequests;
const assignedMergeRequests = data.assigned_merge_requests;
const reviewerMergeRequests = data.review_requested_merge_requests;
- const attentionRequests = data.attention_requests;
- const fullCount = attentionRequestsEnabled
- ? attentionRequests
- : assignedMergeRequests + reviewerMergeRequests;
+ const fullCount = assignedMergeRequests + reviewerMergeRequests;
updateUserMergeRequestCounts(assignedMergeRequests);
updateReviewerMergeRequestCounts(reviewerMergeRequests);
updateMergeRequestCounts(fullCount);
broadcastCount(fullCount);
-
- if (attentionRequestsEnabled) {
- updateAttentionRequestsCount(attentionRequests);
- }
})
.catch((ex) => {
console.error(ex); // eslint-disable-line no-console
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
index f0726ff3e63..05ca7fd75c3 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
@@ -3,13 +3,11 @@ import { GlButtonGroup } from '@gitlab/ui';
import { BubbleMenu } from '@tiptap/vue-2';
import { BUBBLE_MENU_TRACKING_ACTION } from '../../constants';
import trackUIControl from '../../services/track_ui_control';
-import Image from '../../extensions/image';
+import Paragraph from '../../extensions/paragraph';
+import Heading from '../../extensions/heading';
import Audio from '../../extensions/audio';
import Video from '../../extensions/video';
-import Code from '../../extensions/code';
-import CodeBlockHighlight from '../../extensions/code_block_highlight';
-import Diagram from '../../extensions/diagram';
-import Frontmatter from '../../extensions/frontmatter';
+import Image from '../../extensions/image';
import ToolbarButton from '../toolbar_button.vue';
export default {
@@ -27,17 +25,13 @@ export default {
shouldShow: ({ editor, from, to }) => {
if (from === to) return false;
- const exclude = [
- Code.name,
- CodeBlockHighlight.name,
- Diagram.name,
- Frontmatter.name,
- Image.name,
- Audio.name,
- Video.name,
- ];
+ const includes = [Paragraph.name, Heading.name];
+ const excludes = [Image.name, Audio.name, Video.name];
- return !exclude.some((type) => editor.isActive(type));
+ return (
+ includes.some((type) => editor.isActive(type)) &&
+ !excludes.some((type) => editor.isActive(type))
+ );
},
},
};
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 74ae37b6d06..c3c881d9135 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -84,7 +84,14 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
- <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
+ <editor-state-observer
+ @docUpdate="notifyChange"
+ @focus="focus"
+ @blur="blur"
+ @loading="$emit('loading')"
+ @loadingSuccess="$emit('loadingSuccess')"
+ @loadingError="$emit('loadingError')"
+ />
<content-editor-alert />
<div
data-testid="content-editor"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index 6e4cde5dad6..9ad739e7358 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -33,8 +33,12 @@ export default {
this.$emit('execute', { contentType: listType });
},
- execute(command, contentType) {
- this.tiptapEditor.chain().focus()[command]().run();
+ execute(command, contentType, ...args) {
+ this.tiptapEditor
+ .chain()
+ .focus()
+ [command](...args)
+ .run();
this.$emit('execute', { contentType });
},
@@ -67,5 +71,8 @@ export default {
<gl-dropdown-item @click="insert('diagram', { language: 'plantuml' })">
{{ __('PlantUML diagram') }}
</gl-dropdown-item>
+ <gl-dropdown-item @click="execute('insertTableOfContents', 'tableOfContents')">
+ {{ __('Table of contents') }}
+ </gl-dropdown-item>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 65d71814268..1030ebbf838 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -93,7 +93,7 @@ export default {
icon-name="list-task"
class="gl-mx-2 gl-display-none gl-sm-display-inline"
editor-command="toggleTaskList"
- :label="__('Add a task list')"
+ :label="__('Add a checklist')"
@execute="trackToolbarControlExecution"
/>
<toolbar-image-button
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 c0d6e32a739..6456540a0dd 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
@@ -1,7 +1,7 @@
<script>
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
-import { selectedRect as getSelectedRect } from 'prosemirror-tables';
+import { selectedRect as getSelectedRect } from '@_ueberdosis/prosemirror-tables';
import { __ } from '~/locale';
const TABLE_CELL_HEADER = 'th';
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue
new file mode 100644
index 00000000000..a4e1be9d95d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents.vue
@@ -0,0 +1,55 @@
+<script>
+import { debounce } from 'lodash';
+import { NodeViewWrapper } from '@tiptap/vue-2';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { getHeadings } from '../../services/table_of_contents_utils';
+import TableOfContentsHeading from './table_of_contents_heading.vue';
+
+export default {
+ name: 'TableOfContentsWrapper',
+ components: {
+ NodeViewWrapper,
+ TableOfContentsHeading,
+ },
+ props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
+ node: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ headings: [],
+ };
+ },
+ mounted() {
+ this.handleUpdate = debounce(this.handleUpdate, DEFAULT_DEBOUNCE_AND_THROTTLE_MS * 2);
+
+ this.editor.on('update', this.handleUpdate);
+ this.$nextTick(this.handleUpdate);
+ },
+ methods: {
+ handleUpdate() {
+ this.headings = getHeadings(this.editor);
+ },
+ },
+};
+</script>
+<template>
+ <node-view-wrapper
+ as="ul"
+ class="table-of-contents gl-border-1 gl-border-solid gl-border-gray-100 gl-mb-5 gl-p-4!"
+ data-testid="table-of-contents"
+ >
+ {{ __('Table of contents') }}
+ <table-of-contents-heading
+ v-for="(heading, index) in headings"
+ :key="index"
+ :heading="heading"
+ />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
new file mode 100644
index 00000000000..edd75d232e8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_of_contents_heading.vue
@@ -0,0 +1,25 @@
+<script>
+export default {
+ name: 'TableOfContentsHeading',
+ props: {
+ heading: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <li>
+ <a v-if="heading.text" href="#" @click.prevent>
+ {{ heading.text }}
+ </a>
+ <ul v-if="heading.subHeadings.length">
+ <table-of-contents-heading
+ v-for="(child, index) in heading.subHeadings"
+ :key="index"
+ :heading="child"
+ />
+ </ul>
+ </li>
+</template>
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 edf8b3d3a0b..27432b1e18b 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -1,3 +1,4 @@
+import { lowlight } from 'lowlight/lib/core';
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { textblockTypeInputRule } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
@@ -66,4 +67,4 @@ export default CodeBlockLowlight.extend({
addNodeView() {
return new VueNodeViewRenderer(CodeBlockWrapper);
},
-});
+}).configure({ lowlight });
diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js
index c59ca8a28b8..d9983b8c1c5 100644
--- a/app/assets/javascripts/content_editor/extensions/diagram.js
+++ b/app/assets/javascripts/content_editor/extensions/diagram.js
@@ -1,3 +1,4 @@
+import { lowlight } from 'lowlight/lib/core';
import { textblockTypeInputRule } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import languageLoader from '../services/code_block_language_loader';
@@ -10,6 +11,12 @@ export default CodeBlockHighlight.extend({
isolating: true,
+ addOptions() {
+ return {
+ lowlight,
+ };
+ },
+
addAttributes() {
return {
language: {
diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js
index 2ec22158106..428171a9389 100644
--- a/app/assets/javascripts/content_editor/extensions/frontmatter.js
+++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js
@@ -1,9 +1,16 @@
+import { lowlight } from 'lowlight/lib/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
export default CodeBlockHighlight.extend({
name: 'frontmatter',
+ addOptions() {
+ return {
+ lowlight,
+ };
+ },
+
addAttributes() {
return {
...this.parent?.(),
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index 25f976f524f..65849ec4d0d 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -34,6 +34,7 @@ export default Image.extend({
canonicalSrc: {
default: null,
parseHTML: (element) => element.dataset.canonicalSrc,
+ renderHTML: () => '',
},
alt: {
default: null,
@@ -51,6 +52,10 @@ export default Image.extend({
return img.getAttribute('title');
},
},
+ isReference: {
+ default: false,
+ renderHTML: () => '',
+ },
};
},
parseHTML() {
@@ -71,7 +76,6 @@ export default Image.extend({
src: HTMLAttributes.src,
alt: HTMLAttributes.alt,
title: HTMLAttributes.title,
- 'data-canonical-src': HTMLAttributes.canonicalSrc,
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index f9b12f631fe..e985e561fda 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -56,6 +56,11 @@ export default Link.extend({
canonicalSrc: {
default: null,
parseHTML: (element) => element.dataset.canonicalSrc,
+ renderHTML: () => '',
+ },
+ isReference: {
+ default: false,
+ renderHTML: () => '',
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/reference_definition.js b/app/assets/javascripts/content_editor/extensions/reference_definition.js
new file mode 100644
index 00000000000..e2762fe9fd9
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/reference_definition.js
@@ -0,0 +1,29 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'referenceDefinition',
+
+ group: 'block',
+
+ content: 'text*',
+
+ marks: '',
+
+ addAttributes() {
+ return {
+ identifier: {
+ default: null,
+ },
+ url: {
+ default: null,
+ },
+ title: {
+ default: null,
+ },
+ };
+ },
+
+ renderHTML() {
+ return ['pre', {}, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index 618f17b1c5e..f9de71f601b 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -6,6 +6,7 @@ import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
import FootnoteReference from './footnote_reference';
import FootnoteDefinition from './footnote_definition';
+import Frontmatter from './frontmatter';
import Heading from './heading';
import HardBreak from './hard_break';
import HorizontalRule from './horizontal_rule';
@@ -16,6 +17,7 @@ import Link from './link';
import ListItem from './list_item';
import OrderedList from './ordered_list';
import Paragraph from './paragraph';
+import ReferenceDefinition from './reference_definition';
import Strike from './strike';
import TaskList from './task_list';
import TaskItem from './task_item';
@@ -36,6 +38,7 @@ export default Extension.create({
CodeBlockHighlight.name,
FootnoteReference.name,
FootnoteDefinition.name,
+ Frontmatter.name,
HardBreak.name,
Heading.name,
HorizontalRule.name,
@@ -45,6 +48,7 @@ export default Extension.create({
ListItem.name,
OrderedList.name,
Paragraph.name,
+ ReferenceDefinition.name,
Strike.name,
TaskList.name,
TaskItem.name,
diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
index a8882f9ede4..f64ed67199f 100644
--- a/app/assets/javascripts/content_editor/extensions/table_of_contents.js
+++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js
@@ -1,6 +1,8 @@
import { Node, InputRule } from '@tiptap/core';
-import { s__ } from '~/locale';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import { __ } from '~/locale';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import TableOfContentsWrapper from '../components/wrappers/table_of_contents.vue';
export default Node.create({
name: 'tableOfContents',
@@ -25,9 +27,18 @@ export default Node.create({
class:
'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5',
},
- s__('ContentEditor|Table of Contents'),
+ __('Table of contents'),
];
},
+ addNodeView() {
+ return VueNodeViewRenderer(TableOfContentsWrapper);
+ },
+
+ addCommands() {
+ return {
+ insertTableOfContents: () => ({ commands }) => commands.insertContent({ type: this.name }),
+ };
+ },
addInputRules() {
const { type } = this;
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 867bf0b4d55..75d8581890f 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,4 +1,3 @@
-import { TextSelection } from 'prosemirror-state';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */
@@ -59,7 +58,6 @@ export class ContentEditor {
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _eventHub: eventHub } = this;
const { doc, tr } = editor.state;
- const selection = TextSelection.create(doc, 0, doc.content.size);
try {
eventHub.$emit(LOADING_CONTENT_EVENT);
@@ -67,9 +65,7 @@ export class ContentEditor {
if (document) {
this._pristineDoc = document;
- tr.setSelection(selection)
- .replaceSelectionWith(document, false)
- .setMeta('preventUpdate', true);
+ tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
editor.view.dispatch(tr);
}
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index c5cfa9a4285..7a289df94ea 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -1,6 +1,5 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
-import { lowlight } from 'lowlight/lib/core';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
@@ -43,6 +42,7 @@ import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import PasteMarkdown from '../extensions/paste_markdown';
import Reference from '../extensions/reference';
+import ReferenceDefinition from '../extensions/reference_definition';
import Sourcemap from '../extensions/sourcemap';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
@@ -96,7 +96,7 @@ export const createContentEditor = ({
BulletList,
Code,
ColorChip,
- CodeBlockHighlight.configure({ lowlight }),
+ CodeBlockHighlight,
DescriptionItem,
DescriptionList,
Details,
@@ -110,7 +110,7 @@ export const createContentEditor = ({
FootnoteDefinition,
FootnoteReference,
FootnotesSection,
- Frontmatter.configure({ lowlight }),
+ Frontmatter,
Gapcursor,
HardBreak,
Heading,
@@ -127,8 +127,9 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
- PasteMarkdown.configure({ renderMarkdown, eventHub }),
+ PasteMarkdown,
Reference,
+ ReferenceDefinition,
Sourcemap,
Strike,
Subscript,
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 312ab88de4a..28a50adca6b 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
@@ -21,7 +21,7 @@
import { Mark } from 'prosemirror-model';
import { visitParents, SKIP } from 'unist-util-visit-parents';
-import { isFunction, isString, noop } from 'lodash';
+import { isFunction, isString, noop, mapValues } from 'lodash';
const NO_ATTRIBUTES = {};
@@ -73,28 +73,48 @@ function createSourceMapAttributes(hastNode, markdown) {
}
/**
- * Compute ProseMirror node’s attributes from a Hast node.
- * By default, this function includes sourcemap position
- * information in the object returned.
- *
- * Other attributes are retrieved by invoking a getAttrs
- * function provided by the ProseMirror node factory spec.
- *
- * @param {*} proseMirrorNodeSpec ProseMirror node spec object
- * @param {HastNode} hastNode A hast node
- * @param {Array<HastNode>} hastParents All the ancestors of the hastNode
- * @param {String} markdown Markdown source file’s content
- *
- * @returns An object that contains a ProseMirror node’s attributes
+ * Creates a function that resolves the attributes
+ * of a ProseMirror node based on a hast node.
+ *
+ * @param {Object} params Parameters
+ * @param {String} params.markdown Markdown source from which the AST was generated
+ * @param {Object} params.attributeTransformer An object that allows applying a transformation
+ * function to all the attributes listed in the attributes property.
+ * @param {Array} params.attributeTransformer.attributes A list of attributes names
+ * that the getAttrs function should apply the transformation
+ * @param {Function} params.attributeTransformer.transform A function that applies
+ * a transform operation on an attribute value.
+ * @returns A `getAttrs` function
*/
-function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) {
- const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
+const getAttrsFactory = ({ attributeTransformer, markdown }) =>
+ /**
+ * Compute ProseMirror node’s attributes from a Hast node.
+ * By default, this function includes sourcemap position
+ * information in the object returned.
+ *
+ * Other attributes are retrieved by invoking a getAttrs
+ * function provided by the ProseMirror node factory spec.
+ *
+ * @param {Object} proseMirrorNodeSpec ProseMirror node spec object
+ * @param {Object} hastNode A hast node
+ * @param {Array} hastParents All the ancestors of the hastNode
+ * @param {String} markdown Markdown source file’s content
+ * @returns An object that contains a ProseMirror node’s attributes
+ */
+ function getAttrs(proseMirrorNodeSpec, hastNode, hastParents) {
+ const { getAttrs: specGetAttrs } = proseMirrorNodeSpec;
+ const attributes = {
+ ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
+ };
+ const { transform } = attributeTransformer;
- return {
- ...createSourceMapAttributes(hastNode, markdown),
- ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}),
+ return {
+ ...createSourceMapAttributes(hastNode, markdown),
+ ...mapValues(attributes, (attributeValue, attributeName) =>
+ transform(attributeName, attributeValue, hastNode),
+ ),
+ };
};
-}
/**
* Keeps track of the Hast -> ProseMirror conversion process.
@@ -322,7 +342,13 @@ class HastToProseMirrorConverterState {
*
* @returns An object that contains ProseMirror node factories
*/
-const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => {
+const createProseMirrorNodeFactories = (
+ schema,
+ proseMirrorFactorySpecs,
+ attributeTransformer,
+ markdown,
+) => {
+ const getAttrs = getAttrsFactory({ attributeTransformer, markdown });
const factories = {
root: {
selector: 'root',
@@ -355,20 +381,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdow
const nodeType = schema.nodeType(proseMirrorName);
state.closeUntil(parent);
- state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory);
};
} else if (factory.type === 'inline') {
const nodeType = schema.nodeType(proseMirrorName);
factory.handle = (state, hastNode, parent) => {
state.closeUntil(parent);
- state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
+ state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent), factory);
// Inline nodes do not have children therefore they are immediately closed
state.closeNode();
};
} else if (factory.type === 'mark') {
const markType = schema.marks[proseMirrorName];
factory.handle = (state, hastNode, parent) => {
- state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory);
+ state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent), factory);
};
} else if (factory.type === 'ignore') {
factory.handle = noop;
@@ -581,9 +607,15 @@ export const createProseMirrorDocFromMdastTree = ({
factorySpecs,
wrappableTags,
tree,
+ attributeTransformer,
markdown,
}) => {
- const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown);
+ const proseMirrorNodeFactories = createProseMirrorNodeFactories(
+ schema,
+ factorySpecs,
+ attributeTransformer,
+ markdown,
+ );
const state = new HastToProseMirrorConverterState();
visitParents(tree, (hastNode, ancestors) => {
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index c1c7af6b1af..472a0a4815b 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -33,6 +33,7 @@ import MathInline from '../extensions/math_inline';
import OrderedList from '../extensions/ordered_list';
import Paragraph from '../extensions/paragraph';
import Reference from '../extensions/reference';
+import ReferenceDefinition from '../extensions/reference_definition';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
import Superscript from '../extensions/superscript';
@@ -148,10 +149,13 @@ const defaultSerializerConfig = {
state.renderInline(node);
state.ensureNewLine();
}),
- [FootnoteReference.name]: preserveUnchanged((state, node) => {
- state.write(`[^${node.attrs.identifier}]`);
+ [FootnoteReference.name]: preserveUnchanged({
+ render: (state, node) => {
+ state.write(`[^${node.attrs.identifier}]`);
+ },
+ inline: true,
}),
- [Frontmatter.name]: (state, node) => {
+ [Frontmatter.name]: preserveUnchanged((state, node) => {
const { language } = node.attrs;
const syntax = {
toml: '+++',
@@ -164,19 +168,41 @@ const defaultSerializerConfig = {
state.ensureNewLine();
state.write(syntax);
state.closeBlock(node);
- },
+ }),
[Figure.name]: renderHTMLNode('figure'),
[FigureCaption.name]: renderHTMLNode('figcaption'),
[HardBreak.name]: preserveUnchanged(renderHardBreak),
[Heading.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.heading),
[HorizontalRule.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.horizontal_rule),
- [Image.name]: preserveUnchanged(renderImage),
+ [Image.name]: preserveUnchanged({
+ render: renderImage,
+ inline: true,
+ }),
[ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item),
[OrderedList.name]: preserveUnchanged(renderOrderedList),
[Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph),
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
+ [ReferenceDefinition.name]: preserveUnchanged({
+ render: (state, node, parent, index, same, sourceMarkdown) => {
+ const nextSibling = parent.maybeChild(index + 1);
+
+ state.text(same ? sourceMarkdown : node.textContent, false);
+
+ /**
+ * Do not insert a blank line between reference definitions
+ * because it isn’t necessary and a more compact text format
+ * is preferred.
+ */
+ if (!nextSibling || nextSibling.type.name !== ReferenceDefinition.name) {
+ state.closeBlock(node);
+ } else {
+ state.ensureNewLine();
+ }
+ },
+ overwriteSourcePreservationStrategy: true,
+ }),
[TableOfContents.name]: (state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
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 8e2c066e011..8a15633708f 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,4 +1,5 @@
import { render } from '~/lib/gfm';
+import { isValidAttribute } from '~/lib/dompurify';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
@@ -125,6 +126,8 @@ const factorySpecs = {
selector: 'img',
getAttrs: (hastNode) => ({
src: hastNode.properties.src,
+ canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.src,
+ isReference: hastNode.properties.isReference === 'true',
title: hastNode.properties.title,
alt: hastNode.properties.alt,
}),
@@ -154,7 +157,9 @@ const factorySpecs = {
type: 'mark',
selector: 'a',
getAttrs: (hastNode) => ({
+ canonicalSrc: hastNode.properties.identifier ?? hastNode.properties.href,
href: hastNode.properties.href,
+ isReference: hastNode.properties.isReference === 'true',
title: hastNode.properties.title,
}),
},
@@ -170,6 +175,55 @@ const factorySpecs = {
type: 'ignore',
selector: (hastNode) => hastNode.type === 'comment',
},
+
+ referenceDefinition: {
+ type: 'block',
+ selector: 'referencedefinition',
+ getAttrs: (hastNode) => ({
+ title: hastNode.properties.title,
+ url: hastNode.properties.url,
+ identifier: hastNode.properties.identifier,
+ }),
+ },
+
+ frontmatter: {
+ type: 'block',
+ selector: 'frontmatter',
+ getAttrs: (hastNode) => ({
+ language: hastNode.properties.language,
+ }),
+ },
+};
+
+const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference'];
+
+const sanitizeAttribute = (attributeName, attributeValue, hastNode) => {
+ if (!attributeValue || SANITIZE_ALLOWLIST.includes(attributeName)) {
+ return attributeValue;
+ }
+
+ /**
+ * This is a workaround to validate the value of the canonicalSrc
+ * attribute using DOMPurify without passing the attribute name. canonicalSrc
+ * is not an allowed attribute in DOMPurify therefore the library will remove
+ * it regardless of its value.
+ *
+ * We want to preserve canonicalSrc, and we also want to make sure that its
+ * value is sanitized.
+ */
+ const validateAttributeAs = attributeName === 'canonicalSrc' ? 'src' : attributeName;
+
+ if (!isValidAttribute(hastNode.tagName, validateAttributeAs, attributeValue)) {
+ return null;
+ }
+
+ return attributeValue;
+};
+
+const attributeTransformer = {
+ transform: (attributeName, attributeValue, hastNode) => {
+ return sanitizeAttribute(attributeName, attributeValue, hastNode);
+ },
};
export default () => {
@@ -183,9 +237,20 @@ export default () => {
factorySpecs,
tree,
wrappableTags,
+ attributeTransformer,
markdown,
}),
- skipRendering: ['footnoteReference', 'footnoteDefinition', 'code'],
+ skipRendering: [
+ 'footnoteReference',
+ 'footnoteDefinition',
+ 'code',
+ 'definition',
+ 'linkReference',
+ 'imageReference',
+ 'yaml',
+ 'toml',
+ 'json',
+ ],
});
return { document };
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
index 7d5e718b41c..41114571df7 100644
--- a/app/assets/javascripts/content_editor/services/serialization_helpers.js
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -1,4 +1,5 @@
-import { uniq, isString, omit } from 'lodash';
+import { uniq, isString, omit, isFunction } from 'lodash';
+import { removeLastSlashInUrlPath, removeUrlProtocol } from '../../lib/utils/url_utility';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
@@ -306,12 +307,15 @@ export function renderHardBreak(state, node, parent, index) {
}
export function renderImage(state, node) {
- const { alt, canonicalSrc, src, title } = node.attrs;
+ const { alt, canonicalSrc, src, title, isReference } = node.attrs;
if (isString(src) || isString(canonicalSrc)) {
const quotedTitle = title ? ` ${state.quote(title)}` : '';
+ const sourceExpression = isReference
+ ? `[${canonicalSrc}]`
+ : `(${state.esc(canonicalSrc || src)}${quotedTitle})`;
- state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
+ state.write(`![${state.esc(alt || '')}]${sourceExpression}`);
}
}
@@ -327,16 +331,28 @@ export function renderCodeBlock(state, node) {
state.closeBlock(node);
}
-export function preserveUnchanged(render) {
+const expandPreserveUnchangedConfig = (configOrRender) =>
+ isFunction(configOrRender)
+ ? { render: configOrRender, overwriteSourcePreservationStrategy: false, inline: false }
+ : configOrRender;
+
+export function preserveUnchanged(configOrRender) {
return (state, node, parent, index) => {
+ const { render, overwriteSourcePreservationStrategy, inline } = expandPreserveUnchangedConfig(
+ configOrRender,
+ );
+
const { sourceMarkdown } = node.attrs;
const same = state.options.changeTracker.get(node);
- if (same) {
+ if (same && !overwriteSourcePreservationStrategy) {
state.write(sourceMarkdown);
- state.closeBlock(node);
+
+ if (!inline) {
+ state.closeBlock(node);
+ }
} else {
- render(state, node, parent, index);
+ render(state, node, parent, index, same, sourceMarkdown);
}
};
}
@@ -488,24 +504,16 @@ const linkType = (sourceMarkdown) => {
return LINK_HTML;
};
-const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
-
-const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url));
+const normalizeUrl = (url) => decodeURIComponent(removeLastSlashInUrlPath(removeUrlProtocol(url)));
/**
- * Validates that the provided URL is well-formed
+ * Validates that the provided URL is a valid GFM autolink
*
* @param {String} url
- * @returns Returns true when the browser’s URL constructor
- * can successfully parse the URL string
+ * @returns Returns true when the URL is a valid GFM autolink
*/
-const isValidUrl = (url) => {
- try {
- return new URL(url) && true;
- } catch {
- return false;
- }
-};
+const isValidAutolinkURL = (url) =>
+ /(https?:\/\/)?([\w-])+\.{1}([a-zA-Z]{2,63})([/\w-]*)*\/?\??([^#\n\r]*)?#?([^\n\r]*)/.test(url);
const findChildWithMark = (mark, parent) => {
let child;
@@ -542,7 +550,7 @@ const isAutoLink = (linkMark, parent) => {
if (
!child ||
!child.isText ||
- !isValidUrl(href) ||
+ !isValidAutolinkURL(href) ||
normalizeUrl(child.text) !== normalizeUrl(href)
) {
return false;
@@ -582,7 +590,11 @@ export const link = {
return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : '';
}
- const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs;
+ const { canonicalSrc, href, title, sourceMarkdown, isReference } = mark.attrs;
+
+ if (isReference) {
+ return `][${state.esc(canonicalSrc || href)}]`;
+ }
if (linkType(sourceMarkdown) === LINK_HTML) {
return closeTag('a');
diff --git a/app/assets/javascripts/content_editor/services/table_of_contents_utils.js b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js
new file mode 100644
index 00000000000..dad917b2270
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/table_of_contents_utils.js
@@ -0,0 +1,67 @@
+export function fillEmpty(headings) {
+ for (let i = 0; i < headings.length; i += 1) {
+ let j = headings[i - 1]?.level || 0;
+ if (headings[i].level - j > 1) {
+ while (j < headings[i].level) {
+ headings.splice(i, 0, { level: j + 1, text: '' });
+ j += 1;
+ }
+ }
+ }
+
+ return headings;
+}
+
+const exitHeadingBranch = (heading, targetLevel) => {
+ let currentHeading = heading;
+
+ while (currentHeading.level > targetLevel) {
+ currentHeading = currentHeading.parent;
+ }
+
+ return currentHeading;
+};
+
+export function toTree(headings) {
+ fillEmpty(headings);
+
+ const tree = [];
+ let currentHeading;
+ for (let i = 0; i < headings.length; i += 1) {
+ const heading = headings[i];
+ if (heading.level === 1) {
+ const h = { ...heading, subHeadings: [] };
+ tree.push(h);
+ currentHeading = h;
+ } else if (heading.level > currentHeading.level) {
+ const h = { ...heading, subHeadings: [], parent: currentHeading };
+ currentHeading.subHeadings.push(h);
+ currentHeading = h;
+ } else if (heading.level <= currentHeading.level) {
+ currentHeading = exitHeadingBranch(currentHeading, heading.level - 1);
+
+ const h = { ...heading, subHeadings: [], parent: currentHeading };
+ (currentHeading?.subHeadings || headings).push(h);
+ currentHeading = h;
+ }
+ }
+
+ return tree;
+}
+
+export function getHeadings(editor) {
+ const headings = [];
+
+ editor.state.doc.descendants((node) => {
+ if (node.type.name !== 'heading') return false;
+
+ headings.push({
+ level: node.attrs.level,
+ text: node.textContent,
+ });
+
+ return true;
+ });
+
+ return toTree(headings);
+}
diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js
index 79f5c701fb8..8b432b2041a 100644
--- a/app/assets/javascripts/contributors/stores/getters.js
+++ b/app/assets/javascripts/contributors/stores/getters.js
@@ -4,15 +4,15 @@ export const parsedData = (state) => {
const byAuthorEmail = {};
const total = {};
- state.chartData.forEach(({ date, author_name, author_email }) => {
+ state.chartData.forEach(({ date, author_name: name, author_email: email }) => {
total[date] = total[date] ? total[date] + 1 : 1;
- const normalizedEmail = author_email.toLowerCase();
+ const normalizedEmail = email.toLowerCase();
const authorData = byAuthorEmail[normalizedEmail];
if (!authorData) {
byAuthorEmail[normalizedEmail] = {
- name: author_name,
+ name,
commits: 1,
dates: {
[date]: 1,
diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/form.vue
index 72def54aedf..ea6a6892bbd 100644
--- a/app/assets/javascripts/crm/components/form.vue
+++ b/app/assets/javascripts/crm/components/form.vue
@@ -1,5 +1,13 @@
<script>
-import { GlAlert, GlButton, GlDrawer, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlButton,
+ GlDrawer,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInput,
+ GlFormSelect,
+} from '@gitlab/ui';
import { get as getPropValueByPath, isEmpty } from 'lodash';
import { produce } from 'immer';
import { MountingPortal } from 'portal-vue';
@@ -26,6 +34,7 @@ export default {
GlAlert,
GlButton,
GlDrawer,
+ GlFormCheckbox,
GlFormGroup,
GlFormInput,
GlFormSelect,
@@ -113,7 +122,9 @@ export default {
const { fields, model } = this;
return fields.some((field) => {
- return field.required && isEmpty(model[field.name]);
+ return (
+ field.required && isEmpty(model[field.name]) && typeof model[field.name] !== 'boolean'
+ );
});
},
variables() {
@@ -216,6 +227,8 @@ export default {
});
},
getFieldLabel(field) {
+ if (field.bool) return null;
+
const optionalSuffix = field.required ? '' : ` ${MSG_OPTIONAL}`;
return field.label + optionalSuffix;
},
@@ -273,6 +286,9 @@ export default {
v-model="model[field.name]"
:options="field.values"
/>
+ <gl-form-checkbox v-else-if="field.bool" :id="field.name" v-model="model[field.name]"
+ ><span class="gl-font-weight-bold">{{ field.label }}</span></gl-form-checkbox
+ >
<gl-form-input v-else :id="field.name" v-bind="field.input" v-model="model[field.name]" />
</gl-form-group>
<span class="gl-float-right">
diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js
index 3b085837aea..815289e075e 100644
--- a/app/assets/javascripts/crm/constants.js
+++ b/app/assets/javascripts/crm/constants.js
@@ -1,3 +1,7 @@
export const INDEX_ROUTE_NAME = 'index';
export const NEW_ROUTE_NAME = 'new';
export const EDIT_ROUTE_NAME = 'edit';
+export const trackViewsOptions = {
+ category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */,
+ action: 'view_contacts_list',
+};
diff --git a/app/assets/javascripts/crm/contacts/bundle.js b/app/assets/javascripts/crm/contacts/bundle.js
index f49ec64210f..fe62b7cfbe3 100644
--- a/app/assets/javascripts/crm/contacts/bundle.js
+++ b/app/assets/javascripts/crm/contacts/bundle.js
@@ -3,6 +3,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import CrmContactsRoot from './components/contacts_root.vue';
import routes from './routes';
@@ -21,7 +22,14 @@ export default () => {
return false;
}
- const { basePath, groupFullPath, groupIssuesPath, canAdminCrmContact, groupId } = el.dataset;
+ const {
+ basePath,
+ groupFullPath,
+ groupIssuesPath,
+ canAdminCrmContact,
+ groupId,
+ textQuery,
+ } = el.dataset;
const router = new VueRouter({
base: basePath,
@@ -33,7 +41,13 @@ export default () => {
el,
router,
apolloProvider,
- provide: { groupFullPath, groupIssuesPath, canAdminCrmContact, groupId },
+ provide: {
+ groupFullPath,
+ groupIssuesPath,
+ canAdminCrmContact: parseBoolean(canAdminCrmContact),
+ groupId,
+ textQuery,
+ },
render(createElement) {
return createElement(CrmContactsRoot);
},
diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
index f114ffedfe6..b29089519e2 100644
--- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
+++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue
@@ -57,7 +57,7 @@ export default {
getQuery() {
return {
query: getGroupContactsQuery,
- variables: { groupFullPath: this.groupFullPath },
+ variables: { groupFullPath: this.groupFullPath, ids: [this.contactGraphQLId] },
};
},
title() {
@@ -74,7 +74,7 @@ export default {
return { groupId: this.groupGraphQLId };
},
fields() {
- return [
+ const fields = [
{ name: 'firstName', label: __('First name'), required: true },
{ name: 'lastName', label: __('Last name'), required: true },
{ name: 'email', label: __('Email'), required: true },
@@ -86,6 +86,11 @@ export default {
},
{ name: 'description', label: __('Description') },
];
+
+ if (this.isEditMode)
+ fields.push({ name: 'active', label: s__('Crm|Active'), required: true, bool: true });
+
+ return fields;
},
organizationSelectValues() {
const values = this.organizations.map((o) => {
diff --git a/app/assets/javascripts/crm/contacts/components/contacts_root.vue b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
index 9d6f34c73b7..562363ff88e 100644
--- a/app/assets/javascripts/crm/contacts/components/contacts_root.vue
+++ b/app/assets/javascripts/crm/contacts/components/contacts_root.vue
@@ -1,36 +1,54 @@
<script>
-import { GlAlert, GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { GlButton, GlLoadingIcon, GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME } from '../../constants';
-import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql';
+import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
+import {
+ bodyTrClass,
+ initialPaginationState,
+} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
+import { convertToSnakeCase } from '~/lib/utils/text_utility';
+import { EDIT_ROUTE_NAME, NEW_ROUTE_NAME, trackViewsOptions } from '../../constants';
+import getGroupContacts from './graphql/get_group_contacts.query.graphql';
+import getGroupContactsCountByState from './graphql/get_group_contacts_count_by_state.graphql';
export default {
components: {
- GlAlert,
GlButton,
GlLoadingIcon,
GlTable,
+ PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath'],
+ inject: ['canAdminCrmContact', 'groupFullPath', 'groupIssuesPath', 'textQuery'],
data() {
return {
- contacts: [],
+ contacts: { list: [] },
+ contactsCount: {},
error: false,
+ filteredByStatus: '',
+ pagination: initialPaginationState,
+ statusFilter: 'all',
+ searchTerm: this.textQuery,
+ sort: 'LAST_NAME_ASC',
+ sortDesc: false,
};
},
apollo: {
contacts: {
- query() {
- return getGroupContactsQuery;
- },
+ query: getGroupContacts,
variables() {
return {
groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ state: this.statusFilter,
+ sort: this.sort,
+ firstPageSize: this.pagination.firstPageSize,
+ lastPageSize: this.pagination.lastPageSize,
+ prevPageCursor: this.pagination.prevPageCursor,
+ nextPageCursor: this.pagination.nextPageCursor,
};
},
update(data) {
@@ -40,19 +58,52 @@ export default {
this.error = true;
},
},
+ contactsCount: {
+ query: getGroupContactsCountByState,
+ variables() {
+ return {
+ groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ };
+ },
+ update(data) {
+ return data?.group?.contactStateCounts;
+ },
+ error() {
+ this.error = true;
+ },
+ },
},
computed: {
isLoading() {
return this.$apollo.queries.contacts.loading;
},
- canAdmin() {
- return parseBoolean(this.canAdminCrmContact);
+ tbodyTrClass() {
+ return {
+ [bodyTrClass]: !this.loading && !this.isEmpty,
+ };
},
},
methods: {
+ errorAlertDismissed() {
+ this.error = true;
+ },
extractContacts(data) {
const contacts = data?.group?.contacts?.nodes || [];
- return contacts.slice().sort((a, b) => a.firstName.localeCompare(b.firstName));
+ const pageInfo = data?.group?.contacts?.pageInfo || {};
+ return {
+ list: contacts,
+ pageInfo,
+ };
+ },
+ fetchSortedData({ sortBy, sortDesc }) {
+ const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
+ const sortingDirection = sortDesc ? 'DESC' : 'ASC';
+ this.pagination = initialPaginationState;
+ this.sort = `${sortingColumn}_${sortingDirection}`;
+ },
+ filtersChanged({ searchTerm }) {
+ this.searchTerm = searchTerm;
},
getIssuesPath(path, value) {
return `${path}?crm_contact_id=${value}`;
@@ -60,6 +111,13 @@ export default {
getEditRoute(id) {
return { name: this.$options.EDIT_ROUTE_NAME, params: { id } };
},
+ pageChanged(pagination) {
+ this.pagination = pagination;
+ },
+ statusChanged({ filters, status }) {
+ this.statusFilter = filters;
+ this.filteredByStatus = status;
+ },
},
fields: [
{ key: 'firstName', sortable: true },
@@ -92,57 +150,109 @@ export default {
},
EDIT_ROUTE_NAME,
NEW_ROUTE_NAME,
+ statusTabs: [
+ {
+ title: __('Active'),
+ status: 'ACTIVE',
+ filters: 'active',
+ },
+ {
+ title: __('Inactive'),
+ status: 'INACTIVE',
+ filters: 'inactive',
+ },
+ {
+ title: __('All'),
+ status: 'ALL',
+ filters: 'all',
+ },
+ ],
+ trackViewsOptions,
+ emptyArray: [],
};
</script>
<template>
<div>
- <gl-alert v-if="error" variant="danger" class="gl-mt-6" @dismiss="error = false">
- {{ $options.i18n.errorText }}
- </gl-alert>
- <div
- class="gl-display-flex gl-align-items-baseline gl-flex-direction-row gl-justify-content-space-between gl-mt-6"
+ <paginated-table-with-search-and-tabs
+ :show-items="true"
+ :show-error-msg="false"
+ :i18n="$options.i18n"
+ :items="contacts.list"
+ :page-info="contacts.pageInfo"
+ :items-count="contactsCount"
+ :status-tabs="$options.statusTabs"
+ :track-views-options="$options.trackViewsOptions"
+ :filter-search-tokens="$options.emptyArray"
+ filter-search-key="contacts"
+ @page-changed="pageChanged"
+ @tabs-changed="statusChanged"
+ @filters-changed="filtersChanged"
+ @error-alert-dismissed="errorAlertDismissed"
>
- <h2 class="gl-font-size-h2 gl-my-0">
- {{ $options.i18n.title }}
- </h2>
- <div v-if="canAdmin">
- <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
- <gl-button variant="confirm" data-testid="new-contact-button">
+ <template #header-actions>
+ <router-link v-if="canAdminCrmContact" :to="{ name: $options.NEW_ROUTE_NAME }">
+ <gl-button class="gl-my-3 gl-mr-5" variant="confirm" data-testid="new-contact-button">
{{ $options.i18n.newContact }}
</gl-button>
</router-link>
- </div>
- </div>
- <router-view />
- <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="lg" />
- <gl-table
- v-else
- class="gl-mt-5"
- :items="contacts"
- :fields="$options.fields"
- :empty-text="$options.i18n.emptyText"
- show-empty
- >
- <template #cell(id)="{ value: id }">
- <gl-button
- v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
- class="gl-mr-3"
- data-testid="issues-link"
- icon="issues"
- :aria-label="$options.i18n.issuesButtonLabel"
- :href="getIssuesPath(groupIssuesPath, id)"
- />
- <router-link :to="getEditRoute(id)">
- <gl-button
- v-if="canAdmin"
- v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
- data-testid="edit-contact-button"
- icon="pencil"
- :aria-label="$options.i18n.editButtonLabel"
- />
- </router-link>
</template>
- </gl-table>
+
+ <template #title>
+ {{ $options.i18n.title }}
+ </template>
+
+ <template #table>
+ <gl-table
+ :items="contacts.list"
+ :fields="$options.fields"
+ :busy="isLoading"
+ stacked="md"
+ :tbody-tr-class="tbodyTrClass"
+ sort-direction="asc"
+ :sort-desc.sync="sortDesc"
+ sort-by="createdAt"
+ show-empty
+ no-local-sorting
+ sort-icon-left
+ fixed
+ @sort-changed="fetchSortedData"
+ >
+ <template #cell(id)="{ value: id }">
+ <gl-button
+ v-gl-tooltip.hover.bottom="$options.i18n.issuesButtonLabel"
+ class="gl-mr-3"
+ data-testid="issues-link"
+ icon="issues"
+ :aria-label="$options.i18n.issuesButtonLabel"
+ :href="getIssuesPath(groupIssuesPath, id)"
+ />
+ <router-link :to="getEditRoute(id)">
+ <gl-button
+ v-if="canAdminCrmContact"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-contact-button"
+ icon="pencil"
+ :aria-label="$options.i18n.editButtonLabel"
+ />
+ </router-link>
+ </template>
+
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+
+ <template #empty>
+ <span v-if="error">
+ {{ $options.i18n.errorText }}
+ </span>
+ <span v-else>
+ {{ $options.i18n.emptyText }}
+ </span>
+ </template>
+ </gl-table>
+ </template>
+ </paginated-table-with-search-and-tabs>
+ <router-view />
</div>
</template>
diff --git a/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
index cef4083b446..545ddbf5f72 100644
--- a/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/crm_contact_fields.fragment.graphql
@@ -1,13 +1,12 @@
fragment ContactFragment on CustomerRelationsContact {
- __typename
id
firstName
lastName
email
phone
description
+ active
organization {
- __typename
id
name
}
diff --git a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
index 2a8150e42e3..f04d02122fc 100644
--- a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
+++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts.query.graphql
@@ -1,13 +1,37 @@
#import "./crm_contact_fields.fragment.graphql"
-query contacts($groupFullPath: ID!) {
+query contacts(
+ $groupFullPath: ID!
+ $state: CustomerRelationsContactState
+ $searchTerm: String
+ $sort: ContactSort
+ $firstPageSize: Int
+ $lastPageSize: Int
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
+ $ids: [CustomerRelationsContactID!]
+) {
group(fullPath: $groupFullPath) {
- __typename
id
- contacts {
+ contacts(
+ state: $state
+ search: $searchTerm
+ sort: $sort
+ first: $firstPageSize
+ last: $lastPageSize
+ after: $nextPageCursor
+ before: $prevPageCursor
+ ids: $ids
+ ) {
nodes {
...ContactFragment
}
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
}
}
}
diff --git a/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql
new file mode 100644
index 00000000000..6b591240096
--- /dev/null
+++ b/app/assets/javascripts/crm/contacts/components/graphql/get_group_contacts_count_by_state.graphql
@@ -0,0 +1,11 @@
+query contactsCountByState($groupFullPath: ID!, $searchTerm: String) {
+ group(fullPath: $groupFullPath) {
+ __typename
+ id
+ contactStateCounts(search: $searchTerm) {
+ all
+ active
+ inactive
+ }
+ }
+}
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
index 4adc5742d3a..d723bf32ef5 100644
--- a/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/crm_organization_fields.fragment.graphql
@@ -1,7 +1,7 @@
fragment OrganizationFragment on CustomerRelationsOrganization {
- __typename
id
name
defaultRate
description
+ active
}
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
index e8d8109431e..97b75091cac 100644
--- a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
+++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql
@@ -2,7 +2,6 @@
query organizations($groupFullPath: ID!) {
group(fullPath: $groupFullPath) {
- __typename
id
organizations {
nodes {
diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
index 38468e1f4e4..5fd0294b0ea 100644
--- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
+++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
@@ -52,16 +52,23 @@ export default {
additionalCreateParams() {
return { groupId: this.groupGraphQLId };
},
- },
- fields: [
- { name: 'name', label: __('Name'), required: true },
- {
- name: 'defaultRate',
- label: s__('Crm|Default rate'),
- input: { type: 'number', step: '0.01' },
+ fields() {
+ const fields = [
+ { name: 'name', label: __('Name'), required: true },
+ {
+ name: 'defaultRate',
+ label: s__('Crm|Default rate'),
+ input: { type: 'number', step: '0.01' },
+ },
+ { name: 'description', label: __('Description') },
+ ];
+
+ if (this.isEditMode)
+ fields.push({ name: 'active', label: s__('Crm|Active'), required: true, bool: true });
+
+ return fields;
},
- { name: 'description', label: __('Description') },
- ],
+ },
};
</script>
@@ -73,7 +80,7 @@ export default {
:mutation="mutation"
:additional-create-params="additionalCreateParams"
:existing-id="organizationGraphQLId"
- :fields="$options.fields"
+ :fields="fields"
:title="title"
:success-message="successMessage"
/>
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index 1883030e51f..f06544f50c6 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -47,15 +47,13 @@ export default {
'selectedStage',
'selectedStageEvents',
'selectedStageError',
- 'stages',
- 'summary',
- 'permissions',
'stageCounts',
'endpoints',
'features',
'createdBefore',
'createdAfter',
'pagination',
+ 'hasNoAccessError',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
isLoaded() {
@@ -69,9 +67,7 @@ export default {
return !this.isLoadingStage && this.isEmptyStage;
},
displayNoAccess() {
- return (
- !this.isLoadingStage && this.selectedStage?.id && !this.isUserAllowed(this.selectedStage.id)
- );
+ return !this.isLoadingStage && this.hasNoAccessError;
},
displayPathNavigation() {
return this.isLoading || (this.selectedStage && this.pathNavigationData.length);
@@ -137,10 +133,6 @@ export default {
this.isOverviewDialogDismissed = true;
setCookie(OVERVIEW_DIALOG_COOKIE, '1');
},
- isUserAllowed(id) {
- const { permissions } = this;
- return Boolean(permissions?.[id]);
- },
onHandleUpdatePagination(data) {
this.updateStageTablePagination(data);
},
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index e0156b24f9d..5c2e29bfa74 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -1,7 +1,6 @@
import {
getProjectValueStreamStages,
getProjectValueStreams,
- getProjectValueStreamMetrics,
getValueStreamStageMedian,
getValueStreamStageRecords,
getValueStreamStageCounts,
@@ -52,24 +51,6 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => {
commit(types.RECEIVE_VALUE_STREAMS_ERROR, status);
});
};
-export const fetchCycleAnalyticsData = ({
- state: {
- endpoints: { requestPath },
- },
- getters: { legacyFilterParams },
- commit,
-}) => {
- commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
-
- return getProjectValueStreamMetrics(requestPath, legacyFilterParams)
- .then(({ data }) => commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data))
- .catch(() => {
- commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR);
- createFlash({
- message: __('There was an error while fetching value stream summary data.'),
- });
- });
-};
export const fetchStageData = ({
getters: { requestParams, filterParams, paginationParams },
@@ -153,7 +134,6 @@ export const fetchStageCountValues = ({
export const fetchValueStreamStageData = ({ dispatch }) =>
Promise.all([
- dispatch('fetchCycleAnalyticsData'),
dispatch('fetchStageData'),
dispatch('fetchStageMedians'),
dispatch('fetchStageCountValues'),
@@ -178,6 +158,11 @@ export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore
};
export const setInitialStage = ({ dispatch, commit, state: { stages } }, stage) => {
+ if (!stages.length && !stage) {
+ commit(types.SET_NO_ACCESS_ERROR);
+ return null;
+ }
+
const selectedStage = stage || stages[0];
commit(types.SET_SELECTED_STAGE, selectedStage);
return dispatch('fetchValueStreamStageData');
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 962e1d50d12..6fe353405d4 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -49,12 +49,6 @@ const dateRangeParams = ({ createdAfter, createdBefore }) => ({
created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null,
});
-export const legacyFilterParams = ({ daysInPast }) => {
- return {
- 'cycle_analytics[start_date]': daysInPast,
- };
-};
-
export const filterParams = (state) => {
return {
...filterBarParams(state),
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 0ad67d4e6bd..9376d81f317 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -5,6 +5,7 @@ export const SET_SELECTED_VALUE_STREAM = 'SET_SELECTED_VALUE_STREAM';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
export const SET_PAGINATION = 'SET_PAGINATION';
+export const SET_NO_ACCESS_ERROR = 'SET_NO_ACCESS_ERROR';
export const REQUEST_VALUE_STREAMS = 'REQUEST_VALUE_STREAMS';
export const RECEIVE_VALUE_STREAMS_SUCCESS = 'RECEIVE_VALUE_STREAMS_SUCCESS';
@@ -14,10 +15,6 @@ export const REQUEST_VALUE_STREAM_STAGES = 'REQUEST_VALUE_STREAM_STAGES';
export const RECEIVE_VALUE_STREAM_STAGES_SUCCESS = 'RECEIVE_VALUE_STREAM_STAGES_SUCCESS';
export const RECEIVE_VALUE_STREAM_STAGES_ERROR = 'RECEIVE_VALUE_STREAM_STAGES_ERROR';
-export const REQUEST_CYCLE_ANALYTICS_DATA = 'REQUEST_CYCLE_ANALYTICS_DATA';
-export const RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS = 'RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS';
-export const RECEIVE_CYCLE_ANALYTICS_DATA_ERROR = 'RECEIVE_CYCLE_ANALYTICS_DATA_ERROR';
-
export const REQUEST_STAGE_DATA = 'REQUEST_STAGE_DATA';
export const RECEIVE_STAGE_DATA_SUCCESS = 'RECEIVE_STAGE_DATA_SUCCESS';
export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 64930a5b51f..8567529caf2 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -41,6 +41,9 @@ export default {
direction: direction || PAGINATION_SORT_DIRECTION_DESC,
});
},
+ [types.SET_NO_ACCESS_ERROR](state) {
+ state.hasNoAccessError = true;
+ },
[types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = [];
},
@@ -59,23 +62,12 @@ export default {
[types.RECEIVE_VALUE_STREAM_STAGES_ERROR](state) {
state.stages = [];
},
- [types.REQUEST_CYCLE_ANALYTICS_DATA](state) {
- state.isLoading = true;
- state.hasError = false;
- },
- [types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
- state.permissions = data?.permissions || {};
- state.hasError = false;
- },
- [types.RECEIVE_CYCLE_ANALYTICS_DATA_ERROR](state) {
- state.isLoading = false;
- state.hasError = true;
- },
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
state.isEmptyStage = false;
state.selectedStageEvents = [];
- state.hasError = false;
+
+ state.hasNoAccessError = false;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, events = []) {
state.isLoadingStage = false;
@@ -83,13 +75,14 @@ export default {
state.selectedStageEvents = events.map((ev) =>
convertObjectPropsToCamelCase(ev, { deep: true }),
);
- state.hasError = false;
+
+ state.hasNoAccessError = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state, error) {
state.isLoadingStage = false;
state.isEmptyStage = true;
state.selectedStageEvents = [];
- state.hasError = true;
+
state.selectedStageError = error;
},
[types.REQUEST_STAGE_MEDIANS](state) {
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 52bc01cafa4..8d662333afa 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -10,9 +10,7 @@ export default () => ({
createdAfter: null,
createdBefore: null,
stages: [],
- summary: [],
analytics: [],
- stats: [],
valueStreams: [],
selectedValueStream: {},
selectedStage: {},
@@ -20,11 +18,10 @@ export default () => ({
selectedStageError: '',
medians: {},
stageCounts: {},
- hasError: false,
+ hasNoAccessError: false,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
- permissions: {},
pagination: {
page: null,
hasNextPage: false,
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 618096c5bea..ac00af2ab34 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -290,7 +290,6 @@ export default {
<template v-else>
<reply-placeholder
v-if="!isFormVisible"
- class="qa-discussion-reply"
:placeholder-text="__('Reply…')"
@focus="showForm"
/>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 818299e36bd..1b6458668f5 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -90,7 +90,6 @@ export default {
<form class="new-note common-note-form" @submit.prevent>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
- :can-attach-file="false"
:enable-autocomplete="true"
:textarea-value="value"
:markdown-docs-path="markdownDocsPath"
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
index 3fe20705ce2..9d9e3a4ede9 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/design_todo_item.fragment.graphql
@@ -1,11 +1,9 @@
fragment DesignTodoItem on Design {
id
image
- __typename
currentUserTodos(state: pending) {
nodes {
id
- __typename
}
}
}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
index b715633a9f2..09a0b39e1cd 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
@@ -3,7 +3,6 @@ fragment VersionListItem on DesignVersion {
sha
createdAt
author {
- __typename
id
name
avatarUrl
diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
index 3200327e03d..6fe2cae7346 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
@@ -6,9 +6,7 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designs {
...DesignItem
versions {
- __typename
nodes {
- __typename
...VersionListItem
}
}
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 51983b19677..91e35ad3764 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -372,7 +372,7 @@ export default {
</div>
<div
v-show="hasDesigns"
- class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
+ class="gl-display-flex gl-align-items-center gl-my-2"
data-testid="design-selector-toolbar"
>
<gl-button
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 8388458b11c..833fbb8789e 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -175,6 +175,7 @@ export default class Diff {
}
}
+ // eslint-disable-next-line class-methods-use-this
formatElementToObject = (element) => {
const key = element.attributes['data-file-hash'].value;
const name = element.attributes['data-diff-toggle-entity'].value;
@@ -192,6 +193,7 @@ export default class Diff {
return $elements.toArray().map(diff.formatElementToObject).reduce(merge);
};
+ // eslint-disable-next-line class-methods-use-this
showRawViewer = (fileHash, elements) => {
if (elements === undefined) return;
@@ -202,6 +204,7 @@ export default class Diff {
elements.rawViewer.classList.remove('hidden');
};
+ // eslint-disable-next-line class-methods-use-this
showRenderedViewer = (fileHash, elements) => {
if (elements === undefined) return;
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index ad163a2a615..0e5acd0928b 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -104,12 +104,9 @@ export default {
class="d-inline-flex mb-2"
/>
<gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group">
- <gl-button
- label
- class="gl-font-monospace"
- data-testid="commit-sha-short-id"
- v-text="commit.short_id"
- />
+ <gl-button label class="gl-font-monospace" data-testid="commit-sha-short-id">{{
+ commit.short_id
+ }}</gl-button>
<modal-copy-button
:text="commit.id"
:title="__('Copy commit SHA')"
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index fc5766a23ef..3082ba0f16f 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -217,52 +217,47 @@ export default {
</script>
<template>
- <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"
+ <div>
+ <div
+ class="diff-td diff-line-num gl-text-center! gl-p-0! gl-w-full! gl-display-flex gl-flex-direction-column"
+ >
+ <button
+ v-if="showExpandDown"
+ :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)"
>
- <button
- 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)"
- >
- <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline />
- <gl-icon v-else name="expand-down" />
- </button>
- <button
- 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()"
- >
- <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline />
- <gl-icon v-else name="expand" />
- </button>
- <button
- 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)"
- >
- <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline />
- <gl-icon v-else name="expand-up" />
- </button>
- </div>
- <div
- v-safe-html="line.rich_text"
- class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!"
- ></div>
+ <gl-loading-icon v-if="loading.down" size="sm" color="dark" inline />
+ <gl-icon v-else name="expand-down" />
+ </button>
+ <button
+ v-if="lineCountBetween !== -1 && lineCountBetween < 20"
+ :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()"
+ >
+ <gl-loading-icon v-if="loading.all" size="sm" color="dark" inline />
+ <gl-icon v-else name="expand" />
+ </button>
+ <button
+ v-if="showExpandUp"
+ :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)"
+ >
+ <gl-loading-icon v-if="loading.up" size="sm" color="dark" inline />
+ <gl-icon v-else name="expand-up" />
+ </button>
</div>
+ <div
+ v-safe-html="line.rich_text"
+ class="gl-display-flex! gl-flex-direction-column gl-justify-content-center diff-td line_content left-side gl-white-space-normal!"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index ad406947561..ea94df1ad5b 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -197,17 +197,33 @@ export default {
@mousedown="handleParallelLineMouseDown"
>
<template v-for="(line, index) in diffLines">
- <template v-if="line.isMatchLineLeft || line.isMatchLineRight">
+ <div
+ v-if="line.isMatchLineLeft || line.isMatchLineRight"
+ :key="`expand-${index}`"
+ class="diff-grid-row diff-tr line_holder match expansion"
+ >
<diff-expansion-cell
- :key="`expand-${index}`"
:file="diffFile"
:line="line.left"
:is-top="index === 0"
:is-bottom="index + 1 === diffLinesLength"
:inline="inline"
:line-count-between="getCountBetweenIndex(index)"
+ :class="{ parallel: !inline }"
+ class="diff-grid-left diff-grid-2-col left-side"
/>
- </template>
+ <diff-expansion-cell
+ v-if="!inline"
+ :file="diffFile"
+ :line="line.left"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ :inline="inline"
+ :line-count-between="getCountBetweenIndex(index)"
+ :class="{ parallel: !inline }"
+ class="diff-grid-right diff-grid-2-col right-side"
+ />
+ </div>
<diff-row
v-if="!line.isMatchLineLeft && !line.isMatchLineRight"
:key="line.line_code"
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 6c0c9c4e1d0..1cc96ef3d54 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -71,12 +71,15 @@ export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
export const STATE_ERRORED = 'errored';
+export const STATE_PENDING_REVIEW = 'pending_comments';
// State machine transitions
export const TRANSITION_LOAD_START = 'LOAD_START';
export const TRANSITION_LOAD_ERROR = 'LOAD_ERROR';
export const TRANSITION_LOAD_SUCCEED = 'LOAD_SUCCEED';
export const TRANSITION_ACKNOWLEDGE_ERROR = 'ACKNOWLEDGE_ERROR';
+export const TRANSITION_HAS_PENDING_REVIEW = 'PENDING_REVIEW';
+export const TRANSITION_NO_REVIEW = 'NO_REVIEW';
export const RENAMED_DIFF_TRANSITIONS = {
[`${STATE_IDLING}:${TRANSITION_LOAD_START}`]: STATE_LOADING,
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index ace507f601a..5e74a7206b3 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -119,10 +119,10 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
const getBatch = (page = startPage) =>
axios
.get(mergeUrlParams({ ...urlParams, page, per_page: perPage }, state.endpointBatch))
- .then(({ data: { pagination, diff_files } }) => {
- totalLoaded += diff_files.length;
+ .then(({ data: { pagination, diff_files: diffFiles } }) => {
+ totalLoaded += diffFiles.length;
- commit(types.SET_DIFF_DATA_BATCH, { diff_files });
+ commit(types.SET_DIFF_DATA_BATCH, { diff_files: diffFiles });
commit(types.SET_BATCH_LOADING_STATE, 'loaded');
if (!scrolledVirtualScroller) {
@@ -138,7 +138,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
}
if (!isNoteLink && !state.currentDiffFileId) {
- commit(types.SET_CURRENT_DIFF_FILE, diff_files[0]?.file_hash);
+ commit(types.SET_CURRENT_DIFF_FILE, diffFiles[0]?.file_hash);
}
if (isNoteLink) {
@@ -293,8 +293,8 @@ export const assignDiscussionsToDiff = (
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
- const { file_hash, line_code, id } = removeDiscussion;
- commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
+ const { file_hash: fileHash, line_code: lineCode, id } = removeDiscussion;
+ commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode, id });
};
export const toggleLineDiscussions = ({ commit }, options) => {
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 491c2ced358..e6f7a31e07b 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -28,7 +28,6 @@ function getErrorMessage(res) {
export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const divHover = '<div class="div-dropzone-hover"></div>';
const iconPaperclip = spriteIcon('paperclip', 'div-dropzone-icon s24');
- const $attachButton = form.find('.button-attach-file');
const $attachingFileMessage = form.find('.attaching-file-message');
const $cancelButton = form.find('.button-cancel-uploading-files');
const $retryLink = form.find('.retry-uploading-link');
@@ -89,8 +88,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
const shouldPad = processingFileCount >= 1;
pasteText(response.link.markdown, shouldPad);
- // Show 'Attach a file' link only when all files have been uploaded.
- if (!processingFileCount) $attachButton.removeClass('hide');
addFileToForm(response.link.url);
},
error: (file, errorMessage = __('Attaching the file failed.'), xhr) => {
@@ -104,7 +101,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
$uploadingErrorContainer.removeClass('hide');
$uploadingErrorMessage.html(message);
- $attachButton.addClass('hide');
$cancelButton.addClass('hide');
},
totaluploadprogress(totalUploadProgress) {
@@ -115,13 +111,11 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
// DOM elements already exist.
// Instead of dynamically generating them,
// we just either hide or show them.
- $attachButton.addClass('hide');
$uploadingErrorContainer.addClass('hide');
$uploadingProgressContainer.removeClass('hide');
$cancelButton.removeClass('hide');
},
removedfile: () => {
- $attachButton.removeClass('hide');
$cancelButton.addClass('hide');
$uploadingProgressContainer.addClass('hide');
$uploadingErrorContainer.addClass('hide');
@@ -282,11 +276,18 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
messageContainer.text(`${attachingMessage} -`);
};
- form.find('.markdown-selector').click(function onMarkdownClick(e) {
+ function handleAttachFile(e) {
e.preventDefault();
$(this).closest('.gfm-form').find('.div-dropzone').click();
formTextarea.focus();
- });
+ }
+
+ form.find('.markdown-selector').click(handleAttachFile);
+
+ const $attachFileButton = form.find('.js-attach-file-button');
+ if ($attachFileButton.length) {
+ $attachFileButton.get(0).addEventListener('click', handleAttachFile);
+ }
return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null;
}
diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
index 194b482c12e..6ce48ddf89a 100644
--- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
+++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue
@@ -52,6 +52,7 @@ export default {
:icon="icon"
:title="label"
:aria-label="label"
+ data-qa-selector="editor_toolbar_button"
@click="clickHandler"
/>
</template>
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 e4ad0bf8e76..bc3cb163c39 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
@@ -1,3 +1,4 @@
+import { KeyMod, KeyCode } from 'monaco-editor';
import { debounce } from 'lodash';
import { BLOB_PREVIEW_ERROR } from '~/blob_edit/constants';
import createFlash from '~/flash';
@@ -158,8 +159,8 @@ export class EditorMarkdownPreviewExtension {
if (instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)) return;
const actionBasis = {
keybindings: [
- // eslint-disable-next-line no-bitwise,no-undef
- monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P),
+ // eslint-disable-next-line no-bitwise
+ KeyMod.chord(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KEY_P),
],
contextMenuGroupId: 'navigation',
contextMenuOrder: 1.5,
diff --git a/app/assets/javascripts/editor/graphql/typedefs.graphql b/app/assets/javascripts/editor/graphql/typedefs.graphql
index 2433ebf6c66..49beae033f1 100644
--- a/app/assets/javascripts/editor/graphql/typedefs.graphql
+++ b/app/assets/javascripts/editor/graphql/typedefs.graphql
@@ -12,12 +12,22 @@ type Items {
nodes: [Item]!
}
+input ItemInput {
+ id: ID!
+ label: String!
+ icon: String
+ selected: Boolean
+ group: Int!
+ category: String
+ selectedLabel: String
+}
+
extend type Query {
items: Items
}
extend type Mutation {
- updateToolbarItem(id: ID!, propsToUpdate: Item!): LocalErrors
+ updateToolbarItem(id: ID!, propsToUpdate: ItemInput!): LocalErrors
removeToolbarItems(ids: [ID!]): LocalErrors
- addToolbarItems(items: [Item]): LocalErrors
+ addToolbarItems(items: [ItemInput]): LocalErrors
}
diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json
index e8b96c25965..848ba7dbeef 100644
--- a/app/assets/javascripts/editor/schema/ci.json
+++ b/app/assets/javascripts/editor/schema/ci.json
@@ -245,6 +245,10 @@
"terraform": {
"$ref": "#/definitions/string_file_list",
"description": "Path to file or list of files with terraform plan(s)."
+ },
+ "cyclonedx": {
+ "$ref": "#/definitions/string_file_list",
+ "markdownDescription": "Path to file or list of files with cyclonedx report(s). [Learn More](https://docs.gitlab.com/ee/ci/yaml/artifacts_reports.html#artifactsreportscyclonedx)."
}
}
}
@@ -292,7 +296,7 @@
"project": {
"description": "Path to the project, e.g. `group/project`, or `group/sub-group/project`.",
"type": "string",
- "pattern": "\\S/\\S"
+ "pattern": "\\S/\\S|\\$(\\S+)"
},
"ref": {
"description": "Branch/Tag/Commit-hash for the target project.",
@@ -606,11 +610,33 @@
"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",
"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"
- }
+ "anyOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["paths"],
+ "properties": {
+ "paths": {
+ "type": "array",
+ "description": "List of file paths.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "compare_to": {
+ "type": "string",
+ "description": "Ref for comparing changes."
+ }
+ }
+ },
+ {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ ]
},
"exists": {
"type": "array",
@@ -623,11 +649,11 @@
"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",
+ "type": "object",
"additionalProperties": {
"type": ["string", "integer", "array"]
}
- },
+ },
{
"type": "array",
"items": {
@@ -1204,6 +1230,10 @@
"description": "The tag_name must be specified. It can refer to an existing Git tag or can be specified by the user.",
"minLength": 1
},
+ "tag_message": {
+ "type": "string",
+ "description": "Message to use if creating a new annotated tag."
+ },
"description": {
"type": "string",
"description": "Specifies the longer description of the Release.",
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 7ffe8140a21..1e9924246b9 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -627,7 +627,7 @@ export default {
:title="model.name"
class="environment-name table-mobile-content"
>
- <a class="qa-environment-link" :href="environmentPath">
+ <a data-qa-selector="environment_link" :href="environmentPath">
<span v-if="model.size === 1">{{ model.name }}</span>
<span v-else>{{ model.name_without_type }}</span>
</a>
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index 13b9cf14f52..bd67908a6b4 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -135,6 +135,7 @@ export default {
>
<gl-button
v-if="shouldShowExternalUrlButton"
+ v-gl-tooltip.hover
data-testid="metrics-button"
:href="metricsPath"
:title="$options.i18n.metricsButtonTitle"
diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
index 2c17c42dd6d..c3ab9cf7fca 100644
--- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
+++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql
@@ -4,6 +4,5 @@ query getEnvironmentApp($page: Int, $scope: String) {
stoppedCount
environments
reviewApp
- stoppedCount
}
}
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 28a3c54cc8f..d9c627f5c93 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -1,8 +1,8 @@
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
const reviewerToken = {
- formattedKey: __('Reviewer'),
+ formattedKey: s__('SearchToken|Reviewer'),
key: 'reviewer',
type: 'string',
param: 'username',
@@ -13,21 +13,6 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken);
IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken);
- if (window.gon?.features?.mrAttentionRequests) {
- const attentionRequestedToken = {
- formattedKey: __('Attention'),
- key: 'attention',
- type: 'string',
- param: '',
- symbol: '@',
- icon: 'user',
- tag: '@attention',
- hideNotEqual: true,
- };
- IssuableTokenKeys.tokenKeys.splice(2, 0, attentionRequestedToken);
- IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, attentionRequestedToken);
- }
-
const draftToken = {
token: {
formattedKey: __('Draft'),
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index 2c58506985a..acb7449f830 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,5 +1,5 @@
import { flattenDeep } from 'lodash';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
export const tokenKeys = [
@@ -13,7 +13,7 @@ export const tokenKeys = [
tag: '@author',
},
{
- formattedKey: __('Assignee'),
+ formattedKey: s__('SearchToken|Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
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 6b1676eca8a..9fb69a3cae3 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
@@ -75,6 +75,7 @@ export default {
<project-avatar
class="gl-float-left gl-mr-3"
:project-avatar-url="avatarUrl"
+ :project-id="itemId"
:project-name="itemName"
aria-hidden="true"
/>
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index d4dafbdc94f..01d218438cf 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -7,11 +7,20 @@ import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
import { s__, __, sprintf } from '~/locale';
import { isUserBusy } from '~/set_status_modal/utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
+import { state } from '~/sidebar/components/reviewers/sidebar_reviewers.vue';
import AjaxCache from './lib/utils/ajax_cache';
import { spriteIcon } from './lib/utils/common_utils';
import { parsePikadayDate } from './lib/utils/datetime_utility';
import glRegexp from './lib/utils/regexp';
+const USERS_ALIAS = 'users';
+const ISSUES_ALIAS = 'issues';
+const MILESTONES_ALIAS = 'milestones';
+const MERGEREQUESTS_ALIAS = 'mergerequests';
+const LABELS_ALIAS = 'labels';
+const SNIPPETS_ALIAS = 'snippets';
+const CONTACTS_ALIAS = 'contacts';
+export const AT_WHO_ACTIVE_CLASS = 'at-who-active';
/**
* Escapes user input before we pass it to at.js, which
* renders it as HTML in the autocomplete dropdown.
@@ -29,6 +38,15 @@ function escape(string) {
return lodashEscape(string).replace(/\$/g, '&dollar;');
}
+export function showAndHideHelper($input, alias = '') {
+ $input.on(`hidden${alias ? '-' : ''}${alias}.atwho`, () => {
+ $input.removeClass(AT_WHO_ACTIVE_CLASS);
+ });
+ $input.on(`shown${alias ? '-' : ''}${alias}.atwho`, () => {
+ $input.addClass(AT_WHO_ACTIVE_CLASS);
+ });
+}
+
function createMemberSearchString(member) {
return `${member.name.replace(/ /g, '')} ${member.username}`;
}
@@ -237,10 +255,18 @@ class GfmAutoComplete {
callbacks: {
...this.getDefaultCallbacks(),
matcher(flag, subtext) {
- const regexp = new RegExp(`(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^:]*)$`, 'gi');
+ const regexp = new RegExp(
+ `(?:[^${glRegexp.unicodeLetters}0-9:]|\n|^):([^ :][^:]*)?$`,
+ 'gi',
+ );
const match = regexp.exec(subtext);
- return match && match.length ? match[1] : null;
+ if (match && match.length) {
+ // Since we have "?" on the group, it's possible it is undefined
+ return match[1] || '';
+ }
+
+ return null;
},
filter(query, items) {
if (GfmAutoComplete.isLoading(items)) {
@@ -265,6 +291,7 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input);
}
setupMembers($input) {
@@ -276,8 +303,6 @@ class GfmAutoComplete {
UNASSIGN_REVIEWER: '/unassign_reviewer',
REASSIGN: '/reassign',
CC: '/cc',
- ATTENTION: '/attention',
- REMOVE_ATTENTION: '/remove_attention',
};
let assignees = [];
let reviewers = [];
@@ -286,7 +311,7 @@ class GfmAutoComplete {
// Team Members
$input.atwho({
at: '@',
- alias: 'users',
+ alias: USERS_ALIAS,
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
const { avatarTag, username, title, icon, availability } = value;
@@ -328,8 +353,7 @@ class GfmAutoComplete {
// Cache assignees & reviewers list for easier filtering later
assignees =
SidebarMediator.singleton?.store?.assignees?.map(createMemberSearchString) || [];
- reviewers =
- SidebarMediator.singleton?.store?.reviewers?.map(createMemberSearchString) || [];
+ reviewers = state.issuable?.reviewers?.nodes?.map(createMemberSearchString) || [];
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
return match && match.length ? match[1] : null;
@@ -356,23 +380,6 @@ class GfmAutoComplete {
} else if (command === MEMBER_COMMAND.UNASSIGN_REVIEWER) {
// Only include members which are not assigned as a reviewer to Issuable currently
return data.filter((member) => reviewers.includes(member.search));
- } else if (
- command === MEMBER_COMMAND.ATTENTION ||
- command === MEMBER_COMMAND.REMOVE_ATTENTION
- ) {
- const attentionUsers = [
- ...(SidebarMediator.singleton?.store?.assignees || []),
- ...(SidebarMediator.singleton?.store?.reviewers || []),
- ];
- const attentionRequested = command === MEMBER_COMMAND.REMOVE_ATTENTION;
-
- return data.filter((member) =>
- attentionUsers.find(
- (u) =>
- createMemberSearchString(u).includes(member.search) &&
- u.attention_requested === attentionRequested,
- ),
- );
}
return data;
@@ -393,12 +400,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, USERS_ALIAS);
}
setupIssues($input) {
$input.atwho({
at: '#',
- alias: 'issues',
+ alias: ISSUES_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -427,12 +435,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, ISSUES_ALIAS);
}
setupMilestones($input) {
$input.atwho({
at: '%',
- alias: 'milestones',
+ alias: MILESTONES_ALIAS,
searchKey: 'search',
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${title}',
@@ -483,12 +492,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, MILESTONES_ALIAS);
}
setupMergeRequests($input) {
$input.atwho({
at: '!',
- alias: 'mergerequests',
+ alias: MERGEREQUESTS_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -517,6 +527,7 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, MERGEREQUESTS_ALIAS);
}
setupLabels($input) {
@@ -527,7 +538,7 @@ class GfmAutoComplete {
$input.atwho({
at: '~',
- alias: 'labels',
+ alias: LABELS_ALIAS,
searchKey: 'search',
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
@@ -617,12 +628,13 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, LABELS_ALIAS);
}
setupSnippets($input) {
$input.atwho({
at: '$',
- alias: 'snippets',
+ alias: SNIPPETS_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -650,13 +662,14 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, SNIPPETS_ALIAS);
}
setupContacts($input) {
$input.atwho({
at: '[contact:',
suffix: ']',
- alias: 'contacts',
+ alias: CONTACTS_ALIAS,
searchKey: 'search',
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
@@ -686,6 +699,7 @@ class GfmAutoComplete {
},
},
});
+ showAndHideHelper($input, CONTACTS_ALIAS);
}
getDefaultCallbacks() {
diff --git a/app/assets/javascripts/gitlab_pages/new.js b/app/assets/javascripts/gitlab_pages/new.js
new file mode 100644
index 00000000000..e23b08dcd56
--- /dev/null
+++ b/app/assets/javascripts/gitlab_pages/new.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlToast } from '@gitlab/ui';
+import createDefaultClient from '~/lib/graphql';
+import Pages from './components/pages_pipeline_wizard.vue';
+
+Vue.use(VueApollo);
+Vue.use(GlToast);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ batchMax: 1,
+ assumeImmutableResults: true,
+ },
+ ),
+});
+
+export default function initPages() {
+ const el = document.querySelector('#js-pages');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ name: 'GitlabPagesNewRoot',
+ apolloProvider,
+ render(createElement) {
+ return createElement(Pages, {
+ props: {
+ ...el.dataset,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql
deleted file mode 100644
index b202ed12f80..00000000000
--- a/app/assets/javascripts/graphql_shared/fragments/blobviewer.fragment.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-fragment BlobViewer on SnippetBlobViewer {
- collapsed
- renderError
- tooLarge
- type
- fileType
-}
diff --git a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql
deleted file mode 100644
index 78a368089a8..00000000000
--- a/app/assets/javascripts/graphql_shared/fragments/iteration.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment Iteration on Iteration {
- id
- title
-}
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index 45c5cca68cc..eac325f184f 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -3,6 +3,12 @@
"AlertManagementHttpIntegration",
"AlertManagementPrometheusIntegration"
],
+ "CiVariable": [
+ "CiGroupVariable",
+ "CiInstanceVariable",
+ "CiManualVariable",
+ "CiProjectVariable"
+ ],
"CurrentUserTodos": [
"BoardEpic",
"Design",
@@ -134,6 +140,9 @@
"WorkItemWidgetAssignees",
"WorkItemWidgetDescription",
"WorkItemWidgetHierarchy",
+ "WorkItemWidgetLabels",
+ "WorkItemWidgetStartAndDueDate",
+ "WorkItemWidgetVerificationStatus",
"WorkItemWidgetWeight"
]
}
diff --git a/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql
index 12b391e41ac..50ed38e0492 100644
--- a/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/get_user_callouts.query.graphql
@@ -1,11 +1,8 @@
query getUser {
currentUser {
id
- __typename
callouts {
- __typename
nodes {
- __typename
featureName
}
}
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 06aea26830d..8011090f1cb 100644
--- a/app/assets/javascripts/group_settings/components/shared_runners_form.vue
+++ b/app/assets/javascripts/group_settings/components/shared_runners_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlToggle, GlAlert } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
+import { updateGroup } from '~/api/groups_api';
import { I18N_UPDATE_ERROR_MESSAGE, I18N_REFRESH_MESSAGE } from '../constants';
export default {
@@ -9,7 +9,7 @@ export default {
GlAlert,
},
inject: [
- 'updatePath',
+ 'groupId',
'sharedRunnersSetting',
'parentSharedRunnersSetting',
'runnerEnabledValue',
@@ -54,8 +54,7 @@ export default {
this.isLoading = true;
- axios
- .put(this.updatePath, { shared_runners_setting: setting })
+ updateGroup(this.groupId, { shared_runners_setting: setting })
.then(() => {
this.value = setting;
})
diff --git a/app/assets/javascripts/group_settings/mount_shared_runners.js b/app/assets/javascripts/group_settings/mount_shared_runners.js
index aeb6d57a11a..e7e104d61b3 100644
--- a/app/assets/javascripts/group_settings/mount_shared_runners.js
+++ b/app/assets/javascripts/group_settings/mount_shared_runners.js
@@ -5,7 +5,7 @@ export default (containerId = 'update-shared-runners-form') => {
const containerEl = document.getElementById(containerId);
const {
- updatePath,
+ groupId,
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
@@ -16,7 +16,7 @@ export default (containerId = 'update-shared-runners-form') => {
return new Vue({
el: containerEl,
provide: {
- updatePath,
+ groupId,
sharedRunnersSetting,
parentSharedRunnersSetting,
runnerEnabledValue,
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 7345afb8545..2f182b86d2c 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,12 +16,9 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import {
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- ITEM_TYPE,
- VISIBILITY_PRIVATE,
-} from '../constants';
+import { VISIBILITY_LEVELS_ENUM } from '~/visibility_level/constants';
+import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
+
import eventHub from '../event_hub';
import itemActions from './item_actions.vue';
@@ -114,8 +111,8 @@ export default {
shouldShowVisibilityWarning() {
return (
this.action === 'shared' &&
- this.currentGroupVisibility === VISIBILITY_PRIVATE &&
- this.group.visibility !== VISIBILITY_PRIVATE
+ VISIBILITY_LEVELS_ENUM[this.group.visibility] >
+ VISIBILITY_LEVELS_ENUM[this.currentGroupVisibility]
);
},
},
@@ -142,7 +139,7 @@ export default {
shareProjectsWithGroupsHelpPagePath: helpPagePath(
'user/project/members/share_project_with_groups',
{
- anchor: 'share-a-public-project-with-private-group',
+ anchor: 'sharing-projects-with-groups-of-a-higher-restrictive-visibility-level',
},
),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
@@ -182,6 +179,7 @@ export default {
>
<gl-avatar
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :entity-id="group.id"
:entity-name="group.name"
:src="group.avatarUrl"
:alt="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
index 983535d3e9c..9a1ea2f1812 100644
--- a/app/assets/javascripts/groups/components/group_name_and_path.vue
+++ b/app/assets/javascripts/groups/components/group_name_and_path.vue
@@ -6,6 +6,13 @@ import {
GlInputGroupText,
GlLink,
GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlTruncate,
+ GlSearchBoxByType,
} from '@gitlab/ui';
import { debounce } from 'lodash';
@@ -15,6 +22,11 @@ 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';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+
+import searchGroupsWhereUserCanCreateSubgroups from '../queries/search_groups_where_user_can_create_subgroups.query.graphql';
const DEBOUNCE_DURATION = 1000;
@@ -22,7 +34,6 @@ 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.',
@@ -30,7 +41,6 @@ export default {
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.',
@@ -40,9 +50,6 @@ export default {
),
validFeedback: s__('Groups|Group path is available.'),
},
- groupId: {
- label: s__('Groups|Group ID'),
- },
},
apiLoadingMessage: s__('Groups|Checking group URL availability...'),
apiErrorMessage: __(
@@ -51,7 +58,7 @@ export default {
changingUrlWarningMessage: s__('Groups|Changing group URL can have unintended side effects.'),
learnMore: s__('Groups|Learn more'),
},
- nameInputSize: { md: 'lg' },
+ inputSize: { md: 'lg' },
changingGroupPathHelpPagePath: helpPagePath('user/group/index', {
anchor: 'change-a-groups-path',
}),
@@ -63,8 +70,35 @@ export default {
GlInputGroupText,
GlLink,
GlAlert,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlTruncate,
+ GlSearchBoxByType,
+ },
+ apollo: {
+ currentUserGroups: {
+ query: searchGroupsWhereUserCanCreateSubgroups,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return data.currentUser?.groups?.nodes || [];
+ },
+ skip() {
+ const hasNotEnoughSearchCharacters =
+ this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+
+ return this.shouldSkipQuery || hasNotEnoughSearchCharacters;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
},
- inject: ['fields', 'basePath', 'mattermostEnabled'],
+ inject: ['fields', 'basePath', 'newSubgroup', 'mattermostEnabled'],
data() {
return {
name: this.fields.name.value,
@@ -76,9 +110,27 @@ export default {
pathFeedbackState: null,
pathInvalidFeedback: null,
activeApiRequestAbortController: null,
+ search: '',
+ currentUserGroups: {},
+ shouldSkipQuery: true,
+ selectedGroup: {
+ id: this.fields.parentId.value,
+ fullPath: this.fields.parentFullPath.value,
+ },
};
},
computed: {
+ inputLabels() {
+ return {
+ name: this.newSubgroup ? s__('Groups|Subgroup name') : s__('Groups|Group name'),
+ path: this.newSubgroup ? s__('Groups|Subgroup slug') : s__('Groups|Group URL'),
+ subgroupPath: s__('Groups|Subgroup URL'),
+ groupId: s__('Groups|Group ID'),
+ };
+ },
+ pathInputSize() {
+ return this.newSubgroup ? {} : this.$options.inputSize;
+ },
computedPath() {
return this.apiSuggestedPath || this.path;
},
@@ -129,9 +181,11 @@ export default {
try {
const {
data: { exists, suggests },
- } = await getGroupPathAvailability(this.path, this.fields.parentId?.value, {
- signal: this.activeApiRequestAbortController.signal,
- });
+ } = await getGroupPathAvailability(
+ this.path,
+ this.selectedGroup.id || this.fields.parentId.value,
+ { signal: this.activeApiRequestAbortController.signal },
+ );
this.apiLoading = false;
@@ -198,6 +252,21 @@ export default {
this.pathInvalidFeedback = this.$options.i18n.inputs.path.invalidFeedbackInvalidPattern;
this.pathFeedbackState = false;
},
+ handleDropdownShown() {
+ if (this.shouldSkipQuery) {
+ this.shouldSkipQuery = false;
+ }
+
+ this.$refs.search.focusInput();
+ },
+ handleDropdownItemClick({ id, fullPath }) {
+ this.selectedGroup = {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ };
+
+ this.debouncedValidatePath();
+ },
},
};
</script>
@@ -208,10 +277,10 @@ export default {
:id="fields.parentId.id"
type="hidden"
:name="fields.parentId.name"
- :value="fields.parentId.value"
+ :value="selectedGroup.id"
/>
<gl-form-group
- :label="$options.i18n.inputs.name.label"
+ :label="inputLabels.name"
:description="$options.i18n.inputs.name.description"
:label-for="fields.name.id"
:invalid-feedback="$options.i18n.inputs.name.invalidFeedback"
@@ -220,46 +289,102 @@ export default {
<gl-form-input
:id="fields.name.id"
v-model="name"
- class="gl-field-error-ignore"
+ class="gl-field-error-ignore gl-h-auto!"
required
:name="fields.name.name"
:placeholder="$options.i18n.inputs.name.placeholder"
data-qa-selector="group_name_field"
- :size="$options.nameInputSize"
+ :size="$options.inputSize"
: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>
+
+ <div :class="newSubgroup && 'row gl-mb-3'">
+ <gl-form-group v-if="newSubgroup" class="col-sm-6 gl-pr-0" :label="inputLabels.subgroupPath">
+ <div class="input-group gl-flex-nowrap">
+ <gl-button-group class="gl-w-full">
+ <gl-button class="js-group-namespace-button gl-text-truncate gl-flex-grow-0!" label>
+ {{ basePath }}
+ </gl-button>
+
+ <gl-dropdown
+ class="js-group-namespace-dropdown gl-flex-grow-1"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
+ @shown="handleDropdownShown"
+ >
+ <template #button-text>
+ <gl-truncate
+ v-if="selectedGroup.fullPath"
+ :text="selectedGroup.fullPath"
+ position="start"
+ with-tooltip
+ />
+ </template>
+
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ :is-loading="$apollo.queries.currentUserGroups.loading"
+ />
+
+ <template v-if="!$apollo.queries.currentUserGroups.loading">
+ <template v-if="currentUserGroups.length">
+ <gl-dropdown-item
+ v-for="group of currentUserGroups"
+ :key="group.id"
+ data-testid="select_group_dropdown_item"
+ @click="handleDropdownItemClick(group)"
+ >
+ {{ group.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
+
+ <div class="gl-align-self-center gl-pl-5">
+ <span class="gl-display-none gl-md-display-inline">/</span>
+ </div>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ :class="newSubgroup && 'col-sm-6'"
+ :label="inputLabels.path"
+ :label-for="fields.path.id"
+ :description="pathDescription"
+ :state="pathFeedbackState"
+ :valid-feedback="$options.i18n.inputs.path.validFeedback"
+ :invalid-feedback="pathInvalidFeedback"
+ >
+ <gl-form-input-group>
+ <template v-if="!newSubgroup" #prepend>
+ <gl-input-group-text class="group-root-path">
+ {{ basePath.concat(fields.parentFullPath.value) }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :id="fields.path.id"
+ class="gl-field-error-ignore gl-h-auto!"
+ :name="fields.path.name"
+ :value="computedPath"
+ :placeholder="$options.i18n.inputs.path.placeholder"
+ :maxlength="fields.path.maxLength"
+ :pattern="fields.path.pattern"
+ :state="pathFeedbackState"
+ :size="pathInputSize"
+ 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>
+ </div>
+
<template v-if="isEditingGroup">
<gl-alert class="gl-mb-5" :dismissible="false" variant="warning">
{{ $options.i18n.changingUrlWarningMessage }}
@@ -267,7 +392,7 @@ export default {
>{{ $options.i18n.learnMore }}
</gl-link>
</gl-alert>
- <gl-form-group :label="$options.i18n.inputs.groupId.label" :label-for="fields.groupId.id">
+ <gl-form-group :label="inputLabels.groupId" :label-for="fields.groupId.id">
<gl-form-input
:id="fields.groupId.id"
:value="fields.groupId.value"
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 5706df0dd1b..3a05c308a2a 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -42,7 +42,7 @@ export default {
</script>
<template>
- <div class="groups-list-tree-container qa-groups-list-tree-container">
+ <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container">
<div
v-if="searchEmpty"
class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5"
diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue
index e848f10352d..7e7282a27b0 100644
--- a/app/assets/javascripts/groups/components/transfer_group_form.vue
+++ b/app/assets/javascripts/groups/components/transfer_group_form.vue
@@ -70,7 +70,6 @@ export default {
<input type="hidden" name="new_parent_group_id" :value="selectedId" />
</gl-form-group>
<confirm-danger
- button-class="qa-transfer-button"
:disabled="disableSubmitButton"
:phrase="confirmationPhrase"
:button-text="confirmButtonText"
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 29981d09155..0d09ad9442b 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,4 +1,9 @@
import { __, s__ } from '~/locale';
+import {
+ VISIBILITY_LEVEL_PRIVATE,
+ VISIBILITY_LEVEL_INTERNAL,
+ VISIBILITY_LEVEL_PUBLIC,
+} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -28,32 +33,30 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const VISIBILITY_PUBLIC = 'public';
-export const VISIBILITY_INTERNAL = 'internal';
-export const VISIBILITY_PRIVATE = 'private';
-
export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_PUBLIC]: __(
+ [VISIBILITY_LEVEL_PUBLIC]: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
- [VISIBILITY_INTERNAL]: __(
+ [VISIBILITY_LEVEL_INTERNAL]: __(
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
- [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'),
+ [VISIBILITY_LEVEL_PRIVATE]: __(
+ 'Private - The group and its projects can only be viewed by members.',
+ ),
};
export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
- [VISIBILITY_INTERNAL]: __(
+ [VISIBILITY_LEVEL_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
+ [VISIBILITY_LEVEL_INTERNAL]: __(
'Internal - The project can be accessed by any logged in user except external users.',
),
- [VISIBILITY_PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE]: __(
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
),
};
export const VISIBILITY_TYPE_ICON = {
- [VISIBILITY_PUBLIC]: 'earth',
- [VISIBILITY_INTERNAL]: 'shield',
- [VISIBILITY_PRIVATE]: 'lock',
+ [VISIBILITY_LEVEL_PUBLIC]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE]: 'lock',
};
diff --git a/app/assets/javascripts/groups/create_edit_form.js b/app/assets/javascripts/groups/create_edit_form.js
index 8ca0e6077e9..330d343b776 100644
--- a/app/assets/javascripts/groups/create_edit_form.js
+++ b/app/assets/javascripts/groups/create_edit_form.js
@@ -1,8 +1,12 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { parseRailsFormFields } from '~/lib/utils/forms';
import { parseBoolean } from '~/lib/utils/common_utils';
import GroupNameAndPath from './components/group_name_and_path.vue';
+Vue.use(VueApollo);
+
export const initGroupNameAndPath = () => {
const elements = document.querySelectorAll('.js-group-name-and-path');
@@ -12,13 +16,17 @@ export const initGroupNameAndPath = () => {
elements.forEach((element) => {
const fields = parseRailsFormFields(element);
- const { basePath, mattermostEnabled } = element.dataset;
+ const { basePath, newSubgroup, mattermostEnabled } = element.dataset;
return new Vue({
el: element,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
fields,
basePath,
+ newSubgroup: parseBoolean(newSubgroup),
mattermostEnabled: parseBoolean(mattermostEnabled),
},
render(h) {
diff --git a/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql b/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql
new file mode 100644
index 00000000000..c45a31ef387
--- /dev/null
+++ b/app/assets/javascripts/groups/queries/search_groups_where_user_can_create_subgroups.query.graphql
@@ -0,0 +1,11 @@
+query searchGroupsWhereUserCanCreateSubgroups($search: String) {
+ currentUser {
+ id
+ groups(permissionScope: TRANSFER_PROJECTS, search: $search) {
+ nodes {
+ id
+ fullPath
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 0c4f9640972..f4b939fb20f 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -23,6 +23,9 @@ import {
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SCOPE_TOKEN_MAX_LENGTH,
INPUT_FIELD_PADDING,
+ IS_SEARCHING,
+ IS_FOCUSED,
+ IS_NOT_FOCUSED,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@@ -65,6 +68,7 @@ export default {
data() {
return {
showDropdown: false,
+ isFocused: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
},
@@ -92,20 +96,18 @@ export default {
if (!this.showDropdown || !this.isLoggedIn) {
return false;
}
-
return this.searchOptions?.length > 0;
},
showDefaultItems() {
return !this.searchText;
},
- showScopes() {
+ searchTermOverMin() {
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
defaultIndex() {
if (this.showDefaultItems) {
return SEARCH_BOX_INDEX;
}
-
return FIRST_DROPDOWN_INDEX;
},
@@ -132,12 +134,15 @@ export default {
count: this.searchOptions.length,
});
},
- searchBarStateIndicator() {
- const hasIcon =
- this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
- const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
- const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
- return `${isActive} ${isSearching} ${hasIcon}`;
+ searchBarClasses() {
+ return {
+ [IS_SEARCHING]: this.searchTermOverMin,
+ [IS_FOCUSED]: this.isFocused,
+ [IS_NOT_FOCUSED]: !this.isFocused,
+ };
+ },
+ showScopeHelp() {
+ return this.searchTermOverMin && this.isFocused;
},
searchBarItem() {
return this.searchOptions?.[0];
@@ -158,11 +163,22 @@ export default {
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
openDropdown() {
this.showDropdown = true;
- this.$emit('toggleDropdown', this.showDropdown);
+ this.isFocused = true;
+ this.$emit('expandSearchBar', true);
},
closeDropdown() {
this.showDropdown = false;
- this.$emit('toggleDropdown', this.showDropdown);
+ },
+ collapseAndCloseSearchBar() {
+ // we need a delay on this method
+ // for the search bar not to remove
+ // the clear button from dom
+ // and register clicks on dropdown items
+ setTimeout(() => {
+ this.showDropdown = false;
+ this.isFocused = false;
+ this.$emit('collapseSearchBar');
+ }, 200);
},
submitSearch() {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
@@ -171,6 +187,7 @@ export default {
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
+ this.openDropdown();
if (!searchTerm) {
this.clearAutocomplete();
} else {
@@ -201,7 +218,7 @@ export default {
role="search"
:aria-label="$options.i18n.searchGitlab"
class="header-search gl-relative gl-rounded-base gl-w-full"
- :class="searchBarStateIndicator"
+ :class="searchBarClasses"
data-testid="header-search-form"
>
<gl-search-box-by-type
@@ -217,12 +234,13 @@ export default {
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@focus="openDropdown"
@click="openDropdown"
+ @blur="collapseAndCloseSearchBar"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
@keydown.esc.stop.prevent="closeDropdown"
/>
<gl-token
- v-if="showScopes"
+ v-if="showScopeHelp"
v-gl-resize-observer-directive="observeTokenWidth"
class="in-search-scope-help"
:view-only="true"
@@ -242,6 +260,7 @@ export default {
}}
</gl-token>
<kbd
+ v-show="!isFocused"
v-gl-tooltip.bottom.hover.html
class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
:title="$options.i18n.kbdHelp"
@@ -262,9 +281,9 @@ export default {
<div
v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu"
- class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
+ class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0 gl-mt-3"
>
- <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
+ <div class="header-search-dropdown-content gl-py-2">
<dropdown-keyboard-navigation
v-model="currentFocusIndex"
:max="searchOptions.length - 1"
@@ -278,7 +297,7 @@ export default {
/>
<template v-else>
<header-search-scoped-items
- v-if="showScopes"
+ v-if="searchTermOverMin"
:current-focused-option="currentFocusedOption"
/>
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index a026386b2bd..3a20fb0216d 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -51,3 +51,7 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36;
export const INPUT_FIELD_PADDING = 52;
export const HEADER_INIT_EVENTS = ['input', 'focus'];
+
+export const IS_SEARCHING = 'is-searching';
+export const IS_FOCUSED = 'is-focused';
+export const IS_NOT_FOCUSED = 'is-not-focused';
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index b2c505d569f..f6f5c6a14fa 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -26,12 +26,11 @@ export const initHeaderSearchApp = (search = '') => {
render(createElement) {
return createElement(HeaderSearchApp, {
on: {
- toggleDropdown: (isVisible = false) => {
- if (isVisible) {
- navBarEl?.classList.add('header-search-is-active');
- } else {
- navBarEl?.classList.remove('header-search-is-active');
- }
+ expandSearchBar: () => {
+ navBarEl?.classList.add('header-search-is-active');
+ },
+ collapseSearchBar: () => {
+ navBarEl?.classList.remove('header-search-is-active');
},
},
});
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 05a254d3fbf..d02dc67d933 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -156,7 +156,7 @@ export default {
category="primary"
variant="confirm"
block
- class="qa-begin-commit-button"
+ data-qa-selector="begin_commit_button"
data-testid="begin-commit-button"
@click="beginCommit"
>
@@ -184,7 +184,7 @@ export default {
:disabled="commitButtonDisabled"
:loading="submitCommitLoading"
data-testid="commit-button"
- class="qa-commit-button"
+ data-qa-selector="commit_button"
category="primary"
variant="confirm"
type="submit"
diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue
index 0921b5a5424..ba679ae7c9b 100644
--- a/app/assets/javascripts/ide/components/file_templates/bar.vue
+++ b/app/assets/javascripts/ide/components/file_templates/bar.vue
@@ -80,7 +80,9 @@ export default {
<template>
<div
- class="gl-display-flex gl-align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
+ class="gl-display-flex gl-align-items-center ide-file-templates gl-relative gl-z-index-1"
+ data-testid="file-templates-bar"
+ data-qa-selector="file_templates_container"
>
<strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong>
<gl-dropdown
@@ -97,7 +99,8 @@ export default {
</gl-dropdown>
<gl-dropdown
v-if="showTemplatesDropdown"
- class="gl-mr-6 qa-file-template-dropdown"
+ class="gl-mr-6"
+ data-qa-selector="file_template_dropdown"
:text="$options.i18n.templateListDropdownLabel"
@show="fetchTemplateTypes"
>
diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue
index 1c25a8e634d..3296dc2060c 100644
--- a/app/assets/javascripts/ide/components/ide_project_header.vue
+++ b/app/assets/javascripts/ide/components/ide_project_header.vue
@@ -18,6 +18,7 @@ export default {
<div class="context-header ide-context-header">
<a :href="project.web_url" :title="s__('IDE|Go to project')" data-testid="go-to-project-link">
<project-avatar
+ :project-id="project.id"
:project-name="project.name"
:project-avatar-url="project.avatar_url"
:size="48"
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index c9bf84be6ac..737ff49f74c 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -52,7 +52,7 @@ export default {
</script>
<template>
- <div class="ide-file-list qa-file-list">
+ <div class="ide-file-list" data-qa-selector="file_list_container">
<template v-if="showLoading">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
<gl-skeleton-loader />
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index 46128651547..5f67eee5f18 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -51,7 +51,7 @@ export default class Model {
}
get language() {
- return this.model.getModeId();
+ return this.model.getLanguageId();
}
get path() {
diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
index b4ceec22822..fe687ea9767 100644
--- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
+++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue
@@ -133,9 +133,6 @@ export default {
this.model = null;
}
},
- helpHtmlConfig: {
- ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
- },
};
</script>
@@ -147,7 +144,7 @@ export default {
:state="valid"
>
<template v-if="!isCheckbox" #description>
- <span v-safe-html:[$options.helpHtmlConfig]="help"></span>
+ <span v-safe-html="help"></span>
</template>
<template v-if="isCheckbox">
@@ -155,7 +152,7 @@ export default {
<gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting">
{{ checkboxLabel || humanizedTitle }}
<template #help>
- <span v-safe-html:[$options.helpHtmlConfig]="help"></span>
+ <span v-safe-html="help"></span>
</template>
</gl-form-checkbox>
</template>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index f1f574c6424..7a6f1a953a8 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -192,11 +192,7 @@ export default {
this.integrationActive = integrationActive;
},
},
- descriptionHtmlConfig: {
- ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
- },
helpHtmlConfig: {
- ADD_ATTR: ['target'], // allow external links, can be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1427 is implemented
ADD_TAGS: ['use'], // to support icon SVGs
FORBID_ATTR: [], // This is trusted input so we can override the default config to allow data-* attributes
},
@@ -254,7 +250,7 @@ export default {
{{ $options.billingPlanNames[section.plan] }}
</gl-badge>
</h4>
- <p v-safe-html:[$options.descriptionHtmlConfig]="section.description"></p>
+ <p v-safe-html="section.description"></p>
</div>
<div class="col-lg-8">
diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
index 1255ed01f6d..a8389e32b40 100644
--- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
+++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue
@@ -125,6 +125,7 @@ export default {
>
<project-avatar
class="gl-mr-3"
+ :project-id="item.id"
:project-avatar-url="item.avatar_url"
:project-name="item.name"
aria-hidden="true"
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 b71cfbb6112..87f1ed31a7f 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -6,6 +6,9 @@ import {
GlLink,
GlSprintf,
GlFormCheckboxGroup,
+ GlButton,
+ GlCollapse,
+ GlIcon,
} from '@gitlab/ui';
import { partition, isString, uniqueId, isEmpty } from 'lodash';
import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue';
@@ -13,7 +16,7 @@ import Api from '~/api';
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 { n__ } from '~/locale';
+import { n__, sprintf } from '~/locale';
import {
CLOSE_TO_LIMIT_COUNT,
USERS_FILTER_ALL,
@@ -38,6 +41,9 @@ export default {
GlDropdownItem,
GlSprintf,
GlFormCheckboxGroup,
+ GlButton,
+ GlCollapse,
+ GlIcon,
InviteModalBase,
MembersTokenSelect,
ModalConfetti,
@@ -110,6 +116,8 @@ export default {
mode: 'default',
// Kept in sync with "base"
selectedAccessLevel: undefined,
+ errorsLimit: 2,
+ isErrorsSectionExpanded: false,
};
},
computed: {
@@ -135,7 +143,7 @@ export default {
return n__(
"InviteMembersModal|The following member couldn't be invited",
"InviteMembersModal|The following %d members couldn't be invited",
- Object.keys(this.invalidMembers).length,
+ this.errorList.length,
);
},
tasksToBeDoneEnabled() {
@@ -187,6 +195,29 @@ export default {
? this.$options.labels.placeHolderDisabled
: this.$options.labels.placeHolder;
},
+ errorList() {
+ return Object.entries(this.invalidMembers).map(([member, error]) => {
+ return { member, displayedMemberName: this.tokenName(member), message: error };
+ });
+ },
+ errorsLimited() {
+ return this.errorList.slice(0, this.errorsLimit);
+ },
+ errorsExpanded() {
+ return this.errorList.slice(this.errorsLimit);
+ },
+ shouldErrorsSectionExpand() {
+ return Boolean(this.errorsExpanded.length);
+ },
+ errorCollapseText() {
+ if (this.isErrorsSectionExpanded) {
+ return this.$options.labels.expandedErrors;
+ }
+
+ return sprintf(this.$options.labels.collapsedErrors, {
+ count: this.errorsExpanded.length,
+ });
+ },
},
mounted() {
eventHub.$on('openModal', (options) => {
@@ -311,6 +342,9 @@ export default {
delete this.invalidMembers[memberName(token)];
this.invalidMembers = { ...this.invalidMembers };
},
+ toggleErrorExpansion() {
+ this.isErrorsSectionExpanded = !this.isErrorsSectionExpanded;
+ },
},
labels: MEMBER_MODAL_LABELS,
};
@@ -356,11 +390,37 @@ export default {
data-testid="alert-member-error"
>
{{ $options.labels.memberErrorListText }}
- <ul class="gl-pl-5">
- <li v-for="(error, member) in invalidMembers" :key="member">
- <strong>{{ tokenName(member) }}:</strong> {{ error }}
+ <ul class="gl-pl-5 gl-mb-0">
+ <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item">
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
</li>
</ul>
+ <template v-if="shouldErrorsSectionExpand">
+ <gl-collapse v-model="isErrorsSectionExpanded">
+ <ul class="gl-pl-5 gl-mb-0">
+ <li
+ v-for="error in errorsExpanded"
+ :key="error.member"
+ data-testid="errors-expanded-item"
+ >
+ <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }}
+ </li>
+ </ul>
+ </gl-collapse>
+ <gl-button
+ class="gl-text-decoration-none! gl-shadow-none! gl-mt-3"
+ data-testid="accordion-button"
+ variant="link"
+ @click="toggleErrorExpansion"
+ >
+ {{ errorCollapseText }}
+ <gl-icon
+ name="chevron-down"
+ class="gl-transition-medium"
+ :class="{ 'gl-rotate-180': isErrorsSectionExpanded }"
+ />
+ </gl-button>
+ </template>
</gl-alert>
<user-limit-notification
v-else
diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue
index b2bcb9a5906..2ddb04e1eeb 100644
--- a/app/assets/javascripts/invite_members/components/members_token_select.vue
+++ b/app/assets/javascripts/invite_members/components/members_token_select.vue
@@ -1,10 +1,16 @@
<script>
import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@gitlab/ui';
-import { debounce } from 'lodash';
+import { debounce, isEmpty } from 'lodash';
import { __ } from '~/locale';
import { getUsers } from '~/rest_api';
import { memberName } from '../utils/member_utils';
-import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
+import {
+ SEARCH_DELAY,
+ USERS_FILTER_ALL,
+ USERS_FILTER_SAML_PROVIDER_ID,
+ VALID_TOKEN_BACKGROUND,
+ INVALID_TOKEN_BACKGROUND,
+} from '../constants';
export default {
components: {
@@ -75,6 +81,25 @@ export default {
}
return this.$options.defaultQueryOptions;
},
+ hasInvalidMembers() {
+ return !isEmpty(this.invalidMembers);
+ },
+ },
+ watch: {
+ // We might not really want this to be *reactive* since we want the "class" state to be
+ // tied to the specific `selectedToken` such that if the token is removed and re-added, this
+ // state is reset.
+ // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90076#note_1027165312
+ hasInvalidMembers: {
+ handler(updatedInvalidMembers) {
+ // Only update tokens if we receive invalid members
+ if (!updatedInvalidMembers) {
+ return;
+ }
+
+ this.updateTokenClasses();
+ },
+ },
},
methods: {
handleTextInput(query) {
@@ -83,6 +108,12 @@ export default {
this.loading = true;
this.retrieveUsers(query);
},
+ updateTokenClasses() {
+ this.selectedTokens = this.selectedTokens.map((token) => ({
+ ...token,
+ class: this.tokenClass(token),
+ }));
+ },
retrieveUsers: debounce(function debouncedRetrieveUsers() {
return getUsers(this.query, this.queryOptions)
.then((response) => {
@@ -98,6 +129,14 @@ export default {
this.loading = false;
});
}, SEARCH_DELAY),
+ tokenClass(token) {
+ if (this.hasError(token)) {
+ return INVALID_TOKEN_BACKGROUND;
+ }
+
+ // assume success for this token
+ return VALID_TOKEN_BACKGROUND;
+ },
handleInput() {
this.$emit('input', this.selectedTokens);
},
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 ae5c3c11386..6c9b1f8e6d0 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -10,7 +10,6 @@ import {
CLOSE_TO_LIMIT_MESSAGE,
CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
DANGER_ALERT_TITLE_PERSONAL_NAMESPACE,
- WARNING_ALERT_TITLE_PERSONAL_NAMESPACE,
} from '../constants';
export default {
@@ -46,13 +45,6 @@ export default {
return this.usersLimitDataset.purchasePath;
},
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 sprintf(WARNING_ALERT_TITLE, {
count: this.freeUsersLimit - this.membersCount,
members: this.pluralMembers(this.freeUsersLimit - this.membersCount),
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 6141e5e9e0b..1ceb63e2146 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -2,6 +2,8 @@ import { s__ } from '~/locale';
export const CLOSE_TO_LIMIT_COUNT = 2;
export const SEARCH_DELAY = 200;
+export const VALID_TOKEN_BACKGROUND = 'gl-bg-green-100';
+export const INVALID_TOKEN_BACKGROUND = 'gl-bg-red-100';
export const INVITE_MEMBERS_FOR_TASK = {
minimum_access_level: 30,
name: 'invite_members_for_task',
@@ -77,6 +79,8 @@ export const HEADER_CLOSE_LABEL = s__('InviteMembersModal|Close invite team memb
export const MEMBER_ERROR_LIST_TEXT = s__(
'InviteMembersModal|Review the invite errors and try again:',
);
+export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})');
+export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less');
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -113,6 +117,8 @@ export const MEMBER_MODAL_LABELS = {
},
toastMessageSuccessful: TOAST_MESSAGE_SUCCESSFUL,
memberErrorListText: MEMBER_ERROR_LIST_TEXT,
+ collapsedErrors: COLLAPSED_ERRORS,
+ expandedErrors: EXPANDED_ERRORS,
};
export const GROUP_MODAL_LABELS = {
@@ -136,9 +142,6 @@ 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}",
);
@@ -153,12 +156,12 @@ export const REACHED_LIMIT_MESSAGE = s__(
export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.concat(
s__(
- 'InviteMembersModal| To get more members and access to additional paid features, an owner of this namespace can start a trial or upgrade to a paid tier.',
+ 'InviteMembersModal| To get more members and access to additional paid features, an owner of the group can start a trial or upgrade to a paid tier.',
),
);
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.',
+ 'InviteMembersModal|To get more members an owner of the group 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/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js
index a4be3f205a3..6e2c0ecb5bb 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -20,6 +20,8 @@ export default (function initInviteMembersModal() {
return false;
}
+ const usersLimitDataset = JSON.parse(el.dataset.usersLimitDataset || '{}');
+
inviteMembersModal = new Vue({
el,
name: 'InviteMembersModalRoot',
@@ -38,9 +40,10 @@ export default (function initInviteMembersModal() {
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
- usersLimitDataset: convertObjectPropsToCamelCase(
- JSON.parse(el.dataset.usersLimitDataset || '{}'),
- ),
+ usersLimitDataset: convertObjectPropsToCamelCase({
+ ...usersLimitDataset,
+ user_namespace: parseBoolean(usersLimitDataset.user_namespace),
+ }),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/issue_milestone.vue b/app/assets/javascripts/issuable/components/issue_milestone.vue
index 11fc032f34f..4f1001e8c3b 100644
--- a/app/assets/javascripts/issuable/components/issue_milestone.vue
+++ b/app/assets/javascripts/issuable/components/issue_milestone.vue
@@ -73,7 +73,9 @@ export default {
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<gl-icon :size="16" class="gl-mr-2 flex-shrink-0" name="clock" />
- <span class="milestone-title d-inline-block">{{ milestone.title }}</span>
+ <span class="milestone-title gl-display-inline-block gl-text-truncate">{{
+ milestone.title
+ }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index a505a988360..667c712d3be 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -2,29 +2,35 @@
import '~/commons/bootstrap';
import {
GlIcon,
+ GlLink,
GlTooltip,
GlTooltipDirective,
GlButton,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
+import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import relatedIssuableMixin from '../mixins/related_issuable_mixin';
import IssueAssignees from './issue_assignees.vue';
import IssueMilestone from './issue_milestone.vue';
export default {
- name: 'IssueItem',
components: {
IssueMilestone,
IssueAssignees,
CiIcon,
GlIcon,
+ GlLink,
GlTooltip,
IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
IssueDueDate,
GlButton,
+ WorkItemDetailModal,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -47,6 +53,11 @@ export default {
required: false,
default: '',
},
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
stateTitle() {
@@ -62,6 +73,27 @@ export default {
iconClasses() {
return `${this.iconClass} ic-${this.iconName}`;
},
+ workItemId() {
+ return convertToGraphQLId(TYPE_WORK_ITEM, this.idKey);
+ },
+ },
+ methods: {
+ handleTitleClick(event) {
+ if (this.workItemType === 'TASK') {
+ event.preventDefault();
+ this.$refs.modal.show();
+ this.updateWorkItemIdUrlQuery(this.idKey);
+ }
+ },
+ handleWorkItemDeleted(workItemId) {
+ this.$emit('relatedIssueRemoveRequest', workItemId);
+ },
+ updateWorkItemIdUrlQuery(workItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: workItemId }),
+ replace: true,
+ });
+ },
},
};
</script>
@@ -102,7 +134,13 @@ export default {
class="confidential-icon gl-mr-2 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
- <a :href="computedPath" class="sortable-link gl-font-weight-normal">{{ title }}</a>
+ <gl-link
+ :href="computedPath"
+ class="sortable-link gl-font-weight-normal"
+ @click="handleTitleClick"
+ >
+ {{ title }}
+ </gl-link>
</div>
<!-- Info area: meta, path, and assignees -->
@@ -178,16 +216,15 @@ export default {
<span
v-if="isLocked"
- ref="lockIcon"
v-gl-tooltip
class="gl-px-3 gl-display-inline-block gl-cursor-not-allowed"
:title="lockedMessage"
+ data-testid="lockIcon"
>
<gl-icon name="lock" />
</span>
<gl-button
v-else-if="canRemove"
- ref="removeButton"
v-gl-tooltip
icon="close"
category="tertiary"
@@ -198,5 +235,11 @@ export default {
:aria-label="__('Remove')"
@click="onRemoveRequest"
/>
+ <work-item-detail-modal
+ ref="modal"
+ :work-item-id="workItemId"
+ @close="updateWorkItemIdUrlQuery"
+ @workItemDeleted="handleWorkItemDeleted"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/issuable/issuable_template_selector.js b/app/assets/javascripts/issuable/issuable_template_selector.js
index cce903d388d..6b8f3de8d49 100644
--- a/app/assets/javascripts/issuable/issuable_template_selector.js
+++ b/app/assets/javascripts/issuable/issuable_template_selector.js
@@ -17,7 +17,15 @@ export default class IssuableTemplateSelector extends TemplateSelector {
name: this.dropdown.data('selected'),
};
- if (initialQuery.name) this.requestFile(initialQuery);
+ // Only use the default template if we don't have description data from autosave
+ if (!initialQuery.name && this.dropdown.data('default') && !this.editor.getValue().length) {
+ initialQuery.name = this.dropdown.data('default');
+ }
+
+ if (initialQuery.name) {
+ this.requestFile(initialQuery);
+ this.setToggleText(initialQuery.name);
+ }
$('.reset-template', this.dropdown.parent()).on('click', () => {
this.setInputValueToTemplateContent();
@@ -53,10 +61,14 @@ export default class IssuableTemplateSelector extends TemplateSelector {
}
this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
+ this.setToggleText(__('Choose a template'));
this.previousSelectedIndex = null;
}
+ setToggleText(text) {
+ $('.dropdown-toggle-text', this.dropdown).text(text);
+ }
+
setSelectedIndex() {
this.previousSelectedIndex = this.dropdown.data('deprecatedJQueryDropdown').selectedIndex;
}
diff --git a/app/assets/javascripts/issuable/popover/components/issue_popover.vue b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
index 0cafaa1e500..945a3782642 100644
--- a/app/assets/javascripts/issuable/popover/components/issue_popover.vue
+++ b/app/assets/javascripts/issuable/popover/components/issue_popover.vue
@@ -1,14 +1,26 @@
<script>
-import { GlPopover, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIcon, GlPopover, GlSkeletonLoader, GlTooltipDirective } from '@gitlab/ui';
+import query from 'ee_else_ce/issuable/popover/queries/issue.query.graphql';
+import IssueDueDate from '~/boards/components/issue_due_date.vue';
+import IssueMilestone from '~/issuable/components/issue_milestone.vue';
import StatusBox from '~/issuable/components/status_box.vue';
+import { IssuableStatus } from '~/issues/constants';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import query from '../queries/issue.query.graphql';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
export default {
components: {
+ GlIcon,
GlPopover,
GlSkeletonLoader,
+ IssueDueDate,
+ IssueMilestone,
+ IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
StatusBox,
+ WorkItemTypeIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
@@ -44,6 +56,9 @@ export default {
showDetails() {
return Object.keys(this.issue).length > 0;
},
+ isIssueClosed() {
+ return this.issue?.state === IssuableStatus.Closed;
+ },
},
apollo: {
issue: {
@@ -69,15 +84,46 @@ export default {
</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" />
+ <gl-icon
+ v-if="issue.confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :title="__('Confidential')"
+ class="gl-text-orange-500 gl-mr-2"
+ :aria-label="__('Confidential')"
+ />
<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>
+ <work-item-type-icon v-if="!$apollo.queries.issue.loading" :work-item-type="issue.type" />
+ <span class="gl-text-secondary">{{ `${projectPath}#${iid}` }}</span>
</div>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
+
+ <div v-if="!$apollo.queries.issue.loading" class="gl-display-flex gl-text-secondary gl-mt-2">
+ <issue-due-date
+ v-if="issue.dueDate"
+ :date="issue.dueDate.toString()"
+ :closed="isIssueClosed"
+ tooltip-placement="top"
+ class="gl-mr-4"
+ css-class="gl-display-flex gl-white-space-nowrap"
+ />
+ <issue-weight
+ v-if="issue.weight"
+ :weight="issue.weight"
+ tag-name="span"
+ class="gl-display-flex gl-mr-4"
+ />
+ <issue-milestone
+ v-if="issue.milestone"
+ :milestone="issue.milestone"
+ class="gl-display-flex gl-overflow-hidden"
+ />
+ </div>
</gl-popover>
</template>
diff --git a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
index 47a62e2b6ea..1bbe6cb6eac 100644
--- a/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
+++ b/app/assets/javascripts/issuable/popover/queries/issue.query.graphql
@@ -6,6 +6,15 @@ query issue($projectPath: ID!, $iid: String!) {
title
createdAt
state
+ confidential
+ dueDate
+ milestone {
+ id
+ title
+ startDate
+ dueDate
+ }
+ type
}
}
}
diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js
index 380bb5f5346..22ac37656ea 100644
--- a/app/assets/javascripts/issues/index.js
+++ b/app/assets/javascripts/issues/index.js
@@ -9,7 +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 { initRelatedIssues } from '~/related_issues';
import {
initHeaderActions,
initIncidentApp,
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 f567b0f1d68..11911adb401 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -39,13 +39,13 @@ import {
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,
- IssuableTypes,
-} from '~/vue_shared/issuable/list/constants';
+import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { WORK_ITEM_TYPE_ENUM_TASK } from '~/work_items/constants';
import {
CREATED_DESC,
+ defaultTypeTokenOptions,
+ defaultWorkItemTypes,
i18n,
ISSUE_REFERENCE,
MAX_LIST_SIZE,
@@ -67,6 +67,7 @@ import {
TOKEN_TYPE_ORGANIZATION,
TOKEN_TYPE_RELEASE,
TOKEN_TYPE_TYPE,
+ TYPE_TOKEN_TASK_OPTION,
UPDATED_DESC,
urlSortParams,
} from '../constants';
@@ -107,7 +108,6 @@ const CrmOrganizationToken = () =>
export default {
i18n,
IssuableListTabs,
- IssuableTypes: [IssuableTypes.Issue, IssuableTypes.Incident, IssuableTypes.TestCase],
components: {
CsvImportExportButtons,
GlButton,
@@ -123,6 +123,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagMixin()],
inject: [
'autocompleteAwardEmojisPath',
'calendarPath',
@@ -180,9 +181,7 @@ export default {
issues: {
query: getIssuesQuery,
variables() {
- const { types } = this.queryVariables;
-
- return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes };
+ return this.queryVariables;
},
update(data) {
return data[this.namespace]?.issues.nodes ?? [];
@@ -206,9 +205,7 @@ export default {
issuesCounts: {
query: getIssuesCountsQuery,
variables() {
- const { types } = this.queryVariables;
-
- return { ...this.queryVariables, types: types ? [types] : this.$options.IssuableTypes };
+ return this.queryVariables;
},
update(data) {
return data[this.namespace] ?? {};
@@ -240,11 +237,22 @@ export default {
state: this.state,
...this.pageParams,
...this.apiFilterParams,
+ types: this.apiFilterParams.types || this.defaultWorkItemTypes,
};
},
namespace() {
return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
},
+ defaultWorkItemTypes() {
+ return this.isWorkItemsEnabled
+ ? defaultWorkItemTypes.concat(WORK_ITEM_TYPE_ENUM_TASK)
+ : defaultWorkItemTypes;
+ },
+ typeTokenOptions() {
+ return this.isWorkItemsEnabled
+ ? defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION)
+ : defaultTypeTokenOptions;
+ },
hasSearch() {
return (
this.searchQuery ||
@@ -262,6 +270,9 @@ export default {
isOpenTab() {
return this.state === IssuableStates.Opened;
},
+ isWorkItemsEnabled() {
+ return this.glFeatures.workItems;
+ },
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
@@ -340,11 +351,7 @@ export default {
title: TOKEN_TITLE_TYPE,
icon: 'issues',
token: GlFilteredSearchToken,
- options: [
- { icon: 'issue-type-issue', title: 'issue', value: 'issue' },
- { icon: 'issue-type-incident', title: 'incident', value: 'incident' },
- { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
- ],
+ options: this.typeTokenOptions,
},
];
@@ -767,6 +774,7 @@ export default {
:show-page-size-change-controls="showPageSizeControls"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
+ show-work-item-type-icon
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@filter="handleFilter"
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index a921eb62e26..38fe4c33792 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -8,6 +8,11 @@ import {
OPERATOR_IS,
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_ISSUE,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+} from '~/work_items/constants';
export const i18n = {
anonymousSearchingMessage: __('You must sign in to search for specific terms.'),
@@ -147,6 +152,20 @@ export const TOKEN_TYPE_WEIGHT = 'weight';
export const TOKEN_TYPE_CONTACT = 'crm_contact';
export const TOKEN_TYPE_ORGANIZATION = 'crm_organization';
+export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' };
+
+export const defaultWorkItemTypes = [
+ WORK_ITEM_TYPE_ENUM_ISSUE,
+ WORK_ITEM_TYPE_ENUM_INCIDENT,
+ WORK_ITEM_TYPE_ENUM_TEST_CASE,
+];
+
+export const defaultTypeTokenOptions = [
+ { icon: 'issue-type-issue', title: 'issue', value: 'issue' },
+ { icon: 'issue-type-incident', title: 'incident', value: 'incident' },
+ { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' },
+];
+
export const filters = {
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
diff --git a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
index 35762120f71..040763f2ba4 100644
--- a/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues/list/queries/issue.fragment.graphql
@@ -1,5 +1,4 @@
fragment IssueFragment on Issue {
- __typename
id
iid
confidential
@@ -18,9 +17,9 @@ fragment IssueFragment on Issue {
userDiscussionsCount @include(if: $isSignedIn)
webPath
webUrl
+ type
assignees @skip(if: $hideUsers) {
nodes {
- __typename
id
avatarUrl
name
@@ -29,7 +28,6 @@ fragment IssueFragment on Issue {
}
}
author @skip(if: $hideUsers) {
- __typename
id
avatarUrl
name
diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
index 1d48446b083..a5cba3daafa 100644
--- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue
@@ -66,7 +66,7 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
<div class="card card-slim gl-mt-5">
- <div class="card-header">
+ <div class="card-header gl-bg-gray-10">
<div
class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0"
>
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index 449da394841..a6747d67611 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -133,7 +133,7 @@ export default {
},
computed: {
workItemsEnabled() {
- return this.glFeatures.workItems;
+ return this.glFeatures.workItemsCreateFromMarkdown;
},
taskWorkItemType() {
return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
@@ -302,7 +302,9 @@ export default {
if (taskRegexMatches) {
$tasks.text(this.taskStatus);
$tasksShort.text(
- `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`,
+ `${taskRegexMatches[1]}/${taskRegexMatches[2]} checklist item${
+ taskRegexMatches[2] > 1 ? 's' : ''
+ }`,
);
} else {
$tasks.text('');
@@ -315,7 +317,7 @@ export default {
}
this.taskButtons = [];
- const taskListFields = this.$el.querySelectorAll('.task-list-item');
+ const taskListFields = this.$el.querySelectorAll('.task-list-item:not(.inapplicable)');
taskListFields.forEach((item, index) => {
const taskLink = item.querySelector('.gfm-issue');
@@ -326,6 +328,7 @@ export default {
}
const workItemId = convertToGraphQLId(TYPE_WORK_ITEM, issue);
this.addHoverListeners(taskLink, workItemId);
+ taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
e.preventDefault();
this.openWorkItemDetailModal(taskLink);
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index 9fc5027d457..77d13fe085a 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
export const timelineTabI18n = Object.freeze({
title: s__('Incident|Timeline'),
@@ -12,6 +12,9 @@ export const timelineFormI18n = Object.freeze({
'Incident|Something went wrong while creating the incident timeline event.',
),
areaPlaceholder: s__('Incident|Timeline text...'),
+ save: __('Save'),
+ cancel: __('Cancel'),
+ description: __('Description'),
saveAndAdd: s__('Incident|Save and add another event'),
areaLabel: s__('Incident|Timeline text'),
});
diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
new file mode 100644
index 00000000000..c902895702e
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -0,0 +1,117 @@
+<script>
+import { produce } from 'immer';
+import { sortBy } from 'lodash';
+import { sprintf } from '~/locale';
+import { createAlert } from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { timelineFormI18n } from './constants';
+import TimelineEventsForm from './timeline_events_form.vue';
+
+import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
+import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+
+export default {
+ name: 'CreateTimelineEvent',
+ i18n: timelineFormI18n,
+ components: {
+ TimelineEventsForm,
+ },
+ inject: ['fullPath', 'issuableId'],
+ props: {
+ hasTimelineEvents: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return { createTimelineEventActive: false };
+ },
+ methods: {
+ clearForm() {
+ this.$refs.eventForm.clear();
+ },
+ focusDate() {
+ this.$refs.eventForm.focusDate();
+ },
+ updateCache(store, { data }) {
+ const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
+
+ if (errors.length) {
+ return;
+ }
+
+ const variables = {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ fullPath: this.fullPath,
+ };
+
+ const sourceData = store.readQuery({
+ query: getTimelineEvents,
+ variables,
+ });
+
+ const newData = produce(sourceData, (draftData) => {
+ const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
+ draftEventList.push(event);
+ // ISOStrings sort correctly in lexical order
+ const sortedEvents = sortBy(draftEventList, 'occurredAt');
+ draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
+ });
+
+ store.writeQuery({
+ query: getTimelineEvents,
+ variables,
+ data: newData,
+ });
+ },
+ createIncidentTimelineEvent(eventDetails, addAnotherEvent = false) {
+ this.createTimelineEventActive = true;
+ return this.$apollo
+ .mutate({
+ mutation: CreateTimelineEvent,
+ variables: {
+ input: {
+ incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
+ note: eventDetails.note,
+ occurredAt: eventDetails.occurredAt,
+ },
+ },
+ update: this.updateCache,
+ })
+ .then(({ data = {} }) => {
+ this.createTimelineEventActive = false;
+ const errors = data.timelineEventCreate?.errors;
+ if (errors.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
+ });
+ return;
+ }
+ if (addAnotherEvent) {
+ this.$refs.eventForm.clear();
+ } else {
+ this.$emit('hide-new-timeline-events-form');
+ }
+ })
+ .catch((error) => {
+ createAlert({
+ message: this.$options.i18n.createErrorGeneric,
+ captureError: true,
+ error,
+ });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <timeline-events-form
+ ref="eventForm"
+ :is-event-processed="createTimelineEventActive"
+ :has-timeline-events="hasTimelineEvents"
+ @save-event="createIncidentTimelineEvent"
+ @cancel="$emit('hide-new-timeline-events-form')"
+ />
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
index 36ec6362a22..0d84fabb1be 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue
@@ -1,21 +1,12 @@
<script>
import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
-import { produce } from 'immer';
-import { sortBy } from 'lodash';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
-import { createAlert } from '~/flash';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
-import { sprintf } from '~/locale';
-import { getUtcShiftedDateNow } from './utils';
import { timelineFormI18n } from './constants';
-
-import CreateTimelineEvent from './graphql/queries/create_timeline_event.mutation.graphql';
-import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
+import { getUtcShiftedDateNow } from './utils';
export default {
- name: 'IncidentTimelineEventForm',
+ name: 'TimelineEventsForm',
restrictedToolBarItems: [
'quote',
'strikethrough',
@@ -38,112 +29,55 @@ export default {
directives: {
autofocusonshow,
},
- inject: ['fullPath', 'issuableId'],
props: {
hasTimelineEvents: {
type: Boolean,
required: true,
},
+ isEventProcessed: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
- // Create shifted date to force the datepicker to format in UTC
- const utcShiftedDate = getUtcShiftedDateNow();
+ // if occurredAt is undefined, returns "now" in UTC
+ const placeholderDate = getUtcShiftedDateNow();
+
return {
- currentDate: utcShiftedDate,
- currentHour: utcShiftedDate.getHours(),
- currentMinute: utcShiftedDate.getMinutes(),
timelineText: '',
- createTimelineEventActive: false,
+ placeholderDate,
+ hourPickerInput: placeholderDate.getHours(),
+ minutePickerInput: placeholderDate.getMinutes(),
datepickerTextInput: null,
};
},
+ computed: {
+ occurredAt() {
+ const [years, months, days] = this.datepickerTextInput.split('-');
+ const utcDate = new Date(
+ Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput),
+ );
+
+ return utcDate.toISOString();
+ },
+ },
methods: {
clear() {
- const utcShiftedDate = getUtcShiftedDateNow();
- this.currentDate = utcShiftedDate;
- this.currentHour = utcShiftedDate.getHours();
- this.currentMinute = utcShiftedDate.getMinutes();
- },
- hideIncidentTimelineEventForm() {
- this.$emit('hide-incident-timeline-event-form');
+ const utcShiftedDateNow = getUtcShiftedDateNow();
+ this.placeholderDate = utcShiftedDateNow;
+ this.hourPickerInput = utcShiftedDateNow.getHours();
+ this.minutePickerInput = utcShiftedDateNow.getMinutes();
+ this.timelineText = '';
},
focusDate() {
this.$refs.datepicker.$el.focus();
},
- updateCache(store, { data }) {
- const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
-
- if (errors.length) {
- return;
- }
-
- const variables = {
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
- fullPath: this.fullPath,
+ handleSave(addAnotherEvent) {
+ const eventDetails = {
+ note: this.timelineText,
+ occurredAt: this.occurredAt,
};
-
- const sourceData = store.readQuery({
- query: getTimelineEvents,
- variables,
- });
-
- const newData = produce(sourceData, (draftData) => {
- const { nodes: draftEventList } = draftData.project.incidentManagementTimelineEvents;
- draftEventList.push(event);
- // ISOStrings sort correctly in lexical order
- const sortedEvents = sortBy(draftEventList, 'occurredAt');
- draftData.project.incidentManagementTimelineEvents.nodes = sortedEvents;
- });
-
- store.writeQuery({
- query: getTimelineEvents,
- variables,
- data: newData,
- });
- },
- createIncidentTimelineEvent(addOneEvent) {
- this.createTimelineEventActive = true;
- return this.$apollo
- .mutate({
- mutation: CreateTimelineEvent,
- variables: {
- input: {
- incidentId: convertToGraphQLId(TYPE_ISSUE, this.issuableId),
- note: this.timelineText,
- occurredAt: this.createDateString(),
- },
- },
- update: this.updateCache,
- })
- .then(({ data = {} }) => {
- const errors = data.timelineEventCreate?.errors;
- if (errors.length) {
- createAlert({
- message: sprintf(this.$options.i18n.createError, { error: errors.join('. ') }, false),
- });
- }
- })
- .catch((error) => {
- createAlert({
- message: this.$options.i18n.createErrorGeneric,
- captureError: true,
- error,
- });
- })
- .finally(() => {
- this.createTimelineEventActive = false;
- this.timelineText = '';
- if (addOneEvent) {
- this.hideIncidentTimelineEventForm();
- }
- });
- },
- createDateString() {
- const [years, months, days] = this.datepickerTextInput.split('-');
- const utcDate = new Date(
- Date.UTC(years, months - 1, days, this.currentHour, this.currentMinute),
- );
- return utcDate.toISOString();
+ this.$emit('save-event', eventDetails, addAnotherEvent);
},
},
};
@@ -165,7 +99,7 @@ export default {
class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker"
>
<gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
- <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="currentDate">
+ <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate">
<gl-form-input
id="incident-date"
ref="datepicker"
@@ -184,7 +118,7 @@ export default {
<label label-for="timeline-input-hours" class="sr-only"></label>
<gl-form-input
id="timeline-input-hours"
- v-model="currentHour"
+ v-model="hourPickerInput"
data-testid="input-hours"
size="xs"
type="number"
@@ -194,7 +128,7 @@ export default {
<label label-for="timeline-input-minutes" class="sr-only"></label>
<gl-form-input
id="timeline-input-minutes"
- v-model="currentMinute"
+ v-model="minutePickerInput"
class="gl-ml-3"
data-testid="input-minutes"
size="xs"
@@ -223,9 +157,10 @@ export default {
<textarea
v-model="timelineText"
class="note-textarea js-gfm-input js-autosize markdown-area"
+ data-testid="input-note"
dir="auto"
data-supports-quick-actions="false"
- :aria-label="__('Description')"
+ :aria-label="$options.i18n.description"
:placeholder="$options.i18n.areaPlaceholder"
>
</textarea>
@@ -238,26 +173,22 @@ export default {
variant="confirm"
category="primary"
class="gl-mr-3"
- :loading="createTimelineEventActive"
- @click="createIncidentTimelineEvent(true)"
+ :loading="isEventProcessed"
+ @click="handleSave(false)"
>
- {{ __('Save') }}
+ {{ $options.i18n.save }}
</gl-button>
<gl-button
variant="confirm"
category="secondary"
class="gl-mr-3 gl-ml-n2"
- :loading="createTimelineEventActive"
- @click="createIncidentTimelineEvent(false)"
+ :loading="isEventProcessed"
+ @click="handleSave(true)"
>
{{ $options.i18n.saveAndAdd }}
</gl-button>
- <gl-button
- class="gl-ml-n2"
- :disabled="createTimelineEventActive"
- @click="hideIncidentTimelineEventForm"
- >
- {{ __('Cancel') }}
+ <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
+ {{ $options.i18n.cancel }}
</gl-button>
<div class="gl-border-b gl-pt-5"></div>
</gl-form-group>
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_item.vue
index 62ccd696ef6..6175c9969ec 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
@@ -1,5 +1,12 @@
<script>
-import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSafeHtmlDirective,
+ GlSprintf,
+} from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { getEventIcon } from './utils';
@@ -12,6 +19,7 @@ export default {
timeUTC: __('%{time} UTC'),
},
components: {
+ GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
@@ -83,7 +91,7 @@ export default {
no-caret
>
<gl-dropdown-item @click="$emit('delete')">
- {{ $options.i18n.delete }}
+ <gl-button>{{ $options.i18n.delete }}</gl-button>
</gl-dropdown-item>
</gl-dropdown>
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index 519c0d402a0..80ac1c372cd 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
@@ -4,7 +4,7 @@ import { createAlert } from '~/flash';
import { sprintf } from '~/locale';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
-import IncidentTimelineEventListItem from './timeline_events_list_item.vue';
+import IncidentTimelineEventItem from './timeline_events_item.vue';
import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
import { timelineListI18n } from './constants';
@@ -12,7 +12,7 @@ export default {
name: 'IncidentTimelineEventList',
i18n: timelineListI18n,
components: {
- IncidentTimelineEventListItem,
+ IncidentTimelineEventItem,
},
props: {
timelineEventLoading: {
@@ -99,16 +99,21 @@ export default {
<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
+ <ul class="notes main-notes-list">
+ <li
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)"
- @delete="handleDelete(event)"
- />
+ :key="eventIndex"
+ class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
+ >
+ <incident-timeline-event-item
+ :key="event.id"
+ :action="event.action"
+ :occurred-at="event.occurredAt"
+ :note-html="event.noteHtml"
+ :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)"
+ @delete="handleDelete(event)"
+ />
+ </li>
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
index e1946ef4d07..7c2a7878c58 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue
@@ -7,7 +7,7 @@ import getTimelineEvents from './graphql/queries/get_timeline_events.query.graph
import { displayAndLogError } from './utils';
import { timelineTabI18n } from './constants';
-import IncidentTimelineEventForm from './timeline_events_form.vue';
+import CreateTimelineEvent from './create_timeline_event.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue';
export default {
@@ -16,7 +16,7 @@ export default {
GlEmptyState,
GlLoadingIcon,
GlTab,
- IncidentTimelineEventForm,
+ CreateTimelineEvent,
IncidentTimelineEventsList,
},
i18n: timelineTabI18n,
@@ -61,10 +61,10 @@ export default {
this.isEventFormVisible = false;
},
async showEventForm() {
- this.$refs.eventForm.clear();
+ this.$refs.createEventForm.clearForm();
this.isEventFormVisible = true;
await this.$nextTick();
- this.$refs.eventForm.focusDate();
+ this.$refs.createEventForm.focusDate();
},
},
};
@@ -82,14 +82,15 @@ export default {
v-if="hasTimelineEvents"
:timeline-event-loading="timelineEventLoading"
:timeline-events="timelineEvents"
+ @hide-new-timeline-events-form="hideEventForm"
/>
- <incident-timeline-event-form
+ <create-timeline-event
v-show="isEventFormVisible"
- ref="eventForm"
+ ref="createEventForm"
:has-timeline-events="hasTimelineEvents"
class="timeline-event-note timeline-event-note-form"
:class="{ 'gl-pl-0': !hasTimelineEvents }"
- @hide-incident-timeline-event-form="hideEventForm"
+ @hide-new-timeline-events-form="hideEventForm"
/>
<gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
{{ $options.i18n.addEventButton }}
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index 256e3025f19..cf790a11b67 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -11,6 +11,7 @@ export const displayAndLogError = (error) =>
const EVENT_ICONS = {
comment: 'comment',
issues: 'issues',
+ label: 'label',
status: 'status',
default: 'comment',
};
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index da72cbeb856..4046e1ade82 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -189,25 +189,23 @@ export default {
v-if="hasEnvironment"
:href="environmentLink.link"
data-testid="job-environment-link"
- v-text="environmentLink.name"
- />
+ >{{ environmentLink.name }}</gl-link
+ >
</template>
<template #clusterNameOrLink>
<gl-link
v-if="clusterNameOrLink.path"
:href="clusterNameOrLink.path"
data-testid="job-cluster-link"
- v-text="clusterNameOrLink.name"
- />
+ >{{ clusterNameOrLink.name }}</gl-link
+ >
<template v-else>{{ clusterNameOrLink.name }}</template>
</template>
<template #kubernetesNamespace>{{ kubernetesNamespace }}</template>
<template #deploymentLink>
- <gl-link
- :href="deploymentLink.path"
- data-testid="job-deployment-link"
- v-text="deploymentLink.name"
- />
+ <gl-link :href="deploymentLink.path" data-testid="job-deployment-link">{{
+ deploymentLink.name
+ }}</gl-link>
</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index f9e6c64aad1..d5ee3423d70 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -287,6 +287,7 @@ export default {
:is-scroll-top-disabled="isScrollTopDisabled"
:is-job-log-size-visible="isJobLogSizeVisible"
:is-scrolling-down="isScrollingDown"
+ :is-complete="isJobLogComplete"
:job-log="jobLog"
@scrollJobLogTop="scrollTop"
@scrollJobLogBottom="scrollBottom"
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 5e89dd5acc2..e9809ac661b 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui';
-import { scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElement, backOff } from '~/lib/utils/common_utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __, s__, sprintf } from '~/locale';
import HelpPopover from '~/vue_shared/components/help_popover.vue';
@@ -10,6 +10,7 @@ export default {
i18n: {
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
+ scrollToNextFailureButtonLabel: s__('Job|Scroll to next failure'),
showRawButtonLabel: s__('Job|Show complete raw'),
searchPlaceholder: s__('Job|Search job log'),
noResults: s__('Job|No search results found'),
@@ -55,6 +56,10 @@ export default {
type: Boolean,
required: true,
},
+ isComplete: {
+ type: Boolean,
+ required: true,
+ },
jobLog: {
type: Array,
required: true,
@@ -64,6 +69,8 @@ export default {
return {
searchTerm: '',
searchResults: [],
+ failureCount: null,
+ failureIndex: 0,
};
},
computed: {
@@ -72,16 +79,49 @@ export default {
size: numberToHumanSize(this.size),
});
},
- showJobLogSearch() {
- return this.glFeatures.jobLogSearch;
+ showJumpToFailures() {
+ return this.glFeatures.jobLogJumpToFailures;
+ },
+ hasFailures() {
+ return this.failureCount > 0;
+ },
+ shouldDisableJumpToFailures() {
+ return !this.hasFailures;
},
},
+ mounted() {
+ this.checkFailureCount();
+ },
methods: {
+ checkFailureCount() {
+ if (this.glFeatures.jobLogJumpToFailures) {
+ backOff((next, stop) => {
+ this.failureCount = document.querySelectorAll('.term-fg-l-red').length;
+
+ if (this.hasFailures || (this.isComplete && !this.hasFailures)) {
+ stop();
+ } else {
+ next();
+ }
+ });
+ }
+ },
+ handleScrollToNextFailure() {
+ const failures = document.querySelectorAll('.term-fg-l-red');
+ const nextFailure = failures[this.failureIndex];
+
+ if (nextFailure) {
+ nextFailure.scrollIntoView({ block: 'center' });
+ this.failureIndex = (this.failureIndex + 1) % failures.length;
+ }
+ },
handleScrollToTop() {
this.$emit('scrollJobLogTop');
+ this.failureIndex = 0;
},
handleScrollToBottom() {
this.$emit('scrollJobLogBottom');
+ this.failureIndex = 0;
},
searchJobLog() {
this.searchResults = [];
@@ -135,10 +175,10 @@ export default {
};
</script>
<template>
- <div class="top-bar">
+ <div class="top-bar gl-display-flex gl-justify-content-space-between">
<!-- truncate information -->
<div
- class="truncated-info gl-display-none gl-sm-display-block gl-float-left"
+ class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center"
data-testid="log-truncated-info"
>
<template v-if="isJobLogSizeVisible">
@@ -154,25 +194,23 @@ export default {
</div>
<!-- eo truncate information -->
- <div class="controllers gl-float-right">
- <template v-if="showJobLogSearch">
- <gl-search-box-by-click
- v-model="searchTerm"
- class="gl-mr-3"
- :placeholder="$options.i18n.searchPlaceholder"
- data-testid="job-log-search-box"
- @clear="$emit('searchResults', [])"
- @submit="searchJobLog"
- />
+ <div class="controllers">
+ <gl-search-box-by-click
+ v-model="searchTerm"
+ class="gl-mr-3"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-testid="job-log-search-box"
+ @clear="$emit('searchResults', [])"
+ @submit="searchJobLog"
+ />
- <help-popover class="gl-mr-3">
- <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
+ <help-popover class="gl-mr-3">
+ <template #title>{{ $options.i18n.searchPopoverTitle }}</template>
- <p class="gl-mb-0">
- {{ $options.i18n.searchPopoverDescription }}
- </p>
- </help-popover>
- </template>
+ <p class="gl-mb-0">
+ {{ $options.i18n.searchPopoverDescription }}
+ </p>
+ </help-popover>
<!-- links -->
<gl-button
@@ -187,6 +225,18 @@ export default {
<!-- eo links -->
<!-- scroll buttons -->
+ <gl-button
+ v-if="showJumpToFailures"
+ v-gl-tooltip
+ :title="$options.i18n.scrollToNextFailureButtonLabel"
+ :aria-label="$options.i18n.scrollToNextFailureButtonLabel"
+ :disabled="shouldDisableJumpToFailures"
+ class="btn-scroll gl-ml-3"
+ data-testid="job-controller-scroll-to-failure"
+ icon="soft-wrap"
+ @click="handleScrollToNextFailure"
+ />
+
<div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3">
<gl-button
:disabled="isScrollTopDisabled"
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
index 15c4e503685..3b1509e5be5 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
@@ -1,7 +1,6 @@
<script>
import { mapState } from 'vuex';
import { GlBadge } from '@gitlab/ui';
-import { helpPagePath } from '~/helpers/help_page_helper';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -47,11 +46,6 @@ export default {
this.job.coverage,
);
},
- runnerHelpUrl() {
- return helpPagePath('ci/runners/configure_runners.html', {
- anchor: 'set-maximum-job-timeout-for-a-runner',
- });
- },
runnerId() {
const { id, short_sha: token, description } = this.job.runner;
@@ -85,6 +79,7 @@ export default {
TAGS: __('Tags:'),
TIMEOUT: __('Timeout'),
},
+ RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
};
</script>
@@ -101,7 +96,7 @@ export default {
<detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" />
<detail-row
v-if="hasTimeout"
- :help-url="runnerHelpUrl"
+ :help-url="$options.RUNNER_HELP_URL"
:value="timeout"
data-testid="job-timeout"
:title="$options.i18n.TIMEOUT"
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 5b1032c6448..98b51e8c2c4 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -1,7 +1,6 @@
query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) {
project(fullPath: $fullPath) {
id
- __typename
jobs(after: $after, first: $first, statuses: $statuses) {
count
pageInfo {
@@ -9,15 +8,12 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo
hasNextPage
hasPreviousPage
startCursor
- __typename
}
nodes {
- __typename
artifacts {
nodes {
downloadPath
fileType
- __typename
}
}
allowFailure
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 b3db5a94ac5..c2f460cb647 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -3,7 +3,6 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from
import { __ } from '~/locale';
import createFlash from '~/flash';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
-import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
@@ -108,16 +107,7 @@ export default {
}
},
},
- mounted() {
- eventHub.$on('jobActionPerformed', this.handleJobAction);
- },
- beforeDestroy() {
- eventHub.$off('jobActionPerformed', this.handleJobAction);
- },
methods: {
- handleJobAction() {
- this.$apollo.queries.jobs.refetch({ statuses: this.scope });
- },
fetchJobsByStatus(scope) {
this.infiniteScrollingTriggered = false;
@@ -169,6 +159,7 @@ export default {
v-if="shouldShowAlert"
class="gl-mt-2"
variant="danger"
+ data-testid="jobs-table-error-alert"
dismissible
@dismiss="isAlertDismissed = true"
>
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 9d8ee165df2..3e5396c5bd8 100644
--- a/app/assets/javascripts/labels/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -438,7 +438,7 @@ export default class LabelsSelect {
[
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
"<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
- '<br />',
+ '<br>',
'<%= escapeStr(label.description) %>',
'<% } else { %>',
'<%= escapeStr(label.description) %>',
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index a01c6df0003..3e28ca2a0f7 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -33,6 +33,22 @@ const removeUnsafeHref = (node, attr) => {
};
/**
+ * Appends 'noopener' & 'noreferrer' to rel
+ * attr values to prevent reverse tabnabbing.
+ *
+ * @param {String} rel
+ * @returns {String}
+ */
+const appendSecureRelValue = (rel) => {
+ const attributes = new Set(rel ? rel.toLowerCase().split(' ') : []);
+
+ attributes.add('noopener');
+ attributes.add('noreferrer');
+
+ return Array.from(attributes).join(' ');
+};
+
+/**
* Sanitize icons' <use> tag attributes, to safely include
* svgs such as in:
*
@@ -57,4 +73,25 @@ addHook('afterSanitizeAttributes', (node) => {
}
});
+const TEMPORARY_ATTRIBUTE = 'data-temp-href-target';
+
+addHook('beforeSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute('target')) {
+ node.setAttribute(TEMPORARY_ATTRIBUTE, node.getAttribute('target'));
+ }
+});
+
+addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'A' && node.hasAttribute(TEMPORARY_ATTRIBUTE)) {
+ node.setAttribute('target', node.getAttribute(TEMPORARY_ATTRIBUTE));
+ node.removeAttribute(TEMPORARY_ATTRIBUTE);
+ if (node.getAttribute('target') === '_blank') {
+ const rel = node.getAttribute('rel');
+ node.setAttribute('rel', appendSecureRelValue(rel));
+ }
+ }
+});
+
export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config });
+
+export { isValidAttribute } from 'dompurify';
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index 92118c8929f..eaf653e9924 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -1,10 +1,15 @@
import { pick } from 'lodash';
+import normalize from 'mdurl/encode';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
+import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
+const skipFrontmatterHandler = (language) => (h, node) =>
+ h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]);
+
const skipRenderingHandlers = {
footnoteReference: (h, node) =>
h(node.position, 'footnoteReference', { identifier: node.identifier, label: node.label }, []),
@@ -19,12 +24,57 @@ const skipRenderingHandlers = {
h(node.position, 'codeBlock', { language: node.lang, meta: node.meta }, [
{ type: 'text', value: node.value },
]),
+ definition: (h, node) => {
+ const title = node.title ? ` "${node.title}"` : '';
+
+ return h(
+ node.position,
+ 'referenceDefinition',
+ { identifier: node.identifier, url: node.url, title: node.title },
+ [{ type: 'text', value: `[${node.identifier}]: ${node.url}${title}` }],
+ );
+ },
+ linkReference: (h, node) => {
+ const definition = h.definition(node.identifier);
+
+ return h(
+ node.position,
+ 'a',
+ {
+ href: normalize(definition.url ?? ''),
+ identifier: node.identifier,
+ isReference: 'true',
+ title: definition.title,
+ },
+ all(h, node),
+ );
+ },
+ imageReference: (h, node) => {
+ const definition = h.definition(node.identifier);
+
+ return h(
+ node.position,
+ 'img',
+ {
+ src: normalize(definition.url ?? ''),
+ alt: node.alt,
+ identifier: node.identifier,
+ isReference: 'true',
+ title: definition.title,
+ },
+ all(h, node),
+ );
+ },
+ toml: skipFrontmatterHandler('toml'),
+ yaml: skipFrontmatterHandler('yaml'),
+ json: skipFrontmatterHandler('json'),
};
const createParser = ({ skipRendering = [] }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
+ .use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }])
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index cfcce234bfb..98e45f95b38 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -221,6 +221,7 @@ export default (resolvers = {}, config = {}) => {
ac = new ApolloClient({
typeDefs,
link: appLink,
+ connectToDevTools: process.env.NODE_ENV !== 'production',
cache: new InMemoryCache({
...cacheConfig,
typePolicies: {
diff --git a/app/assets/javascripts/lib/markdown_it.js b/app/assets/javascripts/lib/markdown_it.js
new file mode 100644
index 00000000000..0b7a553737d
--- /dev/null
+++ b/app/assets/javascripts/lib/markdown_it.js
@@ -0,0 +1,11 @@
+/**
+ * This module replaces markdown-it with an empty function. markdown-it
+ * is a dependency of the prosemirror-markdown package. prosemirror-markdown
+ * uses markdown-it to parse markdown and produce an AST. However, the
+ * features that use prosemirror-markdown in the GitLab application do not
+ * require markdown parsing.
+ *
+ * Replacing markdown-it with this empty function removes unnecessary javascript
+ * from the production builds.
+ */
+export default () => {};
diff --git a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
index 6473683c3af..5e621ca3216 100644
--- a/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
+++ b/app/assets/javascripts/lib/prosemirror_markdown_serializer.js
@@ -1,3 +1 @@
-// Import from `src/to_markdown` to avoid unnecessary bundling of unused libs
-// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79859
-export * from 'prosemirror-markdown/src/to_markdown';
+export { MarkdownSerializer, defaultMarkdownSerializer } from 'prosemirror-markdown';
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 243de48948c..9f4e12a3010 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -4,12 +4,14 @@ import Shortcuts from '~/behaviors/shortcuts/shortcuts';
import { insertText } from '~/lib/utils/common_utils';
const LINK_TAG_PATTERN = '[{text}](url)';
+const INDENT_CHAR = ' ';
+const INDENT_LENGTH = 2;
// at the start of a line, find any amount of whitespace followed by
// a bullet point character (*+-) and an optional checkbox ([ ] [x])
// OR a number with a . after it and an optional checkbox ([ ] [x])
// followed by one or more whitespace characters
-const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/;
+const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX~\s])\])?\s)(?<content>.)?/;
// detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>)
const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/;
@@ -24,33 +26,104 @@ function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
-function lineBefore(text, textarea, trimNewlines = true) {
- let split = text.substring(0, textarea.selectionStart);
-
- if (trimNewlines) {
- split = split.trim();
- }
+/**
+ * Returns the line of text that is before the first line
+ * of the current selection
+ *
+ * @param {String} text - the text of the targeted text area
+ * @param {Object} textArea - the targeted text area
+ * @returns {String}
+ */
+function lineBeforeSelection(text, textArea) {
+ let split = text.substring(0, textArea.selectionStart);
split = split.split('\n');
- return split[split.length - 1];
-}
+ // Last item, at -1, is the line where the start of selection is.
+ // Line before selection is therefore at -2
+ const lineBefore = split[split.length - 2];
-function lineAfter(text, textarea, trimNewlines = true) {
- let split = text.substring(textarea.selectionEnd);
+ return lineBefore === undefined ? '' : lineBefore;
+}
- if (trimNewlines) {
- split = split.trim();
- } else {
- // remove possible leading newline to get at the real line
- split = split.replace(/^\n/, '');
- }
+/**
+ * Returns the line of text that is after the last line
+ * of the current selection
+ *
+ * @param {String} text - the text of the targeted text area
+ * @param {Object} textArea - the targeted text area
+ * @returns {String}
+ */
+function lineAfterSelection(text, textArea) {
+ let split = text.substring(textArea.selectionEnd);
+ // remove possible leading newline to get at the real line
+ split = split.replace(/^\n/, '');
split = split.split('\n');
return split[0];
}
+/**
+ * Returns the text lines that encompass the current selection
+ *
+ * @param {Object} textArea - the targeted text area
+ * @returns {Object}
+ */
+function linesFromSelection(textArea) {
+ const text = textArea.value;
+ const { selectionStart, selectionEnd } = textArea;
+
+ let startPos = text[selectionStart] === '\n' ? selectionStart - 1 : selectionStart;
+ startPos = text.lastIndexOf('\n', startPos) + 1;
+
+ let endPos = selectionEnd === selectionStart ? selectionEnd : selectionEnd - 1;
+ endPos = text.indexOf('\n', endPos);
+ if (endPos < 0) endPos = text.length;
+
+ const selectedRange = text.substring(startPos, endPos);
+ const lines = selectedRange.split('\n');
+
+ return {
+ lines,
+ selectionStart,
+ selectionEnd,
+ startPos,
+ endPos,
+ };
+}
+
+/**
+ * Set the selection of a textarea such that it maintains the
+ * previous selection before the lines were indented/outdented
+ *
+ * @param {Object} textArea - the targeted text area
+ * @param {Number} selectionStart - start position of original selection
+ * @param {Number} selectionEnd - end position of original selection
+ * @param {Number} lineStart - start pos of first line
+ * @param {Number} firstLineChange - number of characters changed on first line
+ * @param {Number} totalChanged - total number of characters changed
+ */
+function setNewSelectionRange(
+ textArea,
+ selectionStart,
+ selectionEnd,
+ lineStart,
+ firstLineChange,
+ totalChanged,
+) {
+ let newStart = Math.max(lineStart, selectionStart + firstLineChange);
+ let newEnd = Math.max(lineStart, selectionEnd + totalChanged);
+
+ if (selectionStart === selectionEnd) {
+ newEnd = newStart;
+ } else if (selectionStart === lineStart) {
+ newStart = lineStart;
+ }
+
+ textArea.setSelectionRange(newStart, newEnd);
+}
+
function convertMonacoSelectionToAceFormat(sel) {
return {
start: {
@@ -93,7 +166,8 @@ function editorBlockTagText(text, blockTag, selected, editor) {
function blockTagText(text, textArea, blockTag, selected) {
const shouldRemoveBlock =
- lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
+ lineBeforeSelection(text, textArea) === blockTag &&
+ lineAfterSelection(text, textArea) === blockTag;
if (shouldRemoveBlock) {
// To remove the block tag we have to select the line before & after
@@ -312,9 +386,100 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo
});
}
+/**
+ * Indents selected lines to the right by 2 spaces
+ *
+ * @param {Object} textArea - the targeted text area
+ */
+function indentLines(textArea) {
+ const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
+ const shiftedLines = [];
+ let totalAdded = 0;
+
+ textArea.setSelectionRange(startPos, endPos);
+
+ lines.forEach((line) => {
+ line = INDENT_CHAR.repeat(INDENT_LENGTH) + line;
+ totalAdded += INDENT_LENGTH;
+
+ shiftedLines.push(line);
+ });
+
+ const textToInsert = shiftedLines.join('\n');
+
+ insertText(textArea, textToInsert);
+ setNewSelectionRange(textArea, selectionStart, selectionEnd, startPos, INDENT_LENGTH, totalAdded);
+}
+
+/**
+ * Outdents selected lines to the left by 2 spaces
+ *
+ * @param {Object} textArea - the targeted text area
+ */
+function outdentLines(textArea) {
+ const { lines, selectionStart, selectionEnd, startPos, endPos } = linesFromSelection(textArea);
+ const shiftedLines = [];
+ let totalRemoved = 0;
+ let removedFromFirstline = -1;
+ let removedFromLine = 0;
+
+ textArea.setSelectionRange(startPos, endPos);
+
+ lines.forEach((line) => {
+ removedFromLine = 0;
+
+ if (line.length > 0) {
+ // need to count how many spaces are actually removed, so can't use `replace`
+ while (removedFromLine < INDENT_LENGTH && line[removedFromLine] === INDENT_CHAR) {
+ removedFromLine += 1;
+ }
+
+ if (removedFromLine > 0) {
+ line = line.slice(removedFromLine);
+ totalRemoved += removedFromLine;
+ }
+ }
+
+ if (removedFromFirstline === -1) removedFromFirstline = removedFromLine;
+ shiftedLines.push(line);
+ });
+
+ const textToInsert = shiftedLines.join('\n');
+
+ if (totalRemoved > 0) insertText(textArea, textToInsert);
+
+ setNewSelectionRange(
+ textArea,
+ selectionStart,
+ selectionEnd,
+ startPos,
+ -removedFromFirstline,
+ -totalRemoved,
+ );
+}
+
+function handleIndentOutdent(e, textArea) {
+ if (e.altKey || e.ctrlKey || e.shiftKey) return;
+ if (!e.metaKey) return;
+
+ switch (e.key) {
+ case ']':
+ e.preventDefault();
+ indentLines(textArea);
+ break;
+ case '[':
+ e.preventDefault();
+ outdentLines(textArea);
+ break;
+ default:
+ break;
+ }
+}
+
/* eslint-disable @gitlab/require-i18n-strings */
function handleSurroundSelectedText(e, textArea) {
if (!gon.markdown_surround_selection) return;
+ if (e.metaKey) return;
if (textArea.selectionStart === textArea.selectionEnd) return;
const keys = {
@@ -348,13 +513,13 @@ function handleSurroundSelectedText(e, textArea) {
/**
* Returns the content for a new line following a list item.
*
- * @param {Object} result - regex match of the current line
- * @param {Object?} nextLineResult - regex match of the next line
+ * @param {Object} listLineMatch - regex match of the current line
+ * @param {Object?} nextLineMatch - regex match of the next line
* @returns string with the new list item
*/
-function continueOlText(result, nextLineResult) {
- const { indent, leader } = result.groups;
- const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
+function continueOlText(listLineMatch, nextLineMatch) {
+ const { indent, leader } = listLineMatch.groups;
+ const { indent: nextIndent, isOl: nextIsOl } = nextLineMatch?.groups ?? {};
const [numStr, postfix = ''] = leader.split('.');
@@ -368,20 +533,20 @@ function handleContinueList(e, textArea) {
if (!(e.key === 'Enter')) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (textArea.selectionStart !== textArea.selectionEnd) return;
- // prevent unintended line breaks were inserted using Japanese IME on MacOS
+ // prevent unintended line breaks inserted using Japanese IME on MacOS
if (compositioningNoteText) return;
- const currentLine = lineBefore(textArea.value, textArea, false);
- const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
+ const firstSelectedLine = linesFromSelection(textArea).lines[0];
+ const listLineMatch = firstSelectedLine.match(LIST_LINE_HEAD_PATTERN);
- if (result) {
- const { leader, indent, content, isOl } = result.groups;
- const prevLineEmpty = !content;
+ if (listLineMatch) {
+ const { leader, indent, content, isOl } = listLineMatch.groups;
+ const emptyListItem = !content;
- if (prevLineEmpty) {
- // erase previous empty list item - select the text and allow the
- // natural line feed erase the text
- textArea.selectionStart = textArea.selectionStart - result[0].length;
+ if (emptyListItem) {
+ // erase empty list item - select the text and allow the
+ // natural line feed to erase the text
+ textArea.selectionStart = textArea.selectionStart - listLineMatch[0].length;
return;
}
@@ -389,17 +554,17 @@ function handleContinueList(e, textArea) {
// Behaviors specific to either `ol` or `ul`
if (isOl) {
- const nextLine = lineAfter(textArea.value, textArea, false);
- const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
+ const nextLine = lineAfterSelection(textArea.value, textArea);
+ const nextLineMatch = nextLine.match(LIST_LINE_HEAD_PATTERN);
- itemToInsert = continueOlText(result, nextLineResult);
+ itemToInsert = continueOlText(listLineMatch, nextLineMatch);
} else {
- if (currentLine.match(HR_PATTERN)) return;
+ if (firstSelectedLine.match(HR_PATTERN)) return;
itemToInsert = `${indent}${leader}`;
}
- itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]');
+ itemToInsert = itemToInsert.replace(/\[[x~]\]/i, '[ ]');
e.preventDefault();
@@ -419,6 +584,7 @@ export function keypressNoteText(e) {
if ($(textArea).atwho?.('isSelecting')) return;
+ handleIndentOutdent(e, textArea);
handleContinueList(e, textArea);
handleSurroundSelectedText(e, textArea);
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index ff60fd2aecb..ca90eee69c7 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -397,6 +397,7 @@ export function relativePathToAbsolute(path, basePath) {
const absolute = isAbsolute(basePath);
const base = absolute ? basePath : `file:///${basePath}`;
const url = new URL(path, base);
+ url.pathname = url.pathname.replace(/\/\/+/g, '/');
return absolute ? url.href : decodeURIComponent(url.pathname);
}
@@ -668,3 +669,27 @@ export function constructWebIDEPath({
webIDEUrl(`/${sourceProjectFullPath}/merge_requests/${iid}`),
);
}
+
+/**
+ * Examples
+ *
+ * http://gitlab.com => gitlab.com
+ * https://gitlab.com => gitlab.com
+ *
+ * @param {String} url
+ * @returns A url without a protocol / scheme
+ */
+export const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, '');
+
+/**
+ * Examples
+ *
+ * https://www.gitlab.com/path/ => https://www.gitlab.com/path
+ * https://www.gitlab.com/?query=search => https://www.gitlab.com?query=search
+ * https://www.gitlab.com/#fragment => https://www.gitlab.com#fragment
+ *
+ * @param {String} url
+ * @returns A URL that does not have a path that ends with slash
+ */
+export const removeLastSlashInUrlPath = (url) =>
+ url.replace(/\/$/, '').replace(/\/(\?|#){1}([^/]*)$/, '$1$2');
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js
index 9270d388342..48f34624140 100644
--- a/app/assets/javascripts/lib/utils/yaml.js
+++ b/app/assets/javascripts/lib/utils/yaml.js
@@ -16,18 +16,17 @@ function getPath(ancestry) {
function getFirstChildNode(collection) {
let firstChildKey;
- let type;
- switch (collection.constructor.name) {
- case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings
- return collection.items.find((i) => isNode(i));
- case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings
- firstChildKey = collection.items[0]?.key;
- if (!firstChildKey) return undefined;
- return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
- default:
- type = collection.constructor?.name || typeof collection;
- throw Error(`Cannot identify a child Node for type ${type}`);
+ if (isSeq(collection)) {
+ return collection.items.find((i) => isNode(i));
}
+ if (isMap(collection)) {
+ firstChildKey = collection.items[0]?.key;
+ if (!firstChildKey) return undefined;
+ return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey);
+ }
+ throw Error(
+ `Cannot identify a child Node for Collection. Expecting a YAMLMap or a YAMLSeq. Got: ${collection}`,
+ );
}
function moveMetaPropsToFirstChildNode(collection) {
diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js
index 244adca86c9..6d799d30b4b 100644
--- a/app/assets/javascripts/linked_resources/index.js
+++ b/app/assets/javascripts/linked_resources/index.js
@@ -1,25 +1,34 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import ResourceLinksBlock from 'ee_component/linked_resources/components/resource_links_block.vue';
+import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
+Vue.use(VueApollo);
+
export default function initLinkedResources() {
const linkedResourcesRootElement = document.querySelector('.js-linked-resources-root');
if (linkedResourcesRootElement) {
const { issuableId, canAddResourceLinks, helpPath } = linkedResourcesRootElement.dataset;
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
// eslint-disable-next-line no-new
new Vue({
el: linkedResourcesRootElement,
name: 'LinkedResourcesRoot',
+ apolloProvider,
components: {
resourceLinksBlock: ResourceLinksBlock,
},
render: (createElement) =>
createElement('resource-links-block', {
props: {
- issuableId,
helpPath,
+ issuableId: parseInt(issuableId, 10),
canAddResourceLinks: parseBoolean(canAddResourceLinks),
},
}),
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 349a28ace52..c16ed68096d 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -134,12 +134,6 @@ function deferredInitialisation() {
// Adding a helper class to activate animations only after all is rendered
setTimeout(() => $body.addClass('page-initialised'), 1000);
-
- if (window.gon?.features?.mrAttentionRequests) {
- import('~/attention_requests')
- .then((module) => module.default())
- .catch(() => {});
- }
}
// header search vue component bootstrap
diff --git a/app/assets/javascripts/members/components/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
index 971b1a8435e..ecc2ed82ad0 100644
--- a/app/assets/javascripts/members/components/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -1,5 +1,5 @@
<script>
-import { MEMBER_TYPES } from '../../constants';
+import { MEMBER_TYPES, EE_ACTION_BUTTONS } from 'ee_else_ce/members/constants';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
@@ -12,6 +12,8 @@ export default {
GroupActionButtons,
InviteActionButtons,
AccessRequestActionButtons,
+ BannedActionButtons: () =>
+ import('ee_component/members/components/action_buttons/banned_action_buttons.vue'),
},
props: {
member: {
@@ -42,6 +44,7 @@ export default {
[MEMBER_TYPES.group]: 'group-action-buttons',
[MEMBER_TYPES.invite]: 'invite-action-buttons',
[MEMBER_TYPES.accessRequest]: 'access-request-action-buttons',
+ ...EE_ACTION_BUTTONS,
};
return dictionary[this.memberType];
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 2fe816c7ea2..93d113d1afe 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -9,6 +9,8 @@ export const EE_APP_OPTIONS = {};
// Overridden in EE
export const EE_TABS = [];
+export const EE_ACTION_BUTTONS = {};
+
export const FIELD_KEY_ACCOUNT = 'account';
export const FIELD_KEY_SOURCE = 'source';
export const FIELD_KEY_GRANTED = 'granted';
diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js
index cf7a7c304e3..ca0649cc048 100644
--- a/app/assets/javascripts/merge_conflicts/utils.js
+++ b/app/assets/javascripts/merge_conflicts/utils.js
@@ -61,7 +61,7 @@ export const decorateLineForInlineView = (line, id, conflict) => {
};
export const getLineForParallelView = (line, id, lineType, isHead) => {
- const { old_line, new_line, rich_text } = line;
+ const { old_line: oldLine, new_line: newLine, rich_text: richText } = line;
const hasConflict = lineType === 'conflict';
return {
@@ -71,10 +71,9 @@ export const getLineForParallelView = (line, id, lineType, isHead) => {
isHead: hasConflict && isHead,
isOrigin: hasConflict && !isHead,
hasMatch: lineType === 'match',
- // eslint-disable-next-line camelcase
- lineNumber: isHead ? new_line : old_line,
+ lineNumber: isHead ? newLine : oldLine,
section: isHead ? 'head' : 'origin',
- richText: rich_text,
+ richText,
isSelected: false,
isUnselected: false,
};
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index a95b143920b..b74da3ee89b 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -237,10 +237,10 @@ export default {
recentDeployments() {
return this.deploymentData.reduce((acc, deployment) => {
if (deployment.created_at >= this.earliestDatapoint) {
- const { id, created_at, sha, ref, tag } = deployment;
+ const { id, created_at: createdAt, sha, ref, tag } = deployment;
acc.push({
id,
- createdAt: created_at,
+ createdAt,
sha,
commitUrl: `${this.projectPath}/-/commit/${sha}`,
tag,
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 7f75a501635..02a2435d575 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -16,8 +16,8 @@ export const gqClient = createGqClient(
);
/**
- * Metrics loaded from project-defined dashboards do not have a metric_id.
- * This method creates a unique ID combining metric_id and id, if either is present.
+ * Metrics loaded from project-defined dashboards do not have a metricId.
+ * This method creates a unique ID combining metricId and id, if either is present.
* This is hopefully a temporary solution until BE processes metrics before passing to FE
*
* Related:
@@ -25,12 +25,11 @@ export const gqClient = createGqClient(
* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447
*
* @param {Object} metric - metric
- * @param {Number} metric.metric_id - Database metric id
+ * @param {Number} metric.metricId - Database metric id
* @param {String} metric.id - User-defined identifier
* @returns {Object} - normalized metric with a uniqueID
*/
-// eslint-disable-next-line camelcase
-export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`;
+export const uniqMetricsId = ({ metricId, id }) => `${metricId || NOT_IN_DB_PREFIX}_${id}`;
/**
* Project path has a leading slash that doesn't work well
@@ -100,19 +99,28 @@ export const parseAnnotationsResponse = (response) => {
* @returns {Object}
*/
const mapToMetricsViewModel = (metrics) =>
- metrics.map(({ label, id, metric_id, query_range, prometheus_endpoint_path, ...metric }) => ({
- label,
- queryRange: query_range,
- prometheusEndpointPath: prometheus_endpoint_path,
- metricId: uniqMetricsId({ metric_id, id }),
+ metrics.map(
+ ({
+ label,
+ id,
+ metric_id: metricId,
+ query_range: queryRange,
+ prometheus_endpoint_path: prometheusEndpointPath,
+ ...metric
+ }) => ({
+ label,
+ queryRange,
+ prometheusEndpointPath,
+ metricId: uniqMetricsId({ metricId, id }),
- // metric data
- loading: false,
- result: null,
- state: null,
+ // metric data
+ loading: false,
+ result: null,
+ state: null,
- ...metric,
- }));
+ ...metric,
+ }),
+ );
/**
* Maps X-axis view model
@@ -169,26 +177,26 @@ export const mapPanelToViewModel = ({
id = null,
title = '',
type,
- x_axis = {}, // eslint-disable-line camelcase
- x_label,
- y_label,
- y_axis = {}, // eslint-disable-line camelcase
+ x_axis: xAxisBase = {},
+ x_label: xLabel,
+ y_label: yLabel,
+ y_axis: yAxisBase = {},
field,
metrics = [],
links = [],
- min_value,
- max_value,
+ min_value: minValue,
+ max_value: maxValue,
split,
thresholds,
format,
}) => {
// Both `x_axis.name` and `x_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/210521
- const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line camelcase
+ const xAxis = mapXAxisToViewModel({ name: xLabel, ...xAxisBase });
// Both `y_axis.name` and `y_label` are supported for now
// https://gitlab.com/gitlab-org/gitlab/issues/208385
- const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line camelcase
+ const yAxis = mapYAxisToViewModel({ name: yLabel, ...yAxisBase });
return {
id,
@@ -199,8 +207,8 @@ export const mapPanelToViewModel = ({
yAxis,
xAxis,
field,
- minValue: min_value,
- maxValue: max_value,
+ minValue,
+ maxValue,
split,
thresholds,
format,
@@ -295,13 +303,13 @@ export const mapToDashboardViewModel = ({
dashboard = '',
templating = {},
links = [],
- panel_groups = [], // eslint-disable-line camelcase
+ panel_groups: panelGroups = [],
}) => {
return {
dashboard,
variables: mergeURLVariables(parseTemplatingVariables(templating.variables)),
links: links.map(mapLinksToViewModel),
- panelGroups: panel_groups.map(mapToPanelGroupViewModel),
+ panelGroups: panelGroups.map(mapToPanelGroupViewModel),
};
};
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index d85fd10be45..cf24d18c7b6 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -44,7 +44,13 @@ export default () => {
},
watch: {
discussionTabCounter() {
- this.updateDiscussionTabCounter();
+ if (window.gon?.features?.paginatedMrDiscussions) {
+ if (this.$store.state.notes.doneFetchingBatchDiscussions) {
+ this.updateDiscussionTabCounter();
+ }
+ } else {
+ this.updateDiscussionTabCounter();
+ }
},
isShowTabActive: {
handler(newVal) {
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 073b27605bb..8351ae7ced6 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,6 +1,6 @@
<script>
import katex from 'katex';
-import marked from 'marked';
+import { marked } from 'marked';
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
import { hasContent, markdownConfig } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 0e213028c7c..3cf47f42e0c 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -1,19 +1,17 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { escape } from 'lodash';
import { mapActions } from 'vuex';
-
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import noteEditedText from './note_edited_text.vue';
import noteHeader from './note_header.vue';
export default {
name: 'DiffDiscussionHeader',
components: {
- userAvatarLink,
+ GlAvatar,
+ GlAvatarLink,
noteEditedText,
noteHeader,
},
@@ -86,6 +84,9 @@ export default {
return sprintf(text, { commitDisplay, linkStart, linkEnd }, false);
},
+ adaptiveAvatarSize() {
+ return { default: 24, md: 32 };
+ },
},
methods: {
...mapActions(['toggleDiscussion']),
@@ -100,16 +101,11 @@ export default {
<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"
+ class="timeline-icon gl-align-self-start gl-flex-shrink-0 gl-flex-shrink gl-mx-3 gl-md-ml-2 gl-md-mr-5"
>
- <user-avatar-link
- v-if="author"
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :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) */"
- />
+ <gl-avatar-link v-if="author" :href="author.path">
+ <gl-avatar :src="author.avatar_url" :alt="author.name" :size="adaptiveAvatarSize" />
+ </gl-avatar-link>
</div>
<div class="timeline-content w-100">
<note-header
@@ -127,14 +123,14 @@ export default {
:edited-at="discussion.resolved_at"
:edited-by="discussion.resolved_by"
:action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline"
+ class-name="discussion-headline-light js-discussion-headline gl-pl-2"
/>
<note-edited-text
v-else-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
:action-text="__('Last updated')"
- class-name="discussion-headline-light js-discussion-headline"
+ class-name="discussion-headline-light js-discussion-headline gl-pl-2"
/>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 10e3f57a56d..c7f293a219a 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -170,7 +170,7 @@ export default {
return this.targetType === 'issue';
},
canAssign() {
- return this.getNoteableData.current_user?.can_update && this.isIssue;
+ return this.getNoteableData.current_user?.can_set_issue_metadata && this.isIssue;
},
displayAuthorBadgeText() {
return sprintf(__('This user is the author of this %{noteable}.'), {
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index cc74c2ee605..f1c41eea428 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -8,7 +8,6 @@ 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';
@@ -55,11 +54,6 @@ export default {
required: false,
default: '',
},
- isInternalNote: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
...mapGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
@@ -101,12 +95,6 @@ export default {
return escape(suggestion);
},
- internalNoteContainerClasses() {
- if (this.isInternalNote && !this.isEditing) {
- return INTERNAL_NOTE_CLASSES;
- }
- return '';
- },
},
mounted() {
this.renderGFM();
@@ -179,54 +167,52 @@ export default {
}"
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>
+ <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"
+ />
<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 a4cd20e6db8..30579a8eb0d 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -251,8 +251,10 @@ export default {
}
},
cancelHandler(shouldConfirm = false) {
- // Sends information about confirm message and if the textarea has changed
- this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
+ // check if any dropdowns are active before sending the cancelation event
+ if (!this.$refs.textarea.classList.contains('at-who-active')) {
+ this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
+ }
},
onInput() {
if (this.isSubmittingWithKeydown) {
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 095ab5ddb0f..875cfff74fe 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -406,7 +406,7 @@ export default {
<template>
<timeline-entry-item
:id="noteAnchorId"
- :class="classNameBindings"
+ :class="{ ...classNameBindings, 'internal-note': note.confidential }"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
class="note note-wrapper"
@@ -506,7 +506,6 @@ export default {
ref="noteBody"
:note="note"
:can-edit="note.current_user.can_edit"
- :is-internal-note="note.confidential"
:line="line"
:file="diffFile"
:is-editing="isEditing"
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 3317f4e2383..a5f459c8910 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -51,5 +51,3 @@ 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/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 39d0a46d6d0..0823eacf1b7 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -7,7 +7,7 @@ import * as utils from './utils';
export default {
[types.ADD_NEW_NOTE](state, data) {
const note = data.discussion ? data.discussion.notes[0] : data;
- const { discussion_id, type } = note;
+ const { discussion_id: discussionId, type } = note;
const [exists] = state.discussions.filter((n) => n.id === note.discussion_id);
const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE;
@@ -17,9 +17,9 @@ export default {
if (!discussion) {
discussion = {
expanded: true,
- id: discussion_id,
+ id: discussionId,
individual_note: !isDiscussion,
- reply_id: discussion_id,
+ reply_id: discussionId,
};
if (isDiscussion && isInMRPage()) {
diff --git a/app/assets/javascripts/notes/utils.js b/app/assets/javascripts/notes/utils.js
index ec18a570960..14e97fcef46 100644
--- a/app/assets/javascripts/notes/utils.js
+++ b/app/assets/javascripts/notes/utils.js
@@ -1,5 +1,5 @@
/* eslint-disable @gitlab/require-i18n-strings */
-import marked from 'marked';
+import { marked } from 'marked';
import { sanitize } from '~/lib/dompurify';
import { markdownConfig } from '~/lib/utils/text_utility';
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 bfa99c01c3f..ce221a274c9 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
@@ -60,7 +60,7 @@ export default {
return this.$options.i18n[`CLEANUP_STATUS_${this.status}`];
},
calculatedTimeTilNextRun() {
- return timeTilRun(this.expirationPolicy?.next_run);
+ return timeTilRun(this.expirationPolicy?.next_run_at);
},
expireIconName() {
return this.failedDelete ? 'expire' : 'clock';
@@ -90,9 +90,9 @@ export default {
{{ statusText }}
</span>
<gl-icon
- v-if="failedDelete"
+ v-if="failedDelete && calculatedTimeTilNextRun"
:id="iconId"
- :size="14"
+ :size="16"
class="gl-text-gray-500"
data-testid="extra-info"
name="information-o"
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 aecc0bf92ea..80bca536b7c 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
@@ -95,7 +95,7 @@ export default {
if (this.showFullPath) {
return this.item.path;
}
- const projectPath = this.item?.project?.path ?? '';
+ const projectPath = this.item?.project?.path?.toLowerCase() ?? '';
if (this.item.name) {
return joinPaths(projectPath, this.item.name);
}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index 1faff1ff4de..45dc217b9e3 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -39,7 +39,7 @@ export default {
directives: {
GlModalDirective,
},
- inject: ['groupPath', 'groupId', 'noManifestsIllustration'],
+ inject: ['groupPath', 'groupId', 'noManifestsIllustration', 'canClearCache'],
i18n: {
proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
@@ -114,7 +114,7 @@ export default {
);
},
showDeleteDropdown() {
- return this.group.dependencyProxyManifests?.nodes.length > 0;
+ return this.group.dependencyProxyManifests?.nodes.length > 0 && this.canClearCache;
},
showDependencyProxyImagePrefix() {
return this.group.dependencyProxyImagePrefix?.length > 0;
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
index 14789aafdb7..428d6d6cd75 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import app from '~/packages_and_registries/dependency_proxy/app.vue';
import { apolloProvider } from '~/packages_and_registries/dependency_proxy/graphql';
import Translate from '~/vue_shared/translate';
@@ -10,12 +11,15 @@ export const initDependencyProxyApp = () => {
if (!el) {
return null;
}
- const { ...dataset } = el.dataset;
+ const { groupPath, groupId, noManifestsIllustration, canClearCache } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
- ...dataset,
+ groupPath,
+ groupId,
+ noManifestsIllustration,
+ canClearCache: parseBoolean(canClearCache),
},
render(createElement) {
return createElement(app);
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
index 408d34fbe93..51a38c434cb 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js
@@ -26,8 +26,7 @@ export const receivePackagesListSuccess = ({ commit }, { data, headers }) => {
export const requestPackagesList = ({ dispatch, state }, params = {}) => {
dispatch('setLoading', true);
- // eslint-disable-next-line camelcase
- const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params;
+ const { page = DEFAULT_PAGE, per_page: perPage = DEFAULT_PAGE_SIZE } = params;
const { sort, orderBy } = state.sorting;
const type = state.config.forceTerraform
? TERRAFORM_SEARCH_TYPE
@@ -38,7 +37,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => {
const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages';
return Api[apiMethod](state.config.resourceId, {
- params: { page, per_page, sort, order_by: orderBy, ...packageFilters },
+ params: { page, per_page: perPage, sort, order_by: orderBy, ...packageFilters },
})
.then(({ data, headers }) => {
dispatch('receivePackagesListSuccess', { data, headers });
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 a049b0eff8d..b872294d2cf 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,14 +1,16 @@
<script>
-import { GlLink, GlTableLite, GlDropdownItem, GlDropdown, GlIcon, GlButton } from '@gitlab/ui';
+import { GlLink, GlTable, GlDropdownItem, GlDropdown, GlButton, GlFormCheckbox } from '@gitlab/ui';
import { last } from 'lodash';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { __ } from '~/locale';
+import { __, s__ } from '~/locale';
import FileSha from '~/packages_and_registries/package_registry/components/details/file_sha.vue';
import Tracking from '~/tracking';
import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
+ REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
+ SELECT_PACKAGE_FILE_TRACKING_ACTION,
TRACKING_LABEL_PACKAGE_ASSET,
TRACKING_ACTION_EXPAND_PACKAGE_ASSET,
} from '~/packages_and_registries/package_registry/constants';
@@ -17,10 +19,10 @@ export default {
name: 'PackageFiles',
components: {
GlLink,
- GlTableLite,
- GlIcon,
+ GlTable,
GlDropdown,
GlDropdownItem,
+ GlFormCheckbox,
GlButton,
FileIcon,
TimeAgoTooltip,
@@ -33,13 +35,29 @@ export default {
required: false,
default: false,
},
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
packageFiles: {
type: Array,
required: false,
default: () => [],
},
},
+ data() {
+ return {
+ selectedReferences: [],
+ };
+ },
computed: {
+ areFilesSelected() {
+ return this.selectedReferences.length > 0;
+ },
+ areAllFilesSelected() {
+ return this.packageFiles.every(this.isSelected);
+ },
filesTableRows() {
return this.packageFiles.map((pf) => ({
...pf,
@@ -47,6 +65,9 @@ export default {
pipeline: last(pf.pipelines),
}));
},
+ hasSelectedSomeFiles() {
+ return this.areFilesSelected && !this.areAllFilesSelected;
+ },
showCommitColumn() {
// note that this is always false for now since we do not return
// pipelines associated to files for performance concerns
@@ -55,6 +76,12 @@ export default {
filesTableHeaderFields() {
return [
{
+ key: 'checkbox',
+ label: __('Select all'),
+ class: 'gl-w-4',
+ hide: !this.canDelete,
+ },
+ {
key: 'name',
label: __('Name'),
},
@@ -77,7 +104,7 @@ export default {
label: '',
hide: !this.canDelete,
class: 'gl-text-right',
- tdClass: 'gl-w-4',
+ tdClass: 'gl-w-4 gl-pt-3!',
},
].filter((c) => !c.hide);
},
@@ -99,21 +126,71 @@ export default {
this.track(TRACKING_ACTION_EXPAND_PACKAGE_ASSET, { label: TRACKING_LABEL_PACKAGE_ASSET });
}
},
+ updateSelectedReferences(selection) {
+ this.track(SELECT_PACKAGE_FILE_TRACKING_ACTION);
+ this.selectedReferences = selection;
+ },
+ isSelected(packageFile) {
+ return this.selectedReferences.find((reference) => reference.id === packageFile.id);
+ },
+ handleFileDeleteSelected() {
+ this.track(REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION);
+ this.$emit('delete-files', this.selectedReferences);
+ },
},
i18n: {
deleteFile: __('Delete file'),
+ deleteSelected: s__('PackageRegistry|Delete selected'),
+ moreActionsText: __('More actions'),
},
};
</script>
<template>
- <div>
- <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
- <gl-table-lite
+ <div class="gl-pt-6">
+ <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
+ <gl-button
+ v-if="canDelete"
+ :disabled="isLoading || !areFilesSelected"
+ category="secondary"
+ variant="danger"
+ data-testid="delete-selected"
+ @click="handleFileDeleteSelected"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ <gl-table
:fields="filesTableHeaderFields"
:items="filesTableRows"
+ show-empty
+ selectable
+ select-mode="multi"
+ selected-variant="primary"
:tbody-tr-attr="{ 'data-testid': 'file-row' }"
+ @row-selected="updateSelectedReferences"
>
+ <template #head(checkbox)="{ selectAllRows, clearSelected }">
+ <gl-form-checkbox
+ v-if="canDelete"
+ data-testid="package-files-checkbox-all"
+ :checked="areAllFilesSelected"
+ :indeterminate="hasSelectedSomeFiles"
+ @change="areAllFilesSelected ? clearSelected() : selectAllRows()"
+ />
+ </template>
+
+ <template #cell(checkbox)="{ rowSelected, selectRow, unselectRow }">
+ <gl-form-checkbox
+ v-if="canDelete"
+ class="gl-mt-1"
+ :checked="rowSelected"
+ data-testid="package-files-checkbox"
+ @change="rowSelected ? unselectRow() : selectRow()"
+ />
+ </template>
+
<template #cell(name)="{ item, toggleDetails, detailsShowing }">
<gl-button
v-if="hasDetails(item)"
@@ -156,11 +233,15 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <gl-dropdown category="tertiary" right>
- <template #button-content>
- <gl-icon name="ellipsis_v" />
- </template>
- <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-file', item)">
+ <gl-dropdown
+ category="tertiary"
+ icon="ellipsis_v"
+ :text-sr-only="true"
+ :text="$options.i18n.moreActionsText"
+ no-caret
+ right
+ >
+ <gl-dropdown-item data-testid="delete-file" @click="$emit('delete-files', [item])">
{{ $options.i18n.deleteFile }}
</gl-dropdown-item>
</gl-dropdown>
@@ -180,6 +261,6 @@ export default {
<file-sha v-if="item.fileSha1" data-testid="sha-1" title="SHA-1" :sha="item.fileSha1" />
</div>
</template>
- </gl-table-lite>
+ </gl-table>
</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 96b82a20364..a1fc7563de1 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
@@ -5,12 +5,17 @@ import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, n__ } from '~/locale';
+import Tracking from '~/tracking';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
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,
+ TRACKING_ACTION_CLICK_PIPELINE_LINK,
+ TRACKING_ACTION_CLICK_COMMIT_LINK,
+ TRACKING_LABEL_PACKAGE_HISTORY,
} from '../../constants';
import getPackagePipelinesQuery from '../../graphql/queries/get_package_pipelines.query.graphql';
import PackageHistoryLoader from './package_history_loader.vue';
@@ -37,6 +42,9 @@ export default {
PackageHistoryLoader,
TimeAgoTooltip,
},
+ mixins: [Tracking.mixin()],
+ TRACKING_ACTION_CLICK_PIPELINE_LINK,
+ TRACKING_ACTION_CLICK_COMMIT_LINK,
props: {
packageEntity: {
type: Object,
@@ -97,6 +105,11 @@ export default {
first: GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE,
};
},
+ tracking() {
+ return {
+ category: packageTypeToTrackCategory(this.packageType),
+ };
+ },
},
methods: {
truncate(value) {
@@ -105,6 +118,12 @@ export default {
convertToBaseId(value) {
return getIdFromGraphQLId(value);
},
+ trackPipelineClick() {
+ this.track(TRACKING_ACTION_CLICK_PIPELINE_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY });
+ },
+ trackCommitClick() {
+ this.track(TRACKING_ACTION_CLICK_COMMIT_LINK, { label: TRACKING_LABEL_PACKAGE_HISTORY });
+ },
},
};
</script>
@@ -140,7 +159,9 @@ export default {
<history-item icon="commit" data-testid="first-pipeline-commit">
<gl-sprintf :message="$options.i18n.createdByCommitText">
<template #link>
- <gl-link :href="firstPipeline.commitPath">#{{ truncate(firstPipeline.sha) }}</gl-link>
+ <gl-link :href="firstPipeline.commitPath" @click="trackCommitClick"
+ >#{{ truncate(firstPipeline.sha) }}</gl-link
+ >
</template>
<template #branch>
<strong>{{ firstPipeline.ref }}</strong>
@@ -150,7 +171,9 @@ export default {
<history-item icon="pipeline" data-testid="first-pipeline-pipeline">
<gl-sprintf :message="$options.i18n.createdByPipelineText">
<template #link>
- <gl-link :href="firstPipeline.path">#{{ convertToBaseId(firstPipeline.id) }}</gl-link>
+ <gl-link :href="firstPipeline.path" @click="trackPipelineClick"
+ >#{{ convertToBaseId(firstPipeline.id) }}</gl-link
+ >
</template>
<template #datetime>
<time-ago-tooltip :time="firstPipeline.createdAt" />
@@ -189,13 +212,17 @@ export default {
>
<gl-sprintf :message="$options.i18n.combinedUpdateText">
<template #link>
- <gl-link :href="pipeline.commitPath">#{{ truncate(pipeline.sha) }}</gl-link>
+ <gl-link :href="pipeline.commitPath" @click="trackCommitClick"
+ >#{{ truncate(pipeline.sha) }}</gl-link
+ >
</template>
<template #branch>
<strong>{{ pipeline.ref }}</strong>
</template>
<template #pipeline>
- <gl-link :href="pipeline.path">#{{ convertToBaseId(pipeline.id) }}</gl-link>
+ <gl-link :href="pipeline.path" @click="trackPipelineClick"
+ >#{{ convertToBaseId(pipeline.id) }}</gl-link
+ >
</template>
<template #datetime>
<time-ago-tooltip :time="pipeline.createdAt" />
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
index f5946797626..11fd0db3106 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
@@ -23,6 +23,7 @@ export default {
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
+ inject: ['isGroupPage'],
i18n: {
packageInfo: __('v%{version} published %{timeAgo}'),
},
@@ -65,9 +66,6 @@ export default {
this.checkBreakpoints();
},
methods: {
- dynamicSlotName(index) {
- return `metadata-tag${index}`;
- },
checkBreakpoints() {
this.isDesktop = GlBreakpointInstance.isDesktop();
},
@@ -83,21 +81,38 @@ export default {
data-qa-selector="package_title"
>
<template #sub-header>
- <span data-testid="sub-header">
+ <div data-testid="sub-header" class="gl-display-flex gl-gap-3">
<gl-sprintf :message="$options.i18n.packageInfo">
<template #version>
{{ packageEntity.version }}
</template>
<template #timeAgo>
- <time-ago-tooltip
- v-if="packageEntity.createdAt"
- class="gl-ml-2"
- :time="packageEntity.createdAt"
- />
+ <time-ago-tooltip v-if="packageEntity.createdAt" :time="packageEntity.createdAt" />
</template>
</gl-sprintf>
- </span>
+
+ <package-tags
+ v-if="isDesktop && hasTagsToDisplay"
+ :tag-display-limit="2"
+ :tags="packageEntity.tags.nodes"
+ hide-label
+ />
+
+ <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
+ <template v-else-if="hasTagsToDisplay">
+ <gl-badge
+ v-for="(tag, index) in packageEntity.tags.nodes"
+ :key="index"
+ class="gl-my-1"
+ data-testid="tag-badge"
+ variant="info"
+ size="sm"
+ >
+ {{ tag.name }}
+ </gl-badge>
+ </template>
+ </div>
</template>
<template v-if="packageTypeDisplay" #metadata-type>
@@ -108,7 +123,7 @@ export default {
<metadata-item data-testid="package-size" icon="disk" :text="totalSize" />
</template>
- <template v-if="packagePipeline" #metadata-pipeline>
+ <template v-if="isGroupPage && packagePipeline" #metadata-pipeline>
<metadata-item
data-testid="pipeline-project"
icon="review-list"
@@ -121,21 +136,6 @@ export default {
<metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" />
</template>
- <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags>
- <package-tags :tag-display-limit="2" :tags="packageEntity.tags.nodes" hide-label />
- </template>
-
- <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap -->
- <template
- v-for="(tag, index) in packageEntity.tags.nodes"
- v-else-if="hasTagsToDisplay"
- #[dynamicSlotName(index)]
- >
- <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm">
- {{ tag.name }}
- </gl-badge>
- </template>
-
<template #right-actions>
<slot name="delete-button"></slot>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
index a126d30f1ec..dd58f28a262 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/pypi_installation.vue
@@ -1,9 +1,10 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import InstallationTitle from '~/packages_and_registries/package_registry/components/details/installation_title.vue';
import {
+ PERSONAL_ACCESS_TOKEN_HELP_URL,
TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND,
TRACKING_ACTION_COPY_PYPI_SETUP_COMMAND,
TRACKING_LABEL_CODE_INSTRUCTION,
@@ -16,6 +17,7 @@ export default {
components: {
InstallationTitle,
CodeInstruction,
+ GlFormGroup,
GlLink,
GlSprintf,
},
@@ -43,6 +45,7 @@ password = <your personal access token>`;
TRACKING_LABEL_CODE_INSTRUCTION,
},
i18n: {
+ tokenText: s__(`PackageRegistry|You will need a %{linkStart}personal access token%{linkEnd}.`),
setupText: s__(
`PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`,
),
@@ -50,7 +53,10 @@ password = <your personal access token>`;
'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.',
),
},
- links: { PYPI_HELP_PATH },
+ links: {
+ PERSONAL_ACCESS_TOKEN_HELP_URL,
+ PYPI_HELP_PATH,
+ },
installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }],
};
</script>
@@ -59,14 +65,28 @@ password = <your personal access token>`;
<div>
<installation-title package-type="pypi" :options="$options.installOptions" />
- <code-instruction
- :label="s__('PackageRegistry|Pip Command')"
- :instruction="pypiPipCommand"
- :copy-text="s__('PackageRegistry|Copy Pip command')"
- data-testid="pip-command"
- :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND"
- :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
- />
+ <gl-form-group id="installation-pip-command-group">
+ <code-instruction
+ id="installation-pip-command"
+ :label="s__('PackageRegistry|Pip Command')"
+ :instruction="pypiPipCommand"
+ :copy-text="s__('PackageRegistry|Copy Pip command')"
+ data-testid="pip-command"
+ :tracking-action="$options.tracking.TRACKING_ACTION_COPY_PIP_INSTALL_COMMAND"
+ :tracking-label="$options.tracking.TRACKING_LABEL_CODE_INSTRUCTION"
+ />
+ <template #description>
+ <gl-sprintf :message="$options.i18n.tokenText">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.links.PERSONAL_ACCESS_TOKEN_HELP_URL"
+ data-testid="access-token-link"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-form-group>
<h3 class="gl-font-lg">{{ __('Registry setup') }}</h3>
<p>
@@ -87,7 +107,12 @@ password = <your personal access token>`;
/>
<gl-sprintf :message="$options.i18n.helpText">
<template #link="{ content }">
- <gl-link :href="$options.links.PYPI_HELP_PATH" target="_blank">{{ content }}</gl-link>
+ <gl-link
+ :href="$options.links.PYPI_HELP_PATH"
+ target="_blank"
+ data-testid="pypi-docs-link"
+ >{{ content }}</gl-link
+ >
</template>
</gl-sprintf>
</div>
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 cea053992f8..5b2a347a4ee 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -7,9 +7,12 @@ export {
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
PULL_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
+ SELECT_PACKAGE_FILE_TRACKING_ACTION,
} from '~/packages_and_registries/shared/constants';
export const PACKAGE_TYPE_CONAN = 'CONAN';
@@ -69,6 +72,11 @@ export const TRACKING_ACTION_DOWNLOAD_PACKAGE_ASSET = 'download_package_asset';
export const TRACKING_ACTION_EXPAND_PACKAGE_ASSET = 'expand_package_asset';
export const TRACKING_ACTION_COPY_PACKAGE_ASSET_SHA = 'copy_package_asset_sha';
+export const TRACKING_ACTION_CLICK_PIPELINE_LINK = 'click_pipeline_link_from_package';
+export const TRACKING_ACTION_CLICK_COMMIT_LINK = 'click_commit_link_from_package';
+
+export const TRACKING_LABEL_PACKAGE_HISTORY = 'package_history';
+
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
@@ -76,6 +84,12 @@ export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
'PackageRegistry|Package file deleted successfully',
);
+export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__(
+ 'PackageRegistry|Something went wrong while deleting the package assets.',
+);
+export const DELETE_PACKAGE_FILES_SUCCESS_MESSAGE = s__(
+ 'PackageRegistry|Package assets deleted successfully',
+);
export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
'PackageRegistry|Failed to load the package data',
);
@@ -162,5 +176,6 @@ 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 PERSONAL_ACCESS_TOKEN_HELP_URL = helpPagePath('user/profile/personal_access_tokens');
export const GRAPHQL_PACKAGE_PIPELINES_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql
deleted file mode 100644
index f016640f57d..00000000000
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation destroyPackageFile($id: PackagesPackageFileID!) {
- destroyPackageFile(input: { id: $id }) {
- errors
- }
-}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql
new file mode 100644
index 00000000000..8f9a3156492
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql
@@ -0,0 +1,5 @@
+mutation destroyPackageFiles($projectPath: ID!, $ids: [PackagesPackageFileID!]!) {
+ destroyPackageFiles(input: { projectPath: $projectPath, ids: $ids }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql
index 5574020c9e4..f3f0d096d10 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
@@ -20,6 +20,7 @@ query getPackageDetails($id: PackagesPackageID!) {
id
path
name
+ fullPath
}
tags(first: 10) {
nodes {
@@ -39,6 +40,9 @@ query getPackageDetails($id: PackagesPackageID!) {
}
}
packageFiles(first: 100) {
+ pageInfo {
+ hasNextPage
+ }
nodes {
id
fileMd5
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 29438fba86b..e83962bb608 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
@@ -34,16 +34,19 @@ import {
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
DELETE_PACKAGE_FILE_TRACKING_ACTION,
+ DELETE_PACKAGE_FILES_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ DELETE_PACKAGE_FILES_ERROR_MESSAGE,
+ DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
-import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
+import destroyPackageFilesMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_files.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import Tracking from '~/tracking';
@@ -83,7 +86,8 @@ export default {
},
data() {
return {
- fileToDelete: null,
+ filesToDelete: [],
+ mutationLoading: false,
packageEntity: {},
};
},
@@ -114,6 +118,9 @@ export default {
projectName() {
return this.packageEntity.project?.name;
},
+ projectPath() {
+ return this.packageEntity.project?.fullPath;
+ },
packageId() {
return this.$route.params.id;
},
@@ -131,6 +138,9 @@ export default {
isLoading() {
return this.$apollo.queries.packageEntity.loading;
},
+ packageFilesLoading() {
+ return this.isLoading || this.mutationLoading;
+ },
isValidPackage() {
return this.isLoading || Boolean(this.packageEntity.name);
},
@@ -175,12 +185,14 @@ export default {
window.location.replace(`${returnTo}?${modalQuery}`);
},
- async deletePackageFile(id) {
+ async deletePackageFiles(ids) {
+ this.mutationLoading = true;
try {
const { data } = await this.$apollo.mutate({
- mutation: destroyPackageFileMutation,
+ mutation: destroyPackageFilesMutation,
variables: {
- id,
+ projectPath: this.projectPath,
+ ids,
},
awaitRefetchQueries: true,
refetchQueries: [
@@ -190,31 +202,53 @@ export default {
},
],
});
- if (data?.destroyPackageFile?.errors[0]) {
- throw data.destroyPackageFile.errors[0];
+ if (data?.destroyPackageFiles?.errors[0]) {
+ throw data.destroyPackageFiles.errors[0];
}
createFlash({
- message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ message:
+ ids.length === 1
+ ? DELETE_PACKAGE_FILE_SUCCESS_MESSAGE
+ : DELETE_PACKAGE_FILES_SUCCESS_MESSAGE,
type: 'success',
});
} catch (error) {
createFlash({
- message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ message:
+ ids.length === 1
+ ? DELETE_PACKAGE_FILE_ERROR_MESSAGE
+ : DELETE_PACKAGE_FILES_ERROR_MESSAGE,
type: 'warning',
captureError: true,
error,
});
}
+ this.mutationLoading = false;
},
- handleFileDelete(file) {
+ handleFileDelete(files) {
this.track(REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION);
- this.fileToDelete = { ...file };
- this.$refs.deleteFileModal.show();
+ if (
+ files.length === this.packageFiles.length &&
+ !this.packageEntity.packageFiles?.pageInfo?.hasNextPage
+ ) {
+ this.$refs.deleteModal.show();
+ } else {
+ this.filesToDelete = files;
+ if (files.length === 1) {
+ this.$refs.deleteFileModal.show();
+ } else if (files.length > 1) {
+ this.$refs.deleteFilesModal.show();
+ }
+ }
},
- confirmFileDelete() {
- this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
- this.deletePackageFile(this.fileToDelete.id);
- this.fileToDelete = null;
+ confirmFilesDelete() {
+ if (this.filesToDelete.length === 1) {
+ this.track(DELETE_PACKAGE_FILE_TRACKING_ACTION);
+ } else {
+ this.track(DELETE_PACKAGE_FILES_TRACKING_ACTION);
+ }
+ this.deletePackageFiles(this.filesToDelete.map((file) => file.id));
+ this.filesToDelete = [];
},
},
i18n: {
@@ -240,6 +274,10 @@ export default {
text: __('Delete'),
attributes: [{ variant: 'danger' }, { category: 'primary' }],
},
+ filesDeletePrimaryAction: {
+ text: s__('PackageRegistry|Permanently delete assets'),
+ attributes: [{ variant: 'danger' }, { category: 'primary' }],
+ },
cancelAction: {
text: __('Cancel'),
},
@@ -287,9 +325,10 @@ export default {
<package-files
v-if="showFiles"
:can-delete="packageEntity.canDestroy"
+ :is-loading="packageFilesLoading"
:package-files="packageFiles"
@download-file="track($options.trackingActions.DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION)"
- @delete-file="handleFileDelete"
+ @delete-files="handleFileDelete"
/>
</gl-tab>
@@ -355,15 +394,43 @@ export default {
:action-primary="$options.modal.fileDeletePrimaryAction"
:action-cancel="$options.modal.cancelAction"
data-testid="delete-file-modal"
- @primary="confirmFileDelete"
+ @primary="confirmFilesDelete"
@canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
>
<template #modal-title>{{ $options.i18n.deleteFileModalTitle }}</template>
- <gl-sprintf v-if="fileToDelete" :message="$options.i18n.deleteFileModalContent">
+ <gl-sprintf v-if="filesToDelete.length === 1" :message="$options.i18n.deleteFileModalContent">
<template #filename>
- <strong>{{ fileToDelete.file_name }}</strong>
+ <strong>{{ filesToDelete[0].fileName }}</strong>
</template>
</gl-sprintf>
</gl-modal>
+
+ <gl-modal
+ ref="deleteFilesModal"
+ size="sm"
+ modal-id="delete-files-modal"
+ :action-primary="$options.modal.filesDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ data-testid="delete-files-modal"
+ @primary="confirmFilesDelete"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE_FILE)"
+ >
+ <template #modal-title>{{
+ n__(
+ `PackageRegistry|Delete 1 asset`,
+ `PackageRegistry|Delete %d assets`,
+ filesToDelete.length,
+ )
+ }}</template>
+ <span v-if="filesToDelete.length > 0">
+ {{
+ n__(
+ `PackageRegistry|You are about to delete 1 asset. This operation is irreversible.`,
+ `PackageRegistry|You are about to delete %d assets. This operation is irreversible.`,
+ filesToDelete.length,
+ )
+ }}
+ </span>
+ </gl-modal>
</div>
</template>
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
index 90a18d5cf5a..1c44d2bc38b 100644
--- 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
@@ -11,7 +11,7 @@ import {
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 SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
index 7682754fdcb..f06e3a41bd0 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_dropdown.vue
@@ -35,22 +35,34 @@ export default {
required: false,
default: '',
},
+ dropdownClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
};
</script>
<template>
<gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label">
- <gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)">
- <option
- v-for="option in formOptions"
- :key="option.key"
- :value="option.key"
- data-testid="option"
+ <div :class="dropdownClass">
+ <gl-form-select
+ :id="name"
+ :value="value"
+ :disabled="disabled"
+ @input="$emit('input', $event)"
>
- {{ option.label }}
- </option>
- </gl-form-select>
+ <option
+ v-for="option in formOptions"
+ :key="option.key"
+ :value="option.key"
+ data-testid="option"
+ >
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </div>
<template v-if="description" #description>
<span data-testid="description" class="gl-text-gray-400">
{{ description }}
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
index 1170407a349..2f4bc35e5f7 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy.vue
@@ -6,7 +6,7 @@ import {
PACKAGES_CLEANUP_POLICY_DESCRIPTION,
} from '~/packages_and_registries/settings/project/constants';
import packagesCleanupPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_packages_cleanup_policy.query.graphql';
-import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
+import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
import PackagesCleanupPolicyForm from './packages_cleanup_policy_form.vue';
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
index b1751d5174a..f1f0b970b15 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/packages_cleanup_policy_form.vue
@@ -3,10 +3,10 @@ import { GlButton } from '@gitlab/ui';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
- SET_CLEANUP_POLICY_BUTTON,
KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION,
KEEP_N_DUPLICATED_PACKAGE_FILES_FIELDNAME,
KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL,
+ SET_CLEANUP_POLICY_BUTTON,
} from '~/packages_and_registries/settings/project/constants';
import updatePackagesCleanupPolicyMutation from '~/packages_and_registries/settings/project/graphql/mutations/update_packages_cleanup_policy.mutation.graphql';
import { formOptionsGenerator } from '~/packages_and_registries/settings/project/utils';
@@ -108,18 +108,17 @@ export default {
<template>
<form ref="form-element" @submit.prevent="submit">
- <div class="gl-md-max-w-50p">
- <expiration-dropdown
- v-model="prefilledForm.keepNDuplicatedPackageFiles"
- :disabled="isFieldDisabled"
- :form-options="$options.formOptions.keepNDuplicatedPackageFiles"
- :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
- :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
- name="keep-n-duplicated-package-files"
- data-testid="keep-n-duplicated-package-files-dropdown"
- @input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
- />
- </div>
+ <expiration-dropdown
+ :value="prefilledForm.keepNDuplicatedPackageFiles"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.keepNDuplicatedPackageFiles"
+ :label="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_LABEL"
+ :description="$options.i18n.KEEP_N_DUPLICATED_PACKAGE_FILES_DESCRIPTION"
+ dropdown-class="gl-md-max-w-50p gl-sm-pr-5"
+ name="keep-n-duplicated-package-files"
+ data-testid="keep-n-duplicated-package-files-dropdown"
+ @input="onModelChange($event, 'keepNDuplicatedPackageFiles')"
+ />
<div class="gl-mt-7 gl-display-flex gl-align-items-center">
<gl-button
data-testid="save-button"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
index 948520151ce..fcb4a8ee297 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -4,7 +4,7 @@ export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up im
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 SET_CLEANUP_POLICY_BUTTON = __('Save changes');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
);
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
index 5caf95cd050..0458b914b58 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/settings_block.vue
@@ -1,7 +1,7 @@
<template>
<section class="settings gl-py-7">
- <div class="gl-lg-display-flex">
- <div class="gl-lg-w-half gl-pr-10">
+ <div class="gl-lg-display-flex gl-gap-6">
+ <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0">
<h4>
<slot name="title"></slot>
</h4>
@@ -9,7 +9,7 @@
<slot name="description"></slot>
</p>
</div>
- <div class="gl-lg-w-half gl-pt-3">
+ <div class="gl-pt-3 gl-flex-grow-1">
<slot></slot>
</div>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
index 5505205cf33..6744e821565 100644
--- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
+++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js
@@ -9,7 +9,11 @@ export const REQUEST_DELETE_PACKAGE_TRACKING_ACTION = 'request_delete_package';
export const CANCEL_DELETE_PACKAGE_TRACKING_ACTION = 'cancel_delete_package';
export const PULL_PACKAGE_TRACKING_ACTION = 'pull_package';
export const DELETE_PACKAGE_FILE_TRACKING_ACTION = 'delete_package_file';
+export const DELETE_PACKAGE_FILES_TRACKING_ACTION = 'delete_package_files';
+export const SELECT_PACKAGE_FILE_TRACKING_ACTION = 'select_package_file';
export const REQUEST_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'request_delete_package_file';
+export const REQUEST_DELETE_SELECTED_PACKAGE_FILE_TRACKING_ACTION =
+ 'request_delete_selected_package_file';
export const CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION = 'cancel_delete_package_file';
export const DOWNLOAD_PACKAGE_ASSET_TRACKING_ACTION = 'download_package_asset';
diff --git a/app/assets/javascripts/pages/groups/new/components/app.vue b/app/assets/javascripts/pages/groups/new/components/app.vue
index 713287f65b4..f01e5e595a3 100644
--- a/app/assets/javascripts/pages/groups/new/components/app.vue
+++ b/app/assets/javascripts/pages/groups/new/components/app.vue
@@ -2,51 +2,74 @@
import importGroupIllustration from '@gitlab/svgs/dist/illustrations/group-import.svg';
import newGroupIllustration from '@gitlab/svgs/dist/illustrations/group-new.svg';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
import createGroupDescriptionDetails from './create_group_description_details.vue';
-const PANELS = [
- {
- name: 'create-group-pane',
- selector: '#create-group-pane',
- title: s__('GroupsNew|Create group'),
- description: s__(
- 'GroupsNew|Assemble related projects together and grant members access to several projects at once.',
- ),
- illustration: newGroupIllustration,
- details: createGroupDescriptionDetails,
- },
- {
- name: 'import-group-pane',
- selector: '#import-group-pane',
- title: s__('GroupsNew|Import group'),
- description: s__('GroupsNew|Import a group and related data from another GitLab instance.'),
- illustration: importGroupIllustration,
- details: 'Migrate your existing groups from another instance of GitLab.',
- },
-];
-
export default {
components: {
NewNamespacePage,
},
props: {
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ importExistingGroupPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasErrors: {
type: Boolean,
required: false,
default: false,
},
},
- PANELS,
+ computed: {
+ initialBreadcrumb() {
+ return this.parentGroupName || __('New group');
+ },
+ panels() {
+ return [
+ {
+ name: 'create-group-pane',
+ selector: '#create-group-pane',
+ title: this.parentGroupName
+ ? s__('GroupsNew|Create subgroup')
+ : s__('GroupsNew|Create group'),
+ description: s__(
+ 'GroupsNew|Assemble related projects together and grant members access to several projects at once.',
+ ),
+ illustration: newGroupIllustration,
+ details: createGroupDescriptionDetails,
+ detailProps: {
+ parentGroupName: this.parentGroupName,
+ importExistingGroupPath: this.importExistingGroupPath,
+ },
+ },
+ {
+ name: 'import-group-pane',
+ selector: '#import-group-pane',
+ title: s__('GroupsNew|Import group'),
+ description: s__(
+ 'GroupsNew|Import a group and related data from another GitLab instance.',
+ ),
+ illustration: importGroupIllustration,
+ details: 'Migrate your existing groups from another instance of GitLab.',
+ },
+ ];
+ },
+ },
};
</script>
<template>
<new-namespace-page
:jump-to-last-persisted-panel="hasErrors"
- :initial-breadcrumb="__('New group')"
- :panels="$options.PANELS"
+ :initial-breadcrumb="initialBreadcrumb"
+ :panels="panels"
:title="s__('GroupsNew|Create new group')"
persistence-key="new_group_last_active_tab"
/>
diff --git a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
index 35193171fb8..be8542628c4 100644
--- a/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
+++ b/app/assets/javascripts/pages/groups/new/components/create_group_description_details.vue
@@ -1,6 +1,22 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+const DESCRIPTION_DETAILS = {
+ group: [
+ s__(
+ 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
+ ),
+ s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.'),
+ ],
+ subgroup: [
+ s__(
+ 'GroupsNew|%{groupsLinkStart}Groups%{groupsLinkEnd} and %{subgroupsLinkStart}subgroups%{subgroupsLinkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
+ ),
+ s__('GroupsNew|You can also %{linkStart}import an existing group%{linkEnd}.'),
+ ],
+};
export default {
components: {
@@ -11,30 +27,46 @@ export default {
groupsHelpPath: helpPagePath('user/group/index'),
subgroupsHelpPath: helpPagePath('user/group/subgroups/index'),
},
+ props: {
+ parentGroupName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ importExistingGroupPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ descriptionDetails: DESCRIPTION_DETAILS,
};
</script>
<template>
<div>
<p>
- <gl-sprintf
- :message="
- s__(
- 'GroupsNew|%{linkStart}Groups%{linkEnd} allow you to manage and collaborate across multiple projects. Members of a group have access to all of its projects.',
- )
- "
- >
+ <gl-sprintf v-if="parentGroupName" :message="$options.descriptionDetails.subgroup[0]">
+ <template #groupsLink="{ content }">
+ <gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #subgroupsLink="{ content }">
+ <gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.descriptionDetails.group[0]">
<template #link="{ content }">
<gl-link :href="$options.paths.groupsHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<p>
- <gl-sprintf
- :message="
- s__('GroupsNew|Groups can also be nested by creating %{linkStart}subgroups%{linkEnd}.')
- "
- >
+ <gl-sprintf v-if="parentGroupName" :message="$options.descriptionDetails.subgroup[1]">
+ <template #link="{ content }">
+ <gl-link :href="importExistingGroupPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="$options.descriptionDetails.group[1]">
<template #link="{ content }">
<gl-link :href="$options.paths.subgroupsHelpPath" target="_blank">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 7c409010510..7dab5258b24 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -16,9 +16,18 @@ BindInOut.initAll();
initFilePickers();
function initNewGroupCreation(el) {
- const { hasErrors, verificationRequired, verificationFormUrl, subscriptionsUrl } = el.dataset;
+ const {
+ hasErrors,
+ parentGroupName,
+ importExistingGroupPath,
+ verificationRequired,
+ verificationFormUrl,
+ subscriptionsUrl,
+ } = el.dataset;
const props = {
+ parentGroupName,
+ importExistingGroupPath,
hasErrors: parseBoolean(hasErrors),
};
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 6748a62e777..9cce6723bf7 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
@@ -68,7 +68,7 @@ export default {
}),
tableCell({
key: 'created_at',
- label: __('Date'),
+ label: __('Start date'),
}),
tableCell({
key: 'status',
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 3fae9809e51..c520042c172 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -2,12 +2,10 @@ import {
initAccessTokenTableApp,
initExpiresAtField,
initNewAccessTokenApp,
- initProjectsField,
initTokensApp,
} from '~/access_tokens';
initAccessTokenTableApp();
initExpiresAtField();
initNewAccessTokenApp();
-initProjectsField();
initTokensApp();
diff --git a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js b/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
deleted file mode 100644
index 61486606665..00000000000
--- a/app/assets/javascripts/pages/projects/ci/secure_files/show/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import { initCiSecureFiles } from '~/ci_secure_files';
-
-initCiSecureFiles();
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index c217bc5a727..65e7f48ed24 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -55,18 +55,30 @@ waitForCSSLoaded(() => {
},
attrs: {
height: LANGUAGE_CHART_HEIGHT,
+ responsive: true,
},
});
},
});
+ const {
+ graphEndpoint,
+ graphEndDate,
+ graphStartDate,
+ graphRef,
+ graphCsvPath,
+ } = codeCoverageContainer.dataset;
// eslint-disable-next-line no-new
new Vue({
el: codeCoverageContainer,
render(h) {
return h(CodeCoverage, {
props: {
- graphEndpoint: codeCoverageContainer.dataset?.graphEndpoint,
+ graphEndpoint,
+ graphEndDate,
+ graphStartDate,
+ graphRef,
+ graphCsvPath,
},
});
},
@@ -92,6 +104,9 @@ waitForCSSLoaded(() => {
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
+ attrs: {
+ responsive: true,
+ },
});
},
});
@@ -125,6 +140,9 @@ waitForCSSLoaded(() => {
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
+ attrs: {
+ responsive: true,
+ },
});
},
});
@@ -149,6 +167,9 @@ waitForCSSLoaded(() => {
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
},
+ attrs: {
+ responsive: true,
+ },
});
},
});
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 92ae8128285..d7e68484143 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAlert, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { get } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
@@ -11,6 +11,7 @@ export default {
components: {
GlAlert,
GlAreaChart,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlSprintf,
@@ -20,6 +21,22 @@ export default {
type: String,
required: true,
},
+ graphEndDate: {
+ type: String,
+ required: true,
+ },
+ graphStartDate: {
+ type: String,
+ required: true,
+ },
+ graphRef: {
+ type: String,
+ required: true,
+ },
+ graphCsvPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -119,6 +136,28 @@ export default {
<template>
<div>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-border-t gl-pt-4 gl-mb-3"
+ >
+ <h4 class="gl-m-0" sub-header>
+ <gl-sprintf
+ :message="__('Code coverage statistics for %{ref} %{start_date} - %{end_date}')"
+ >
+ <template #ref>
+ <strong> {{ graphRef }} </strong>
+ </template>
+ <template #start_date>
+ <strong> {{ graphStartDate }} </strong>
+ </template>
+ <template #end_date>
+ <strong> {{ graphEndDate }} </strong>
+ </template>
+ </gl-sprintf>
+ </h4>
+ <gl-button v-if="canShowData" size="small" data-testid="download-button" :href="graphCsvPath">
+ {{ __('Download raw data (.csv)') }}
+ </gl-button>
+ </div>
<div class="gl-mt-3 gl-mb-3">
<gl-alert
v-if="hasFetchError"
@@ -155,6 +194,7 @@ export default {
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
+ responsive
>
<template v-if="canShowData" #tooltip-title>
{{ tooltipTitle }}
diff --git a/app/assets/javascripts/pages/projects/init_blob.js b/app/assets/javascripts/pages/projects/init_blob.js
index 7db34816cfe..f7849e8d588 100644
--- a/app/assets/javascripts/pages/projects/init_blob.js
+++ b/app/assets/javascripts/pages/projects/init_blob.js
@@ -4,6 +4,7 @@ import BlobForkSuggestion from '~/blob/blob_fork_suggestion';
import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater';
import LineHighlighter from '~/blob/line_highlighter';
import initBlobBundle from '~/blob_edit/blob_bundle';
+import addBlobLinksTracking from '~/blob/blob_links_tracking';
export default () => {
new LineHighlighter(); // eslint-disable-line no-new
@@ -11,10 +12,16 @@ export default () => {
// eslint-disable-next-line no-new
new BlobLinePermalinkUpdater(
document.querySelector('#blob-content-holder'),
- '.diff-line-num[data-line-number], .diff-line-num[data-line-number] *',
+ '.file-line-num[data-line-number], .file-line-num[data-line-number] *',
document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'),
);
+ const eventsToTrack = [
+ { selector: '.file-line-blame', property: 'blame' },
+ { selector: '.file-line-num', property: 'link' },
+ ];
+ addBlobLinksTracking('#blob-content-holder', eventsToTrack);
+
const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url');
const fileBlobPermalinkUrl =
fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href');
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index ca2b1a08be8..c92958cd8c7 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -1,6 +1,6 @@
import { initShow } from '~/issues';
import { store } from '~/notes/stores';
-import initRelatedIssues from '~/related_issues';
+import { initRelatedIssues } from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initWorkItemLinks from '~/work_items/components/work_item_links';
diff --git a/app/assets/javascripts/pages/projects/pages/new/index.js b/app/assets/javascripts/pages/projects/pages/new/index.js
new file mode 100644
index 00000000000..a5157f5b01b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pages/new/index.js
@@ -0,0 +1,3 @@
+import initPages from '~/gitlab_pages/new';
+
+initPages();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
index cd4bc35e74e..9513f42d9c9 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
+import PipelineSchedulesTakeOwnershipModal from '~/pipeline_schedules/components/take_ownership_modal.vue';
import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue';
function initPipelineSchedules() {
@@ -23,4 +25,43 @@ function initPipelineSchedules() {
});
}
+function initTakeownershipModal() {
+ const modalId = 'pipeline-take-ownership-modal';
+ const buttonSelector = 'js-take-ownership-button';
+ const el = document.getElementById(modalId);
+ const takeOwnershipButtons = document.querySelectorAll(`.${buttonSelector}`);
+
+ if (!el) {
+ return;
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ data() {
+ return {
+ url: '',
+ };
+ },
+ mounted() {
+ takeOwnershipButtons.forEach((button) => {
+ button.addEventListener('click', () => {
+ const { url } = button.dataset;
+
+ this.url = url;
+ this.$root.$emit(BV_SHOW_MODAL, modalId, `.${buttonSelector}`);
+ });
+ });
+ },
+ render(createElement) {
+ return createElement(PipelineSchedulesTakeOwnershipModal, {
+ props: {
+ ownershipUrl: this.url,
+ },
+ });
+ },
+ });
+}
+
initPipelineSchedules();
+initTakeownershipModal();
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 f2c30870a68..c7c331c7de5 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
@@ -29,6 +29,10 @@ export default {
lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'),
mergeRequestsLabel: s__('ProjectSettings|Merge requests'),
operationsLabel: s__('ProjectSettings|Operations'),
+ environmentsLabel: s__('ProjectSettings|Environments'),
+ environmentsHelpText: s__(
+ 'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.',
+ ),
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.',
),
@@ -209,6 +213,7 @@ export default {
requirementsAccessLevel: featureAccessLevel.EVERYONE,
securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
operationsAccessLevel: featureAccessLevel.EVERYONE,
+ environmentsAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryAccessLevel: featureAccessLevel.EVERYONE,
warnAboutPotentiallyUnwantedCharacters: true,
lfsEnabled: true,
@@ -282,6 +287,9 @@ export default {
return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED;
},
+ environmentsEnabled() {
+ return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED;
+ },
repositoryEnabled() {
return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED;
},
@@ -318,12 +326,8 @@ export default {
packageRegistryAccessLevelEnabled() {
return this.glFeatures.packageRegistryAccessLevel;
},
- showAdditonalSettings() {
- if (this.glFeatures.enforceAuthChecksOnUploads) {
- return true;
- }
-
- return this.visibilityLevel !== this.visibilityOptions.PRIVATE;
+ splitOperationsEnabled() {
+ return this.glFeatures.splitOperationsVisibilityPermissions;
},
},
@@ -381,6 +385,10 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.operationsAccessLevel,
);
+ this.environmentsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.environmentsAccessLevel,
+ );
this.containerRegistryAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.containerRegistryAccessLevel,
@@ -422,6 +430,8 @@ export default {
this.requirementsAccessLevel = featureAccessLevel.EVERYONE;
if (this.operationsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.operationsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.environmentsAccessLevel = featureAccessLevel.EVERYONE;
if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE;
@@ -545,7 +555,7 @@ export default {
</template>
</gl-sprintf>
</span>
- <div v-if="showAdditonalSettings" class="gl-mt-4">
+ <div class="gl-mt-4">
<strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
<label
v-if="visibilityLevel !== visibilityOptions.PRIVATE"
@@ -560,9 +570,7 @@ export default {
{{ s__('ProjectSettings|Users can request access') }}
</label>
<label
- v-if="
- visibilityLevel !== visibilityOptions.PUBLIC && glFeatures.enforceAuthChecksOnUploads
- "
+ v-if="visibilityLevel !== visibilityOptions.PUBLIC"
class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0"
>
<input
@@ -866,6 +874,20 @@ export default {
/>
</project-setting-row>
</div>
+ <template v-if="splitOperationsEnabled">
+ <project-setting-row
+ ref="environments-settings"
+ :label="$options.i18n.environmentsLabel"
+ :help-text="$options.i18n.environmentsHelpText"
+ >
+ <project-feature-setting
+ v-model="environmentsAccessLevel"
+ :label="$options.i18n.environmentsLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][environments_access_level]"
+ />
+ </project-setting-row>
+ </template>
</div>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js
deleted file mode 100644
index cafd880b4be..00000000000
--- a/app/assets/javascripts/pages/projects/tags/releases/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import $ from 'jquery';
-import GLForm from '~/gl_form';
-import ZenMode from '~/zen_mode';
-
-new ZenMode(); // eslint-disable-line no-new
-new GLForm($('.release-form')); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js
index 94a5c1cb29b..897acf9b02c 100644
--- a/app/assets/javascripts/pages/registrations/new/index.js
+++ b/app/assets/javascripts/pages/registrations/new/index.js
@@ -3,12 +3,17 @@ import { trackNewRegistrations } from '~/google_tag_manager';
import NoEmojiValidator from '~/emoji/no_emoji_validator';
import LengthValidator from '~/pages/sessions/new/length_validator';
import UsernameValidator from '~/pages/sessions/new/username_validator';
+import EmailFormatValidator from '~/pages/sessions/new/email_format_validator';
import Tracking from '~/tracking';
new UsernameValidator(); // eslint-disable-line no-new
new LengthValidator(); // eslint-disable-line no-new
new NoEmojiValidator(); // eslint-disable-line no-new
+if (gon.features.trialEmailValidation) {
+ new EmailFormatValidator(); // eslint-disable-line no-new
+}
+
trackNewRegistrations();
Tracking.enableFormTracking({
diff --git a/app/assets/javascripts/pages/sessions/new/email_format_validator.js b/app/assets/javascripts/pages/sessions/new/email_format_validator.js
new file mode 100644
index 00000000000..6dcf3b50dca
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/email_format_validator.js
@@ -0,0 +1,46 @@
+import InputValidator from '~/validators/input_validator';
+
+// It checks if email contains at least one character, number or whatever except
+// another "@" or whitespace before "@", at least two characters except
+// another "@" or whitespace after "@" and one dot in between
+const emailRegexPattern = /[^@\s]+@[^@\s]+\.[^@\s]+/;
+const hintMessageSelector = '.validation-hint';
+const warningMessageSelector = '.validation-warning';
+
+export default class EmailFormatValidator extends InputValidator {
+ constructor(opts = {}) {
+ super();
+
+ const container = opts.container || '';
+
+ document
+ .querySelectorAll(`${container} .js-validate-email`)
+ .forEach((element) =>
+ element.addEventListener('keyup', EmailFormatValidator.eventHandler.bind(this)),
+ );
+ }
+
+ static eventHandler(event) {
+ const inputDomElement = event.target;
+
+ EmailFormatValidator.setMessageVisibility(inputDomElement, hintMessageSelector);
+ EmailFormatValidator.setMessageVisibility(inputDomElement, warningMessageSelector);
+ EmailFormatValidator.validateEmailInput(inputDomElement);
+ }
+
+ static validateEmailInput(inputDomElement) {
+ const validEmail = inputDomElement.checkValidity();
+ const validPattern = inputDomElement.value.match(emailRegexPattern);
+
+ EmailFormatValidator.setMessageVisibility(
+ inputDomElement,
+ warningMessageSelector,
+ validEmail && !validPattern,
+ );
+ }
+
+ static setMessageVisibility(inputDomElement, messageSelector, isVisible = false) {
+ const messageElement = inputDomElement.parentElement.querySelector(messageSelector);
+ messageElement.classList.toggle('hide', !isVisible);
+ }
+}
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
index 3c22844434d..9d7d9e376cf 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -9,6 +9,7 @@ import {
GlFormGroup,
GlFormInput,
GlFormSelect,
+ GlSegmentedControl,
} from '@gitlab/ui';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import axios from '~/lib/utils/axios_utils';
@@ -81,9 +82,11 @@ export default {
newPage: s__('WikiPage|Create page'),
},
cancel: s__('WikiPage|Cancel'),
- editSourceButtonText: s__('WikiPage|Edit source'),
- editRichTextButtonText: s__('WikiPage|Edit rich text'),
},
+ switchEditingControlOptions: [
+ { text: s__('Wiki Page|Source'), value: 'source' },
+ { text: s__('Wiki Page|Rich text'), value: 'richText' },
+ ],
components: {
GlAlert,
GlIcon,
@@ -94,6 +97,7 @@ export default {
GlSprintf,
GlLink,
GlButton,
+ GlSegmentedControl,
MarkdownField,
LocalStorageSync,
ContentEditor: () =>
@@ -105,14 +109,15 @@ export default {
inject: ['formatOptions', 'pageInfo'],
data() {
return {
+ editingMode: 'source',
title: this.pageInfo.title?.trim() || '',
format: this.pageInfo.format || 'markdown',
content: this.pageInfo.content || '',
- useContentEditor: false,
commitMessage: '',
isDirty: false,
contentEditorRenderFailed: false,
contentEditorEmpty: false,
+ switchEditingControlDisabled: false,
};
},
computed: {
@@ -177,6 +182,9 @@ export default {
isContentEditorActive() {
return this.isMarkdownFormat && this.useContentEditor;
},
+ useContentEditor() {
+ return this.editingMode === 'richText';
+ },
},
mounted() {
this.updateCommitMessage();
@@ -193,16 +201,15 @@ export default {
.then(({ data }) => data.body);
},
- toggleEditingMode() {
- if (this.useContentEditor) {
+ toggleEditingMode(editingMode) {
+ this.editingMode = editingMode;
+ if (!this.useContentEditor && this.contentEditor) {
this.content = this.contentEditor.getSerializedContent();
}
-
- this.useContentEditor = !this.useContentEditor;
},
- setUseContentEditor(value) {
- this.useContentEditor = value;
+ setEditingMode(value) {
+ this.editingMode = value;
},
async handleFormSubmit(e) {
@@ -294,6 +301,14 @@ export default {
},
});
},
+
+ enableSwitchEditingControl() {
+ this.switchEditingControlDisabled = false;
+ },
+
+ disableSwitchEditingControl() {
+ this.switchEditingControlDisabled = true;
+ },
},
};
</script>
@@ -372,20 +387,21 @@ export default {
<div class="row" data-testid="wiki-form-content-fieldset">
<div class="col-sm-12 row-sm-5">
<gl-form-group>
- <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-end gl-mb-3">
- <gl-button
+ <div v-if="isMarkdownFormat" class="gl-display-flex gl-justify-content-start gl-mb-3">
+ <gl-segmented-control
data-testid="toggle-editing-mode-button"
data-qa-selector="editing_mode_button"
- :data-qa-mode="toggleEditingModeButtonText"
- variant="link"
- @click="toggleEditingMode"
- >{{ toggleEditingModeButtonText }}</gl-button
- >
+ class="gl-display-flex"
+ :checked="editingMode"
+ :options="$options.switchEditingControlOptions"
+ :disabled="switchEditingControlDisabled"
+ @input="toggleEditingMode"
+ />
</div>
<local-storage-sync
storage-key="gl-wiki-content-editor-enabled"
- :value="useContentEditor"
- @input="setUseContentEditor"
+ :value="editingMode"
+ @input="setEditingMode"
/>
<markdown-field
v-if="!isContentEditorActive"
@@ -422,6 +438,9 @@ export default {
:uploads-path="pageInfo.uploadsPath"
@initialized="loadInitialContent"
@change="handleContentEditorChange"
+ @loading="disableSwitchEditingControl"
+ @loadingSuccess="enableSwitchEditingControl"
+ @loadingError="enableSwitchEditingControl"
/>
<input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
</div>
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 7c424088c8b..9cea89f4990 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -7,11 +7,12 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
constructor(container, options = container.dataset) {
- const { dismissEndpoint, featureId, groupId, deferLinks } = options;
+ const { dismissEndpoint, featureId, groupId, namespaceId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
this.groupId = groupId;
+ this.namespaceId = namespaceId;
this.deferLinks = parseBoolean(deferLinks);
this.closeButtons = this.container.querySelectorAll('.js-close');
@@ -56,6 +57,7 @@ export default class PersistentUserCallout {
.post(this.dismissEndpoint, {
feature_name: this.featureId,
group_id: this.groupId,
+ namespace_id: this.namespaceId,
})
.then(() => {
this.container.remove();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index f836921f5e5..ead512e3574 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -16,6 +16,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-minute-limit-banner',
'.js-submit-license-usage-data-banner',
'.js-project-usage-limitations-callout',
+ '.js-namespace-storage-alert',
];
const initCallouts = () => {
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 1f74e89f90c..0b57433e894 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
@@ -28,13 +28,13 @@ export default {
GlSprintf,
},
mixins: [Tracking.mixin()],
- inject: ['runnerHelpPagePath'],
methods: {
trackHelpPageClick() {
const { label, actions } = pipelineEditorTrackingOptions;
this.track(actions.helpDrawerLinks.runners, { label });
},
},
+ RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
};
</script>
<template>
@@ -47,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" @click="trackHelpPageClick()">
+ <gl-link :href="$options.RUNNER_HELP_URL" target="_blank" @click="trackHelpPageClick()">
{{ 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 65a2a6b56e4..189690ce2c3 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
@@ -43,7 +43,9 @@ export default {
</script>
<template>
- <div class="gl-bg-gray-10 gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1">
+ <div
+ class="gl-bg-gray-10 gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1"
+ >
<gl-button
:href="$options.TEMPLATE_REPOSITORY_URL"
size="small"
diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
deleted file mode 100644
index f1cf5630fbf..00000000000
--- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue
+++ /dev/null
@@ -1,53 +0,0 @@
-<script>
-import { flatten } from 'lodash';
-import CiLintResults from './ci_lint_results.vue';
-
-export default {
- components: {
- CiLintResults,
- },
- inject: {
- lintHelpPagePath: {
- default: '',
- },
- },
- props: {
- isValid: {
- type: Boolean,
- required: true,
- },
- ciConfig: {
- type: Object,
- required: true,
- },
- },
- computed: {
- stages() {
- return this.ciConfig?.stages || [];
- },
- jobs() {
- const groupedJobs = this.stages.reduce((acc, { groups, name: stageName }) => {
- return acc.concat(
- groups.map(({ jobs }) => {
- return jobs.map((job) => ({
- stage: stageName,
- ...job,
- }));
- }),
- );
- }, []);
-
- return flatten(groupedJobs);
- },
- },
-};
-</script>
-
-<template>
- <ci-lint-results
- :errors="ciConfig.errors"
- :is-valid="isValid"
- :jobs="jobs"
- :lint-help-page-path="lintHelpPagePath"
- />
-</template>
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 99ee244577e..4941f22230b 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -2,7 +2,6 @@
import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import {
CREATE_TAB,
@@ -11,7 +10,6 @@ import {
EDITOR_APP_STATUS_LOADING,
EDITOR_APP_STATUS_VALID,
EDITOR_APP_STATUS_LINT_UNAVAILABLE,
- LINT_TAB,
MERGED_TAB,
TAB_QUERY_PARAM,
TABS_INDEX,
@@ -22,7 +20,6 @@ import {
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 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';
@@ -56,7 +53,6 @@ export default {
},
tabConstants: {
CREATE_TAB,
- LINT_TAB,
MERGED_TAB,
VALIDATE_TAB,
VISUALIZE_TAB,
@@ -64,7 +60,6 @@ export default {
components: {
CiConfigMergedPreview,
CiEditorHeader,
- CiLint,
CiValidate,
EditorTab,
GlAlert,
@@ -74,7 +69,6 @@ export default {
TextEditor,
WalkthroughPopover,
},
- mixins: [glFeatureFlagsMixin()],
props: {
ciConfigData: {
type: Object,
@@ -212,7 +206,6 @@ 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"
:badge-title="validateTabBadgeTitle"
@@ -222,19 +215,6 @@ export default {
<ci-validate :ci-file-content="ciFileContent" />
</editor-tab>
<editor-tab
- v-else
- class="gl-mb-3"
- :empty-message="$options.i18n.empty.lint"
- :is-empty="isEmpty"
- :is-unavailable="isLintUnavailable"
- :title="$options.i18n.tabLint"
- data-testid="lint-tab"
- @click="setCurrentTab($options.tabConstants.LINT_TAB)"
- >
- <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" />
- <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" />
- </editor-tab>
- <editor-tab
class="gl-mb-3"
:empty-message="$options.i18n.empty.merge"
:keep-component-mounted="false"
diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
index 47673119db9..83fcab4b343 100644
--- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
+++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue
@@ -11,6 +11,8 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import { pipelineEditorTrackingOptions } from '../../constants';
import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue';
import CiLintResults from '../lint/ci_lint_results.vue';
import getBlobContent from '../../graphql/queries/blob_content.query.graphql';
@@ -70,6 +72,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'],
props: {
ciFileContent: {
@@ -110,6 +113,9 @@ export default {
};
},
computed: {
+ canResimulatePipeline() {
+ return this.hasSimulationResults && this.hasCiContentChanged;
+ },
isInitialCiContentLoading() {
return this.$apollo.queries.initialBlobContent.loading;
},
@@ -128,6 +134,10 @@ export default {
variant: this.isValid ? 'success' : 'danger',
};
},
+ trackingAction() {
+ const { actions } = pipelineEditorTrackingOptions;
+ return this.canResimulatePipeline ? actions.resimulatePipeline : actions.simulatePipeline;
+ },
},
watch: {
ciFileContent(value) {
@@ -139,7 +149,12 @@ export default {
cancelSimulation() {
this.state = VALIDATE_TAB_INIT;
},
+ trackSimulation() {
+ const { label } = pipelineEditorTrackingOptions;
+ this.track(this.trackingAction, { label });
+ },
async validateYaml() {
+ this.trackSimulation();
this.state = VALIDATE_TAB_LOADING;
try {
@@ -150,7 +165,7 @@ export default {
} = await this.$apollo.mutate({
mutation: lintCiMutation,
variables: {
- dry_run: true,
+ dry: true,
content: this.yaml,
endpoint: this.ciLintPath,
},
@@ -198,7 +213,7 @@ export default {
:aria-label="$options.i18n.help"
/>
</div>
- <div v-if="hasSimulationResults && hasCiContentChanged">
+ <div v-if="canResimulatePipeline">
<span class="gl-text-gray-400" data-testid="content-status">
{{ $options.i18n.contentChange }}
</span>
@@ -232,6 +247,7 @@ export default {
class="gl-mt-3"
:disabled="isInitialCiContentLoading"
data-testid="simulate-pipeline-button"
+ data-qa-selector="simulate_pipeline_button"
@click="validateYaml"
>
{{ $options.i18n.cta }}
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index 05db0afd15d..dd25c4d433b 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -30,7 +30,6 @@ export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
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';
@@ -38,9 +37,8 @@ export const VISUALIZE_TAB = 'VISUALIZE_TAB';
export const TABS_INDEX = {
[CREATE_TAB]: '0',
[VISUALIZE_TAB]: '1',
- [LINT_TAB]: '2',
- [VALIDATE_TAB]: '3',
- [MERGED_TAB]: '4',
+ [VALIDATE_TAB]: '2',
+ [MERGED_TAB]: '3',
};
export const TAB_QUERY_PARAM = 'tab';
@@ -77,6 +75,8 @@ export const pipelineEditorTrackingOptions = {
[CI_YAML_LINK]: 'visit_help_drawer_link_yaml',
},
openHelpDrawer: 'open_help_drawer',
+ resimulatePipeline: 'resimulate_pipeline',
+ simulatePipeline: 'simulate_pipeline',
},
};
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
index 5091d63111f..2d42ebb6ac3 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql
@@ -13,7 +13,6 @@ mutation lintCI($endpoint: String, $content: String, $dry: Boolean) {
only {
refs
}
- afterScript
stage
tags
when
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
index 77a3cdf586c..3495ca51283 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -19,9 +19,7 @@ mutation commitCIFile(
]
}
) {
- __typename
commit {
- __typename
id
sha
}
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index 4f5b69107bf..13dad0b2459 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -40,7 +40,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectFullPath,
projectPath,
projectNamespace,
- runnerHelpPagePath,
simulatePipelineHelpPagePath,
totalBranches,
validateTabIllustrationPath,
@@ -132,7 +131,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
projectFullPath,
projectPath,
projectNamespace,
- runnerHelpPagePath,
simulatePipelineHelpPagePath,
totalBranches: parseInt(totalBranches, 10),
validateTabIllustrationPath,
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index d84fc724d38..9378b67b915 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -3,10 +3,11 @@ import {
GlAlert,
GlIcon,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
- GlFormSelect,
GlFormTextarea,
GlLink,
GlSprintf,
@@ -43,10 +44,10 @@ const i18n = {
};
export default {
- typeOptions: [
- { value: VARIABLE_TYPE, text: __('Variable') },
- { value: FILE_TYPE, text: __('File') },
- ],
+ typeOptions: {
+ [VARIABLE_TYPE]: __('Variable'),
+ [FILE_TYPE]: __('File'),
+ },
i18n,
formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0',
// this height value is used inline on the textarea to match the input field height
@@ -56,10 +57,11 @@ export default {
GlAlert,
GlIcon,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlForm,
GlFormGroup,
GlFormInput,
- GlFormSelect,
GlFormTextarea,
GlLink,
GlSprintf,
@@ -202,6 +204,11 @@ export default {
});
}
},
+ setVariableType(key, type) {
+ const { variables } = this.form[this.refFullName];
+ const variable = variables.find((v) => v.key === key);
+ variable.variable_type = type;
+ },
setVariableParams(refValue, type, paramsObj) {
Object.entries(paramsObj).forEach(([key, value]) => {
this.setVariable(refValue, type, key, value);
@@ -401,12 +408,19 @@ export default {
<div
class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
>
- <gl-form-select
- v-model="variable.variable_type"
+ <gl-dropdown
+ :text="$options.typeOptions[variable.variable_type]"
:class="$options.formElementClasses"
- :options="$options.typeOptions"
data-testid="pipeline-form-ci-variable-type"
- />
+ >
+ <gl-dropdown-item
+ v-for="type in Object.keys($options.typeOptions)"
+ :key="type"
+ @click="setVariableType(variable.key, type)"
+ >
+ {{ $options.typeOptions[type] }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<gl-form-input
v-model="variable.key"
:placeholder="s__('CiVariables|Input variable key')"
diff --git a/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue b/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue
new file mode 100644
index 00000000000..7ded3945a32
--- /dev/null
+++ b/app/assets/javascripts/pipeline_schedules/components/take_ownership_modal.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ ownershipUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ modalId: 'pipeline-take-ownership-modal',
+ i18n: {
+ takeOwnership: s__('PipelineSchedules|Take ownership'),
+ ownershipMessage: s__(
+ 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?',
+ ),
+ cancelLabel: __('Cancel'),
+ },
+ computed: {
+ actionCancel() {
+ return { text: this.$options.i18n.cancelLabel };
+ },
+ actionPrimary() {
+ return {
+ text: this.$options.i18n.takeOwnership,
+ attributes: [
+ {
+ variant: 'confirm',
+ category: 'primary',
+ href: this.ownershipUrl,
+ 'data-method': 'post',
+ },
+ ],
+ };
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :modal-id="$options.modalId"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ :title="$options.i18n.takeOwnership"
+ >
+ <p>{{ $options.i18n.ownershipMessage }}</p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
index f2b159acfee..4ba5c237311 100644
--- a/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/widgets/checklist.vue
@@ -32,6 +32,11 @@ export default {
required: false,
default: false,
},
+ id: {
+ type: String,
+ required: false,
+ default: () => uniqueId('checklist_'),
+ },
},
computed: {
checklistItems() {
@@ -62,8 +67,8 @@ export default {
</script>
<template>
- <gl-form-group #default="{ ariaDescribedby }" :label="title">
- <gl-form-checkbox-group :aria-describedby="ariaDescribedby" @input="updateValidState">
+ <gl-form-group :label="title" :label-for="id">
+ <gl-form-checkbox-group :id="id" :label="title" @input="updateValidState">
<gl-form-checkbox
v-for="item in checklistItems"
:id="item.id"
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
index 939702fd1b5..79b1507ad0e 100644
--- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -49,7 +49,7 @@ export default {
<template>
<div>
<div class="gl-my-8">
- <h2 class="gl-mb-4" data-testid="title">{{ title }}</h2>
+ <h1 class="gl-mb-4" data-testid="title">{{ title }}</h1>
<p class="text-tertiary gl-font-lg gl-max-w-80" data-testid="description">
{{ description }}
</p>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 795ba91a164..8d764fad0c5 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -46,6 +46,9 @@ export default {
const { name, status } = this.group;
return `${name} - ${status.label}`;
},
+ jobGroupClasses() {
+ return [this.cssClassJobName, `job-${this.group.status.group}`];
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry('job_group_dropdown', `error: ${err}, info: ${info}`);
@@ -68,7 +71,7 @@ export default {
type="button"
data-toggle="dropdown"
data-display="static"
- :class="cssClassJobName"
+ :class="jobGroupClasses"
class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!"
>
<div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between">
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 362571930d6..377f21b299f 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -200,6 +200,9 @@ export default {
},
{ 'gl-rounded-lg': this.isBridge },
this.cssClassJobName,
+ {
+ [`job-${this.status.group}`]: this.isSingleItem,
+ },
];
},
},
diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
index ae6b9186930..fdbf0ca19bc 100644
--- a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
+++ b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
@@ -97,13 +97,16 @@ export default {
<gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" />
<template v-else>
- <gl-alert v-if="showLimitMessage" class="gl-mb-4" :dismissible="false">
- <p>{{ $options.i18n.insightsLimit }}</p>
+ <gl-alert class="gl-mb-4" :dismissible="false">
+ <p v-if="showLimitMessage" data-testid="limit-alert-text">
+ {{ $options.i18n.insightsLimit }}
+ </p>
<gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/365902" class="gl-mt-5">
{{ $options.i18n.feeback }}
</gl-link>
</gl-alert>
- <div class="gl-display-flex gl-justify-content-space-between gl-mb-7">
+
+ <div class="gl-display-flex gl-justify-content-space-between gl-mt-2 gl-mb-7">
<gl-card class="gl-w-half gl-mr-7 gl-text-center">
<template #header>
<span class="gl-font-weight-bold">{{ $options.i18n.queuedCardHeader }}</span>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
index e1745969649..df59962569e 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue
@@ -34,7 +34,13 @@ export default {
PipelineGraphWrapper,
TestReports,
},
- inject: ['defaultTabValue', 'failedJobsCount', 'failedJobsSummary', 'totalJobCount'],
+ inject: [
+ 'defaultTabValue',
+ 'failedJobsCount',
+ 'failedJobsSummary',
+ 'totalJobCount',
+ 'testsCount',
+ ],
computed: {
showFailedJobsTab() {
return this.failedJobsCount > 0;
@@ -81,11 +87,11 @@ export default {
</template>
<failed-jobs-app :failed-jobs-summary="failedJobsSummary" />
</gl-tab>
- <gl-tab
- :title="$options.i18n.tabs.testsTitle"
- :active="isActive($options.tabNames.tests)"
- data-testid="tests-tab"
- >
+ <gl-tab :active="isActive($options.tabNames.tests)" data-testid="tests-tab" lazy>
+ <template #title>
+ <span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span>
+ <gl-badge size="sm" data-testid="tests-counter">{{ testsCount }}</gl-badge>
+ </template>
<test-reports />
</gl-tab>
<slot></slot>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
index 64d4414eb94..439dc0eb253 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue
@@ -32,9 +32,10 @@ export default {
.map(({ name, logo, title }) => {
return {
name: title || name,
+ description: sprintf(this.$options.i18n.description, { name: title || name }),
+ isPng: logo.endsWith('png'),
logo,
link: mergeUrlParams({ template: name }, this.pipelineEditorPath),
- description: sprintf(this.$options.i18n.description, { name: title || name }),
};
});
@@ -48,6 +49,9 @@ export default {
label: template,
});
},
+ logoStyle(template) {
+ return template.isPng ? { objectFit: 'contain' } : '';
+ },
},
i18n: {
description: s__(
@@ -66,11 +70,13 @@ export default {
>
<div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
<gl-avatar
- :src="template.logo"
- :size="48"
+ :alt="template.name"
class="gl-mr-5 gl-bg-white dark-mode-override"
+ :class="{ 'gl-p-2': template.isPng }"
+ :style="logoStyle(template)"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
- :alt="template.name"
+ :size="48"
+ :src="template.logo"
data-testid="template-logo"
/>
<div class="gl-flex-direction-row">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index 9725e882d5e..05a1ceface3 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -3,16 +3,16 @@ import {
GlAlert,
GlDropdown,
GlDropdownItem,
- GlDropdownSectionHeader,
+ GlSearchBoxByType,
GlLoadingIcon,
GlTooltipDirective,
} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
export const i18n = {
- artifacts: __('Artifacts'),
- artifactSectionHeader: __('Download artifacts'),
+ downloadArtifacts: __('Download artifacts'),
artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'),
emptyArtifactsMessage: __('No artifacts found'),
};
@@ -26,7 +26,7 @@ export default {
GlAlert,
GlDropdown,
GlDropdownItem,
- GlDropdownSectionHeader,
+ GlSearchBoxByType,
GlLoadingIcon,
},
inject: {
@@ -48,8 +48,16 @@ export default {
artifacts: [],
hasError: false,
isLoading: false,
+ searchQuery: '',
};
},
+ computed: {
+ filteredArtifacts() {
+ return this.searchQuery.length > 0
+ ? fuzzaldrinPlus.filter(this.artifacts, this.searchQuery, { key: 'name' })
+ : this.artifacts;
+ },
+ },
methods: {
fetchArtifacts() {
this.isLoading = true;
@@ -70,27 +78,27 @@ export default {
this.isLoading = false;
});
},
+ handleDropdownShown() {
+ this.$refs.searchInput.focusInput();
+ },
},
};
</script>
<template>
<gl-dropdown
v-gl-tooltip
- :title="$options.i18n.artifacts"
- :text="$options.i18n.artifacts"
- :aria-label="$options.i18n.artifacts"
- icon="ellipsis_v"
+ :title="$options.i18n.downloadArtifacts"
+ :text="$options.i18n.downloadArtifacts"
+ :aria-label="$options.i18n.downloadArtifacts"
+ :header-text="$options.i18n.downloadArtifacts"
+ icon="download"
data-testid="pipeline-multi-actions-dropdown"
right
lazy
text-sr-only
- no-caret
@show.once="fetchArtifacts"
+ @shown="handleDropdownShown"
>
- <gl-dropdown-section-header>{{
- $options.i18n.artifactSectionHeader
- }}</gl-dropdown-section-header>
-
<gl-alert v-if="hasError" variant="danger" :dismissible="false">
{{ $options.i18n.artifactsFetchErrorMessage }}
</gl-alert>
@@ -101,8 +109,12 @@ export default {
{{ $options.i18n.emptyArtifactsMessage }}
</gl-dropdown-item>
+ <template #header>
+ <gl-search-box-by-type v-if="artifacts.length" ref="searchInput" v-model.trim="searchQuery" />
+ </template>
+
<gl-dropdown-item
- v-for="(artifact, i) in artifacts"
+ v-for="(artifact, i) in filteredArtifacts"
:key="i"
:href="artifact.path"
rel="nofollow"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 3fb46a4f128..e5666f7a658 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createTestReportsStore from '../../stores/test_reports';
import EmptyState from './empty_state.vue';
import TestSuiteTable from './test_suite_table.vue';
@@ -16,6 +17,7 @@ export default {
TestSummary,
TestSummaryTable,
},
+ mixins: [glFeatureFlagMixin()],
inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'],
computed: {
...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']),
@@ -29,14 +31,16 @@ export default {
},
},
created() {
- this.$store.registerModule(
- 'testReports',
- createTestReportsStore({
- blobPath: this.blobPath,
- summaryEndpoint: this.summaryEndpoint,
- suiteEndpoint: this.suiteEndpoint,
- }),
- );
+ if (!this.glFeatures.pipelineTabsVue) {
+ this.$store.registerModule(
+ 'testReports',
+ createTestReportsStore({
+ blobPath: this.blobPath,
+ summaryEndpoint: this.summaryEndpoint,
+ suiteEndpoint: this.suiteEndpoint,
+ }),
+ );
+ }
this.fetchSummary();
},
@@ -74,7 +78,7 @@ export default {
<div
v-else-if="!isLoading && showTests"
ref="container"
- class="position-relative"
+ class="gl-relative"
data-testid="tests-detail"
>
<transition
@@ -82,13 +86,13 @@ export default {
@before-enter="beforeEnterTransition"
@after-leave="afterLeaveTransition"
>
- <div v-if="showSuite" key="detail" class="w-100 slide-enter-to-element">
+ <div v-if="showSuite" key="detail" class="gl-w-full slide-enter-to-element">
<test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" />
<test-suite-table />
</div>
- <div v-else key="summary" class="w-100 slide-enter-from-element">
+ <div v-else key="summary" class="gl-w-full slide-enter-from-element">
<test-summary :report="testReports" />
<test-summary-table @row-click="summaryTableRowClick" />
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 1f438c63fee..7d0f1ba4b5f 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
@@ -80,7 +80,10 @@ export default {
<h4>{{ heading }}</h4>
</div>
</div>
- <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray">
+ <div
+ role="row"
+ class="gl-responsive-table-row table-row-header gl-font-weight-bold gl-fill-gray-700"
+ >
<div role="rowheader" class="table-section section-20">
{{ __('Suite') }}
</div>
@@ -104,7 +107,7 @@ export default {
<div
v-for="(testCase, index) in getSuiteTests"
:key="index"
- class="gl-responsive-table-row rounded align-items-md-start"
+ class="gl-responsive-table-row gl-rounded-base gl-align-items-flex-start"
data-testid="test-case-row"
>
<div class="table-section section-20 section-wrap">
@@ -142,11 +145,8 @@ export default {
<div class="table-section section-10 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div>
- <div class="table-mobile-content text-center">
- <div
- class="ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center"
- :class="`ci-status-icon-${testCase.status}`"
- >
+ <div class="table-mobile-content gl-md-display-flex gl-justify-content-center">
+ <div class="ci-status-icon" :class="`ci-status-icon-${testCase.status}`">
<gl-icon :size="24" :name="testCase.icon" />
</div>
</div>
@@ -156,7 +156,7 @@ export default {
<div role="rowheader" class="table-mobile-header">
{{ __('Duration') }}
</div>
- <div class="table-mobile-content pr-sm-1">
+ <div class="table-mobile-content gl-sm-pr-2">
{{ testCase.formattedTime }}
</div>
</div>
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 2f5301715c3..6b723ad5481 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -65,58 +65,53 @@ export default {
<template>
<div>
- <div class="row">
- <div class="col-12 d-flex gl-mt-3 align-items-center">
- <gl-button
- v-if="showBack"
- size="small"
- class="gl-mr-3 js-back-button"
- icon="chevron-lg-left"
- :aria-label="__('Go back')"
- @click="onBackClick"
- />
+ <div class="gl-w-full gl-display-flex gl-mt-3 gl-align-items-center">
+ <gl-button
+ v-if="showBack"
+ size="small"
+ class="gl-mr-3 js-back-button"
+ icon="chevron-lg-left"
+ :aria-label="__('Go back')"
+ @click="onBackClick"
+ />
- <h4>{{ heading }}</h4>
- </div>
+ <h4>{{ heading }}</h4>
</div>
- <div class="row mt-2">
- <div class="col-4 col-md">
- <span class="js-total-tests">{{
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-w-full gl-mt-3"
+ >
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-basis-half">
+ <span class="js-total-tests gl-flex-grow-1">{{
sprintf(s__('TestReports|%{count} tests'), { count: report.total_count })
}}</span>
- </div>
- <div class="col-4 col-md text-center text-md-center">
- <span class="js-failed-tests">{{
+ <span class="js-failed-tests gl-flex-grow-1">{{
sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count })
}}</span>
- </div>
- <div class="col-4 col-md text-right text-md-center">
<span class="js-errored-tests">{{
sprintf(s__('TestReports|%{count} errors'), { count: report.error_count })
}}</span>
</div>
-
- <div class="col-6 mt-3 col-md mt-md-0 text-md-center">
- <span class="js-success-rate">{{
+ <div class="gl-display-flex gl-justify-content-space-between gl-flex-grow-1">
+ <div class="gl-display-none gl-md-display-block gl-flex-grow-1"></div>
+ <span class="js-success-rate gl-flex-grow-1">{{
sprintf(s__('TestReports|%{rate}%{sign} success rate'), {
rate: successPercentage,
sign: '%',
})
}}</span>
- </div>
- <div class="col-6 mt-3 col-md mt-md-0 text-right">
<span class="js-duration">{{ formattedDuration }}</span>
</div>
</div>
- <div class="row mt-3">
- <div class="col-12">
- <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" />
- </div>
- </div>
+ <gl-progress-bar
+ class="gl-mt-5"
+ :value="successPercentage"
+ :variant="progressBarVariant"
+ height="10px"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 8389c2a5104..7ab48da1a9d 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -34,33 +34,31 @@ export default {
<template>
<div>
- <div class="row gl-mt-3">
- <div class="col-12">
- <h4>{{ heading }}</h4>
- </div>
+ <div class="gl-mt-5">
+ <h4>{{ heading }}</h4>
</div>
- <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-suites-table">
- <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold">
- <div role="rowheader" class="table-section section-25 pl-3">
+ <div v-if="hasSuites" class="js-test-suites-table">
+ <div role="row" class="gl-responsive-table-row table-row-header gl-font-weight-bold">
+ <div role="rowheader" class="table-section section-25 gl-pl-5">
{{ __('Job') }}
</div>
<div role="rowheader" class="table-section section-25">
{{ __('Duration') }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Failed') }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Errors'), }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Skipped'), }}
</div>
- <div role="rowheader" class="table-section section-10 text-center">
+ <div role="rowheader" class="table-section section-10 gl-text-center">
{{ __('Passed'), }}
</div>
- <div role="rowheader" class="table-section section-10 pr-3 text-right">
+ <div role="rowheader" class="table-section section-10 gl-pr-5 gl-text-right">
{{ __('Total') }}
</div>
</div>
@@ -69,17 +67,17 @@ export default {
v-for="(testSuite, index) in getTestSuites"
:key="index"
role="row"
- class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row"
+ class="gl-responsive-table-row gl-rounded-base js-suite-row"
:class="{
- 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error,
+ 'gl-responsive-table-row-clickable gl-cursor-pointer': !testSuite.suite_error,
}"
@click="tableRowClick(index)"
>
<div class="table-section section-25">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Suite') }}
</div>
- <div class="table-mobile-content underline cgray pl-3">
+ <div class="table-mobile-content underline gl-text-gray-900 gl-pl-5">
{{ testSuite.name }}
<gl-icon
v-if="testSuite.suite_error"
@@ -93,44 +91,44 @@ export default {
</div>
<div class="table-section section-25">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Duration') }}
</div>
- <div class="table-mobile-content text-md-left">
+ <div class="table-mobile-content gl-text-left">
{{ testSuite.formattedTime }}
</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Failed') }}
</div>
<div class="table-mobile-content">{{ testSuite.failed_count }}</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Errors') }}
</div>
<div class="table-mobile-content">{{ testSuite.error_count }}</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Skipped') }}
</div>
<div class="table-mobile-content">{{ testSuite.skipped_count }}</div>
</div>
- <div class="table-section section-10 text-center">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-center">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Passed') }}
</div>
<div class="table-mobile-content">{{ testSuite.success_count }}</div>
</div>
- <div class="table-section section-10 text-right pr-md-3">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
+ <div class="table-section section-10 gl-text-right pr-md-3">
+ <div role="rowheader" class="table-mobile-header gl-font-weight-bold">
{{ __('Total') }}
</div>
<div class="table-mobile-content">{{ testSuite.total_count }}</div>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 2e825016c91..7b38f870cb6 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -83,7 +83,7 @@ export const PipelineKeyOptions = [
export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.');
-export const BUTTON_TOOLTIP_RETRY = __('Retry failed jobs');
+export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs');
export const BUTTON_TOOLTIP_CANCEL = __('Cancel');
export const DEFAULT_FIELDS = [
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index c4f7665c91d..e8e49cc652e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -1,5 +1,6 @@
import Visibility from 'visibilityjs';
-import createFlash from '~/flash';
+import createFlash, { createAlert } from '~/flash';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import Poll from '~/lib/utils/poll';
@@ -198,18 +199,20 @@ export default {
})
.catch((e) => {
const unauthorized = e.response.status === httpStatusCodes.UNAUTHORIZED;
- const badRequest = e.response.status === httpStatusCodes.BAD_REQUEST;
-
let errorMessage = __(
'An error occurred while trying to run a new pipeline for this merge request.',
);
- if (unauthorized || badRequest) {
+ if (unauthorized) {
errorMessage = __('You do not have permission to run a pipeline on this branch.');
}
- createFlash({
+ createAlert({
message: errorMessage,
+ primaryButton: {
+ text: __('Learn more'),
+ link: helpPagePath('ci/pipelines/merge_request_pipelines.md'),
+ },
});
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index c0e769e2485..7051d356089 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -5,6 +5,7 @@ import PipelineTabs from 'ee_else_ce/pipelines/components/pipeline_tabs.vue';
import { removeParams, updateHistory } from '~/lib/utils/url_utility';
import { TAB_QUERY_PARAM } from '~/pipelines/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
+import createTestReportsStore from './stores/test_reports';
import { getPipelineDefaultTab, reportToSentry } from './utils';
Vue.use(VueApollo);
@@ -29,6 +30,17 @@ export const createAppOptions = (selector, apolloProvider) => {
pipelineIid,
pipelineProjectPath,
totalJobCount,
+ licenseManagementApiUrl,
+ licenseManagementSettingsPath,
+ licensesApiPath,
+ canManageLicenses,
+ summaryEndpoint,
+ suiteEndpoint,
+ blobPath,
+ hasTestReport,
+ emptyStateImagePath,
+ artifactsExpiredImagePath,
+ testsCount,
} = dataset;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
@@ -39,7 +51,15 @@ export const createAppOptions = (selector, apolloProvider) => {
PipelineTabs,
},
apolloProvider,
- store: new Vuex.Store(),
+ store: new Vuex.Store({
+ modules: {
+ testReports: createTestReportsStore({
+ blobPath,
+ summaryEndpoint,
+ suiteEndpoint,
+ }),
+ },
+ }),
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
@@ -54,6 +74,17 @@ export const createAppOptions = (selector, apolloProvider) => {
pipelineIid,
pipelineProjectPath,
totalJobCount,
+ licenseManagementApiUrl,
+ licenseManagementSettingsPath,
+ licensesApiPath,
+ canManageLicenses: parseBoolean(canManageLicenses),
+ summaryEndpoint,
+ suiteEndpoint,
+ blobPath,
+ hasTestReport,
+ emptyStateImagePath,
+ artifactsExpiredImagePath,
+ testsCount,
},
errorCaptured(err, _vm, info) {
reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`);
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index f0556f3d12e..b785fd1753c 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -30,7 +30,6 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => {
dispatch('toggleLoading');
- // eslint-disable-next-line camelcase
const { build_ids = [] } = state.testReports?.test_suites?.[index] || {};
// Replacing `/:suite_name.json` with the name of the suite. Including the extra characters
// to ensure that we replace exactly the template part of the URL string
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue
index bda58091b97..4ba7156b026 100644
--- a/app/assets/javascripts/projects/compare/components/app.vue
+++ b/app/assets/javascripts/projects/compare/components/app.vue
@@ -15,7 +15,11 @@ export default {
type: String,
required: true,
},
- refsProjectPath: {
+ sourceProjectRefsPath: {
+ type: String,
+ required: true,
+ },
+ targetProjectRefsPath: {
type: String,
required: true,
},
@@ -37,7 +41,11 @@ export default {
type: String,
required: true,
},
- defaultProject: {
+ sourceProject: {
+ type: Object,
+ required: true,
+ },
+ targetProject: {
type: Object,
required: true,
},
@@ -50,14 +58,14 @@ export default {
return {
from: {
projects: this.projects,
- selectedProject: this.defaultProject,
+ selectedProject: this.targetProject,
revision: this.paramsFrom,
- refsProjectPath: this.refsProjectPath,
+ refsProjectPath: this.targetProjectRefsPath,
},
to: {
- selectedProject: this.defaultProject,
+ selectedProject: this.sourceProject,
revision: this.paramsTo,
- refsProjectPath: this.refsProjectPath,
+ refsProjectPath: this.sourceProjectRefsPath,
},
};
},
diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js
index e485a086d39..074b8565c3c 100644
--- a/app/assets/javascripts/projects/compare/index.js
+++ b/app/assets/javascripts/projects/compare/index.js
@@ -5,13 +5,15 @@ export default function init() {
const el = document.getElementById('js-compare-selector');
const {
- refsProjectPath,
+ sourceProjectRefsPath,
+ targetProjectRefsPath,
paramsFrom,
paramsTo,
projectCompareIndexPath,
projectMergeRequestPath,
createMrPath,
- projectTo,
+ sourceProject,
+ targetProject,
projectsFrom,
} = el.dataset;
@@ -23,13 +25,15 @@ export default function init() {
render(createElement) {
return createElement(CompareApp, {
props: {
- refsProjectPath,
+ sourceProjectRefsPath,
+ targetProjectRefsPath,
paramsFrom,
paramsTo,
projectCompareIndexPath,
projectMergeRequestPath,
createMrPath,
- defaultProject: JSON.parse(projectTo),
+ sourceProject: JSON.parse(sourceProject),
+ targetProject: JSON.parse(targetProject),
projects: JSON.parse(projectsFrom),
},
});
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 28b77f6defd..0cfea401be6 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -17,6 +17,7 @@ const mountPipelineChartsApp = (el) => {
coverageChartPath,
defaultBranch,
testRunsEmptyStateImagePath,
+ projectQualitySummaryFeedbackImagePath,
} = el.dataset;
const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts);
@@ -37,6 +38,7 @@ const mountPipelineChartsApp = (el) => {
coverageChartPath,
defaultBranch,
testRunsEmptyStateImagePath,
+ projectQualitySummaryFeedbackImagePath,
},
render: (createElement) => createElement(ProjectPipelinesCharts, {}),
});
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index fe84660422b..424ea3b61c5 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'any_else_ce/projects/default_project_templates';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import Tracking from '~/tracking';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants';
import { ENTER_KEY } from '../lib/utils/keys';
import axios from '../lib/utils/axios_utils';
@@ -109,8 +110,31 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
);
};
+ const projectPathValueListener = () => {
+ // eslint-disable-next-line no-param-reassign
+ $projectPathInput.oldInputValue = $projectPathInput.value;
+ };
+
+ const projectPathTrackListener = () => {
+ if ($projectPathInput.oldInputValue === $projectPathInput.value) {
+ // no change made to the input
+ return;
+ }
+
+ const trackEvent = 'user_input_path_slug';
+ const trackCategory = undefined; // will be default set in event method
+
+ Tracking.event(trackCategory, trackEvent, {
+ label: 'new_project_form',
+ });
+ };
+
$projectPathInput.removeEventListener('keyup', projectPathInputListener);
$projectPathInput.addEventListener('keyup', projectPathInputListener);
+ $projectPathInput.removeEventListener('focus', projectPathValueListener);
+ $projectPathInput.addEventListener('focus', projectPathValueListener);
+ $projectPathInput.removeEventListener('blur', projectPathTrackListener);
+ $projectPathInput.addEventListener('blur', projectPathTrackListener);
$projectPathInput.removeEventListener('change', projectPathInputListener);
$projectPathInput.addEventListener('change', projectPathInputListener);
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
index 6bbe0ab7d5f..6ba2ef7da99 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue
@@ -1,12 +1,24 @@
<script>
-import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlSearchBoxByType,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
import { createAlert } from '~/flash';
-import { __, sprintf } from '~/locale';
+import { s__, sprintf } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import branchesQuery from '../queries/branches.query.graphql';
export const i18n = {
- fetchBranchesError: __('An error occurred while fetching branches.'),
- noMatch: __('No matching results'),
+ fetchBranchesError: s__('BranchRules|An error occurred while fetching branches.'),
+ noMatch: s__('BranchRules|No matching results'),
+ branchHelpText: s__(
+ 'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/* are supported.',
+ ),
+ wildCardSearchHelp: s__('BranchRules|Create wildcard: %{searchTerm}'),
};
export default {
@@ -17,6 +29,8 @@ export default {
GlDropdownItem,
GlDropdownDivider,
GlSearchBoxByType,
+ GlSprintf,
+ GlLink,
},
apollo: {
branchNames: {
@@ -39,6 +53,10 @@ export default {
},
},
},
+ searchInputDelay: 250,
+ wildcardsHelpPath: helpPagePath('user/project/protected_branches', {
+ anchor: 'configure-multiple-protected-branches-by-using-a-wildcard',
+ }),
props: {
projectPath: {
type: String,
@@ -58,7 +76,9 @@ export default {
},
computed: {
createButtonLabel() {
- return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm });
+ return sprintf(this.$options.i18n.wildCardSearchHelp, {
+ searchTerm: this.searchTerm,
+ });
},
shouldRenderCreateButton() {
return this.searchTerm && !this.branchNames.includes(this.searchTerm);
@@ -81,30 +101,37 @@ export default {
};
</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 }}
+ <div>
+ <gl-dropdown :text="value || branchNames[0]" class="gl-w-full">
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ data-testid="branch-search"
+ :debounce="$options.searchInputDelay"
+ :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>
- </template>
- </gl-dropdown>
+ <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>
+ <gl-sprintf :message="$options.i18n.branchHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.wildcardsHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue
new file mode 100644
index 00000000000..bcc0f64d667
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import PushProtections from './push_protections.vue';
+import MergeProtections from './merge_protections.vue';
+
+export const i18n = {
+ protections: s__('BranchRules|Protections'),
+ protectionsHelpText: s__(
+ 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}',
+ ),
+};
+
+export default {
+ name: 'BranchProtections',
+ i18n,
+ components: {
+ GlSprintf,
+ GlLink,
+ PushProtections,
+ MergeProtections,
+ },
+ protectedBranchesHelpPath: helpPagePath('user/project/protected_branches'),
+ props: {
+ protections: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h4 class="gl-border-t gl-pt-4">{{ $options.i18n.protections }}</h4>
+
+ <div data-testid="protections-help-text">
+ <gl-sprintf :message="$options.i18n.protectionsHelpText">
+ <template #link="{ content }">
+ <gl-link :href="$options.protectedBranchesHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
+ <push-protections
+ class="gl-mt-5"
+ :members-allowed-to-push="protections.membersAllowedToPush"
+ :allow-force-push="protections.allowForcePush"
+ v-on="$listeners"
+ />
+
+ <merge-protections
+ :members-allowed-to-merge="protections.membersAllowedToMerge"
+ :require-code-owners-approval="protections.requireCodeOwnersApproval"
+ v-on="$listeners"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue
new file mode 100644
index 00000000000..85f168af4a8
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export const i18n = {
+ allowedToMerge: s__('BranchRules|Allowed to merge'),
+ requireApprovalTitle: s__('BranchRules|Require approval from code owners.'),
+ requireApprovalHelpText: s__(
+ 'BranchRules|Reject code pushes that change files listed in the CODEOWNERS file.',
+ ),
+};
+
+export default {
+ name: 'BranchMergeProtections',
+ i18n,
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ },
+ props: {
+ membersAllowedToMerge: {
+ type: Array,
+ required: true,
+ },
+ requireCodeOwnersApproval: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.allowedToMerge">
+ <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+
+ <gl-form-checkbox
+ class="gl-mt-5"
+ :checked="requireCodeOwnersApproval"
+ @change="$emit('change-require-code-owners-approval', $event)"
+ >
+ <span>{{ $options.i18n.requireApprovalTitle }}</span>
+ <template #help>{{ $options.i18n.requireApprovalHelpText }}</template>
+ </gl-form-checkbox>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue
new file mode 100644
index 00000000000..541923bb735
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlFormGroup, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
+
+export const i18n = {
+ allowedToPush: s__('BranchRules|Allowed to push'),
+ forcePushTitle: s__(
+ 'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.',
+ ),
+};
+
+export default {
+ name: 'BranchPushProtections',
+ i18n,
+ components: {
+ GlFormGroup,
+ GlSprintf,
+ GlLink,
+ GlFormCheckbox,
+ },
+ forcePushHelpPath: helpPagePath('topics/git/git_rebase', { anchor: 'force-push' }),
+ props: {
+ membersAllowedToPush: {
+ type: Array,
+ required: true,
+ },
+ allowForcePush: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :label="$options.i18n.allowedToPush">
+ <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+
+ <gl-form-checkbox
+ class="gl-mt-5"
+ :checked="allowForcePush"
+ @change="$emit('change-allow-force-push', $event)"
+ >
+ <gl-sprintf :message="$options.i18n.forcePushTitle">
+ <template #link="{ content }">
+ <gl-link :href="$options.forcePushHelpPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-form-checkbox>
+ </gl-form-group>
+</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
index c2e7f4e9b1b..ad3eb7d2899 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue
@@ -1,15 +1,18 @@
<script>
import { GlFormGroup } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import { getParameterByName } from '~/lib/utils/url_utility';
import BranchDropdown from './branch_dropdown.vue';
+import Protections from './protections/index.vue';
export default {
name: 'RuleEdit',
- i18n: {
- branch: __('Branch'),
+ i18n: { branch: s__('BranchRules|Branch') },
+ components: {
+ BranchDropdown,
+ GlFormGroup,
+ Protections,
},
- components: { BranchDropdown, GlFormGroup },
props: {
projectPath: {
type: String,
@@ -19,20 +22,35 @@ export default {
data() {
return {
branch: getParameterByName('branch'),
+ protections: {
+ membersAllowedToPush: [],
+ allowForcePush: false,
+ membersAllowedToMerge: [],
+ requireCodeOwnersApproval: false,
+ },
};
},
};
</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"
+ <div>
+ <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>
+
+ <protections
+ :protections="protections"
+ @change-allowed-to-push-members="protections.membersAllowedToPush = $event"
+ @change-allow-force-push="protections.allowForcePush = $event"
+ @change-allowed-to-merge-members="protections.membersAllowedToMerge = $event"
+ @change-require-code-owners-approval="protections.requireCodeOwnersApproval = $event"
/>
- </gl-form-group>
- <!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) -->
+ </div>
</template>
diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
index fcf81c9d1f7..2209172c06d 100644
--- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue
+++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue
@@ -262,8 +262,8 @@ export default {
const selectedUsers = this.preselectedItems
.filter(({ type }) => type === LEVEL_TYPES.USER)
- .map(({ user_id, name, username, avatar_url, type }) => ({
- id: user_id,
+ .map(({ user_id: id, name, username, avatar_url, type }) => ({
+ id,
name,
username,
avatar_url,
diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
index fe968e74c6d..c13753da00b 100644
--- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
+++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue
@@ -1,7 +1,13 @@
<script>
import { GlFormGroup } from '@gitlab/ui';
-import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import produce from 'immer';
import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue';
+import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchNamespacesWhereUserCanTransferProjects from '../graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql';
+
+const GROUPS_PER_PAGE = 25;
export default {
name: 'TransferProjectForm',
@@ -11,14 +17,6 @@ export default {
ConfirmDanger,
},
props: {
- groupNamespaces: {
- type: Array,
- required: true,
- },
- userNamespaces: {
- type: Array,
- required: true,
- },
confirmationPhrase: {
type: String,
required: true,
@@ -28,19 +26,88 @@ export default {
required: true,
},
},
+ apollo: {
+ currentUser: {
+ query: searchNamespacesWhereUserCanTransferProjects,
+ debounce: DEBOUNCE_DELAY,
+ variables() {
+ return {
+ search: this.searchTerm,
+ after: null,
+ first: GROUPS_PER_PAGE,
+ };
+ },
+ result() {
+ this.isLoadingMoreGroups = false;
+ this.isSearchLoading = false;
+ },
+ },
+ },
data() {
- return { selectedNamespace: null };
+ return {
+ currentUser: {},
+ selectedNamespace: null,
+ isLoadingMoreGroups: false,
+ isSearchLoading: false,
+ searchTerm: '',
+ };
},
computed: {
hasSelectedNamespace() {
return Boolean(this.selectedNamespace?.id);
},
+ groupNamespaces() {
+ return this.currentUser.groups?.nodes?.map(this.formatNamespace) || [];
+ },
+ userNamespaces() {
+ const { namespace } = this.currentUser;
+
+ return namespace ? [this.formatNamespace(namespace)] : [];
+ },
+ hasNextPageOfGroups() {
+ return this.currentUser.groups?.pageInfo?.hasNextPage || false;
+ },
},
methods: {
handleSelect(selectedNamespace) {
this.selectedNamespace = selectedNamespace;
this.$emit('selectNamespace', selectedNamespace.id);
},
+ handleLoadMoreGroups() {
+ this.isLoadingMoreGroups = true;
+
+ this.$apollo.queries.currentUser.fetchMore({
+ variables: {
+ after: this.currentUser.groups.pageInfo.endCursor,
+ first: GROUPS_PER_PAGE,
+ },
+ updateQuery(
+ previousResult,
+ {
+ fetchMoreResult: {
+ currentUser: { groups: newGroups },
+ },
+ },
+ ) {
+ const previousGroups = previousResult.currentUser.groups;
+
+ return produce(previousResult, (draftData) => {
+ draftData.currentUser.groups.nodes = [...previousGroups.nodes, ...newGroups.nodes];
+ draftData.currentUser.groups.pageInfo = newGroups.pageInfo;
+ });
+ },
+ });
+ },
+ handleSearch(searchTerm) {
+ this.isSearchLoading = true;
+ this.searchTerm = searchTerm;
+ },
+ formatNamespace({ id, fullName }) {
+ return {
+ id: getIdFromGraphQLId(id),
+ humanName: fullName,
+ };
+ },
},
};
</script>
@@ -53,11 +120,16 @@ export default {
:group-namespaces="groupNamespaces"
:user-namespaces="userNamespaces"
:selected-namespace="selectedNamespace"
+ :has-next-page-of-groups="hasNextPageOfGroups"
+ :is-loading-more-groups="isLoadingMoreGroups"
+ :is-search-loading="isSearchLoading"
+ :should-filter-namespaces="false"
@select="handleSelect"
+ @load-more-groups="handleLoadMoreGroups"
+ @search="handleSearch"
/>
</gl-form-group>
<confirm-danger
- button-class="qa-transfer-button"
:disabled="!hasSelectedNamespace"
:phrase="confirmationPhrase"
:button-text="confirmButtonText"
diff --git a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql
new file mode 100644
index 00000000000..d4bcb8c869c
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql
@@ -0,0 +1,24 @@
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+query searchNamespacesWhereUserCanTransferProjects(
+ $search: String = ""
+ $after: String = ""
+ $first: Int = null
+) {
+ currentUser {
+ id
+ groups(permissionScope: TRANSFER_PROJECTS, search: $search, after: $after, first: $first) {
+ nodes {
+ id
+ fullName
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ namespace {
+ id
+ fullName
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
index a5f720bffaa..bc1aff640d2 100644
--- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js
+++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js
@@ -1,36 +1,29 @@
import Vue from 'vue';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import TransferProjectForm from './components/transfer_project_form.vue';
-const prepareNamespaces = (rawNamespaces = '') => {
- if (!rawNamespaces) {
- return { groupNamespaces: [], userNamespaces: [] };
- }
-
- const data = JSON.parse(rawNamespaces);
- return {
- groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [],
- userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [],
- };
-};
-
export default () => {
const el = document.querySelector('.js-transfer-project-form');
if (!el) {
return false;
}
+ Vue.use(VueApollo);
+
const {
targetFormId = null,
targetHiddenInputId = null,
buttonText: confirmButtonText = '',
phrase: confirmationPhrase = '',
confirmDangerMessage = '',
- namespaces = '',
} = el.dataset;
return new Vue({
el,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
confirmDangerMessage,
},
@@ -39,7 +32,6 @@ export default () => {
props: {
confirmButtonText,
confirmationPhrase,
- ...prepareNamespaces(namespaces),
},
on: {
selectNamespace: (id) => {
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index d02526160fd..1343ad8246c 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -256,6 +256,7 @@ export default {
:error-message="i18n.branchesErrorMessage"
:show-header="showSectionHeaders"
data-testid="branches-section"
+ data-qa-selector="branches_section"
@selected="selectRef($event)"
/>
diff --git a/app/assets/javascripts/related_issues/components/add_issuable_form.vue b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
index d765033d00b..102f1228355 100644
--- a/app/assets/javascripts/related_issues/components/add_issuable_form.vue
+++ b/app/assets/javascripts/related_issues/components/add_issuable_form.vue
@@ -208,7 +208,7 @@ export default {
<p v-if="hasError" class="gl-field-error">
{{ addRelatedErrorMessage }}
</p>
- <div class="gl-mt-5 gl-clearfix">
+ <div class="gl-mt-5">
<gl-button
ref="addButton"
category="primary"
@@ -216,12 +216,13 @@ export default {
:disabled="isSubmitButtonDisabled"
:loading="isSubmitting"
type="submit"
- class="gl-float-left"
+ size="small"
+ class="gl-mr-2"
data-qa-selector="add_issue_button"
>
{{ __('Add') }}
</gl-button>
- <gl-button class="gl-float-right" @click="onFormCancel">
+ <gl-button size="small" @click="onFormCancel">
{{ __('Cancel') }}
</gl-button>
</div>
diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue
index eeb4c254a1b..5b4a6d1fe0d 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -1,5 +1,6 @@
<script>
import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import {
issuableIconMap,
issuableQaClassMap,
@@ -96,6 +97,11 @@ export default {
default: true,
},
},
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
computed: {
hasRelatedIssues() {
return this.relatedIssues.length > 0;
@@ -139,6 +145,21 @@ export default {
qaClass() {
return issuableQaClassMap[this.issuableType];
},
+ toggleIcon() {
+ return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
+ },
+ toggleLabel() {
+ return this.isOpen ? __('Collapse') : __('Expand');
+ },
+ },
+ methods: {
+ handleToggle() {
+ this.isOpen = !this.isOpen;
+ },
+ addButtonClick(event) {
+ this.isOpen = true;
+ this.$emit('toggleAddRelatedIssuesForm', event);
+ },
},
linkedIssueTypesTextMap,
};
@@ -148,12 +169,10 @@ export default {
<div id="related-issues" class="related-issues-block gl-mt-5">
<div class="card card-slim gl-overflow-hidden">
<div
- :class="{ 'panel-empty-heading border-bottom-0': !hasBody }"
- class="card-header gl-display-flex gl-justify-content-space-between"
+ :class="{ 'panel-empty-heading border-bottom-0': !hasBody, 'gl-border-b-0': !isOpen }"
+ class="gl-display-flex gl-justify-content-space-between gl-line-height-24 gl-py-3 gl-px-5 gl-bg-gray-10 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
- <h3
- class="card-title h5 position-relative gl-my-0 gl-display-flex gl-align-items-center gl-h-7"
- >
+ <h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
<gl-link
id="user-content-related-issues"
class="anchor position-absolute gl-text-decoration-none"
@@ -172,30 +191,45 @@ export default {
<gl-icon name="question" :size="12" />
</gl-link>
- <div class="gl-display-inline-flex">
- <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-5">
- <span class="gl-display-inline-flex gl-align-items-center">
- <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
- {{ badgeLabel }}
- </span>
- </div>
- <gl-button
- v-if="canAdmin"
- data-qa-selector="related_issues_plus_button"
- icon="plus"
- :aria-label="addIssuableButtonText"
- :class="qaClass"
- @click="$emit('toggleAddRelatedIssuesForm', $event)"
- />
+ <div class="js-related-issues-header-issue-count gl-display-inline-flex gl-mx-3">
+ <span class="gl-display-inline-flex gl-align-items-center">
+ <gl-icon :name="issuableTypeIcon" class="gl-mr-2 gl-text-gray-500" />
+ {{ badgeLabel }}
+ </span>
</div>
</h3>
<slot name="header-actions"></slot>
+ <gl-button
+ v-if="canAdmin"
+ size="small"
+ data-qa-selector="related_issues_plus_button"
+ data-testid="related-issues-plus-button"
+ :aria-label="addIssuableButtonText"
+ :class="qaClass"
+ class="gl-ml-3"
+ @click="addButtonClick"
+ >
+ <slot name="add-button-text">{{ __('Add') }}</slot>
+ </gl-button>
+ <div class="gl-pl-3 gl-ml-3 gl-border-l-1 gl-border-l-solid gl-border-l-gray-100">
+ <gl-button
+ category="tertiary"
+ size="small"
+ :icon="toggleIcon"
+ :aria-label="toggleLabel"
+ :disabled="!hasRelatedIssues"
+ data-testid="toggle-links"
+ @click="handleToggle"
+ />
+ </div>
</div>
<div
- class="linked-issues-card-body bg-gray-light"
+ v-if="isOpen"
+ class="linked-issues-card-body gl-bg-gray-10"
:class="{
'gl-p-5': isFormVisible || shouldShowTokenBody,
}"
+ data-testid="related-issues-body"
>
<div
v-if="isFormVisible"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue
index 9ed895e90fb..11de734f5d4 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_list.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue
@@ -5,7 +5,6 @@ import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue
import { defaultSortableOptions } from '~/sortable/constants';
export default {
- name: 'RelatedIssuesList',
components: {
GlLoadingIcon,
RelatedIssuableItem,
@@ -141,6 +140,7 @@ export default {
:path-id-separator="pathIdSeparator"
:is-locked="issue.lockIssueRemoval"
:locked-message="issue.lockedMessage"
+ :work-item-type="issue.type"
event-namespace="relatedIssue"
data-qa-selector="related_issuable_content"
@relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)"
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index da049d68467..cad5037d7e4 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -24,6 +24,7 @@ Your caret can stop touching a `rawReference` can happen in a variety of ways:
*/
import createFlash from '~/flash';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import {
relatedIssuesRemoveErrorMap,
@@ -123,6 +124,14 @@ export default {
return this.state.relatedIssues.find((issue) => issue.id === id);
},
onRelatedIssueRemoveRequest(idToRemove) {
+ if (isGid(idToRemove)) {
+ const deletedId = getIdFromGraphQLId(idToRemove);
+ this.state.relatedIssues = this.state.relatedIssues.filter(
+ (issue) => issue.id !== deletedId,
+ );
+ return;
+ }
+
const issueToRemove = this.findRelatedIssueById(idToRemove);
if (issueToRemove) {
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 3516836952f..23ea93cd258 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -114,8 +114,8 @@ export const PathIdSeparator = {
};
export const issuablesBlockHeaderTextMap = {
- [issuableTypesMap.ISSUE]: __('Linked issues'),
- [issuableTypesMap.INCIDENT]: __('Related incidents or issues'),
+ [issuableTypesMap.ISSUE]: __('Linked items'),
+ [issuableTypesMap.INCIDENT]: __('Linked incidents or issues'),
[issuableTypesMap.EPIC]: __('Linked epics'),
};
@@ -136,7 +136,7 @@ export const issuablesFormCategoryHeaderTextMap = {
};
export const issuablesFormInputTextMap = {
- [issuableTypesMap.ISSUE]: __('the following issue(s)'),
- [issuableTypesMap.INCIDENT]: __('the following incident(s) or issue(s)'),
- [issuableTypesMap.EPIC]: __('the following epic(s)'),
+ [issuableTypesMap.ISSUE]: __('the following issues'),
+ [issuableTypesMap.INCIDENT]: __('the following incidents or issues'),
+ [issuableTypesMap.EPIC]: __('the following epics'),
};
diff --git a/app/assets/javascripts/related_issues/index.js b/app/assets/javascripts/related_issues/index.js
index 655ec57bc3d..eb2f5d119b8 100644
--- a/app/assets/javascripts/related_issues/index.js
+++ b/app/assets/javascripts/related_issues/index.js
@@ -1,30 +1,33 @@
import Vue from 'vue';
+import apolloProvider from '~/issues/show/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import RelatedIssuesRoot from './components/related_issues_root.vue';
-export default function initRelatedIssues(issueType = 'issue') {
- const relatedIssuesRootElement = document.querySelector('.js-related-issues-root');
- if (relatedIssuesRootElement) {
- // eslint-disable-next-line no-new
- new Vue({
- el: relatedIssuesRootElement,
- name: 'RelatedIssuesRoot',
- components: {
- relatedIssuesRoot: RelatedIssuesRoot,
- },
- render: (createElement) =>
- createElement('related-issues-root', {
- props: {
- endpoint: relatedIssuesRootElement.dataset.endpoint,
- canAdmin: parseBoolean(relatedIssuesRootElement.dataset.canAddRelatedIssues),
- helpPath: relatedIssuesRootElement.dataset.helpPath,
- showCategorizedIssues: parseBoolean(
- relatedIssuesRootElement.dataset.showCategorizedIssues,
- ),
- issuableType: issueType,
- autoCompleteEpics: false,
- },
- }),
- });
+export function initRelatedIssues(issueType = 'issue') {
+ const el = document.querySelector('.js-related-issues-root');
+
+ if (!el) {
+ return null;
}
+
+ return new Vue({
+ el,
+ name: 'RelatedIssuesRoot',
+ apolloProvider,
+ provide: {
+ fullPath: el.dataset.fullPath,
+ hasIssueWeightsFeature: parseBoolean(el.dataset.hasIssueWeightsFeature),
+ },
+ render: (createElement) =>
+ createElement(RelatedIssuesRoot, {
+ props: {
+ endpoint: el.dataset.endpoint,
+ canAdmin: parseBoolean(el.dataset.canAddRelatedIssues),
+ helpPath: el.dataset.helpPath,
+ showCategorizedIssues: parseBoolean(el.dataset.showCategorizedIssues),
+ issuableType: issueType,
+ autoCompleteEpics: false,
+ },
+ }),
+ });
}
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index 022c3224bb4..dd3f4ed636f 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -128,8 +128,13 @@ export default {
async mounted() {
await this.initializeRelease();
- // Focus the first non-disabled input or button element
- this.$el.querySelector('input:enabled, button:enabled').focus();
+ if (this.release?.tagName) {
+ // Focus the release title input if a tag was preselected
+ this.$refs.releaseTitleInput.$el.focus();
+ } else {
+ // Focus the first non-disabled input or button element otherwise
+ this.$el.querySelector('input:enabled, button:enabled').focus();
+ }
},
methods: {
...mapActions('editNew', [
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index b81da399a7b..7c6d44456d9 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -209,7 +209,7 @@ export default {
:id="`asset-type-${index}`"
ref="typeSelect"
:value="link.linkType || $options.defaultTypeOptionValue"
- class="form-control pr-4"
+ class="pr-4"
name="asset-type"
:options="$options.typeOptions"
@change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })"
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index def38780545..070865cf84b 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -7,6 +7,10 @@ import { BACK_URL_PARAM } from '~/releases/constants';
export default {
i18n: {
editButton: __('Edit this release'),
+ historical: __('Historical release'),
+ historicalTooltip: __(
+ 'This release was created with a date in the past. Evidence collection at the moment of the release is unavailable.',
+ ),
},
name: 'ReleaseBlockHeader',
components: {
@@ -65,6 +69,14 @@ export default {
<gl-badge v-if="release.upcomingRelease" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
+ <gl-badge
+ v-else-if="release.historicalRelease"
+ v-gl-tooltip
+ :title="$options.i18n.historicalTooltip"
+ class="gl-vertical-align-middle"
+ >
+ {{ $options.i18n.historical }}
+ </gl-badge>
</h2>
<gl-button
v-if="editLink"
diff --git a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
index e0de6d12b13..e22726f27a7 100644
--- a/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
+++ b/app/assets/javascripts/releases/graphql/fragments/release.fragment.graphql
@@ -1,5 +1,4 @@
fragment Release on Release {
- __typename
id
name
tagName
@@ -8,21 +7,17 @@ fragment Release on Release {
releasedAt
createdAt
upcomingRelease
+ historicalRelease
assets {
- __typename
count
sources {
- __typename
nodes {
- __typename
format
url
}
}
links {
- __typename
nodes {
- __typename
id
name
url
@@ -33,9 +28,7 @@ fragment Release on Release {
}
}
evidences {
- __typename
nodes {
- __typename
id
filepath
collectedAt
@@ -43,7 +36,6 @@ fragment Release on Release {
}
}
links {
- __typename
editUrl
selfUrl
openedIssuesUrl
@@ -53,29 +45,24 @@ fragment Release on Release {
closedMergeRequestsUrl
}
commit {
- __typename
id
sha
webUrl
title
}
author {
- __typename
id
webUrl
avatarUrl
username
}
milestones {
- __typename
nodes {
- __typename
id
title
description
webPath
stats {
- __typename
totalIssuesCount
closedIssuesCount
}
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 61a06f268bd..1e3d31c86bf 100644
--- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
+++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql
@@ -1,3 +1,5 @@
+#import "../fragments/release.fragment.graphql"
+
query allReleases(
$fullPath: ID!
$first: Int
@@ -7,96 +9,12 @@ query allReleases(
$sort: ReleaseSort
) {
project(fullPath: $fullPath) {
- __typename
id
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
- __typename
nodes {
- __typename
- id
- name
- tagName
- tagPath
- descriptionHtml
- releasedAt
- createdAt
- upcomingRelease
- assets {
- __typename
- count
- sources {
- __typename
- nodes {
- __typename
- format
- url
- }
- }
- links {
- __typename
- nodes {
- __typename
- id
- name
- url
- directAssetUrl
- linkType
- external
- }
- }
- }
- evidences {
- __typename
- nodes {
- __typename
- id
- filepath
- collectedAt
- sha
- }
- }
- links {
- __typename
- editUrl
- selfUrl
- openedIssuesUrl
- closedIssuesUrl
- openedMergeRequestsUrl
- mergedMergeRequestsUrl
- closedMergeRequestsUrl
- }
- commit {
- __typename
- id
- sha
- webUrl
- title
- }
- author {
- __typename
- id
- webUrl
- avatarUrl
- username
- }
- milestones {
- __typename
- nodes {
- __typename
- id
- title
- description
- webPath
- stats {
- __typename
- totalIssuesCount
- closedIssuesCount
- }
- }
- }
+ ...Release
}
pageInfo {
- __typename
startCursor
hasPreviousPage
hasNextPage
diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js
index f1f5f4bca4c..a1027ef08d7 100644
--- a/app/assets/javascripts/releases/util.js
+++ b/app/assets/javascripts/releases/util.js
@@ -12,6 +12,7 @@ const convertScalarProperties = (graphQLRelease) =>
'description',
'descriptionHtml',
'upcomingRelease',
+ 'historicalRelease',
]);
const convertDateProperties = ({ releasedAt }) => ({
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 0714d88b392..6061be465ca 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -206,7 +206,6 @@ export default {
<gl-button
v-if="isCollapsible"
- class="js-collapse-btn"
data-testid="report-section-expand-button"
data-qa-selector="expand_report_button"
@click="toggleCollapsed"
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index bf4f19504f0..7999b916e0f 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -8,7 +8,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { isLoggedIn, handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import { redirectTo } from '~/lib/utils/url_utility';
+import { redirectTo, getLocationHash } 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';
@@ -63,6 +63,28 @@ export default {
};
},
result() {
+ const urlHash = getLocationHash();
+ const plain = this.$route?.query?.plain;
+
+ // When the 'plain' URL param is present, its value determines which viewer to render:
+ // - when 0 and the rich viewer is available we render with it
+ // - otherwise we render the simple viewer
+ if (plain !== undefined) {
+ if (plain === '0' && this.hasRichViewer) {
+ this.switchViewer(RICH_BLOB_VIEWER);
+ } else {
+ this.switchViewer(SIMPLE_BLOB_VIEWER);
+ }
+ return;
+ }
+
+ // If there is a code line hash in the URL we render with the simple viewer
+ if (urlHash && urlHash.startsWith('L')) {
+ this.switchViewer(SIMPLE_BLOB_VIEWER);
+ return;
+ }
+
+ // By default, if present, use the rich viewer to render
this.switchViewer(this.hasRichViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER);
},
error() {
@@ -173,6 +195,21 @@ export default {
return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE;
},
},
+ watch: {
+ // Watch the URL 'plain' query value to know if the viewer needs changing.
+ // This is the case when the user switches the viewer and then goes back
+ // through the hystory.
+ '$route.query.plain': {
+ handler(plainValue) {
+ this.switchViewer(
+ this.hasRichViewer && (plainValue === undefined || plainValue === '0')
+ ? RICH_BLOB_VIEWER
+ : SIMPLE_BLOB_VIEWER,
+ plainValue !== undefined,
+ );
+ },
+ },
+ },
methods: {
onError() {
this.useFallback = true;
@@ -189,15 +226,10 @@ export default {
axios
.get(`${this.blobInfo.webPath}?format=json&viewer=${type}`)
.then(async ({ data: { html, binary } }) => {
- if (type === SIMPLE_BLOB_VIEWER) {
- this.isRenderingLegacyTextViewer = true;
+ this.isRenderingLegacyTextViewer = true;
+ if (type === SIMPLE_BLOB_VIEWER) {
this.legacySimpleViewer = html;
-
- window.requestIdleCallback(() => {
- this.isRenderingLegacyTextViewer = false;
- new LineHighlighter(); // eslint-disable-line no-new
- });
} else {
this.legacyRichViewer = html;
}
@@ -205,6 +237,14 @@ export default {
this.isBinary = binary;
this.isLoadingLegacyViewer = false;
+ window.requestIdleCallback(() => {
+ this.isRenderingLegacyTextViewer = false;
+
+ if (type === SIMPLE_BLOB_VIEWER) {
+ new LineHighlighter(); // eslint-disable-line no-new
+ }
+ });
+
await this.$nextTick();
handleLocationHash(); // Ensures that we scroll to the hash when async content is loaded
})
@@ -220,6 +260,22 @@ export default {
this.loadLegacyViewer();
}
},
+ updateRouteQuery() {
+ const plain = this.activeViewerType === SIMPLE_BLOB_VIEWER ? '1' : '0';
+
+ if (this.$route?.query?.plain === plain) {
+ return;
+ }
+
+ this.$router.push({
+ path: this.$route.path,
+ query: { ...this.$route.query, plain },
+ });
+ },
+ handleViewerChanged(newViewer) {
+ this.switchViewer(newViewer);
+ this.updateRouteQuery();
+ },
editBlob(target) {
if (this.showForkSuggestion) {
this.setForkTarget(target);
@@ -251,7 +307,7 @@ export default {
:has-render-error="hasRenderError"
:show-path="false"
:override-copy="glFeatures.highlightJs"
- @viewer-changed="switchViewer"
+ @viewer-changed="handleViewerChanged"
@copy="onCopy"
>
<template #actions>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 9f2cf8505d3..7f408485326 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -196,12 +196,9 @@ export default {
</gl-link>
</div>
<gl-button-group class="gl-ml-4 js-commit-sha-group">
- <gl-button
- label
- class="gl-font-monospace"
- data-testid="last-commit-id-label"
- v-text="showCommitId"
- />
+ <gl-button label class="gl-font-monospace" data-testid="last-commit-id-label">{{
+ showCommitId
+ }}</gl-button>
<clipboard-button
:text="commit.sha"
:title="__('Copy commit SHA')"
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 0e80f306638..77d3a517d28 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -94,7 +94,6 @@ export const LFS_STORAGE = 'lfs';
*/
export const LEGACY_FILE_TYPES = [
'gemfile',
- 'gemspec',
'composer_json',
'podfile',
'podspec',
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
index 8baee80e5d6..45a7793e559 100644
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql
@@ -27,6 +27,7 @@ query getBlobInfo(
fileType
language
path
+ blamePath
editBlobPath
gitpodBlobUrl
ideEditPath
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 9de67015094..3256e13f4da 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -3,9 +3,7 @@
import $ from 'jquery';
import { setCookie } from '~/lib/utils/common_utils';
import { hide, fixTitle } from '~/tooltips';
-import createFlash from './flash';
-import axios from './lib/utils/axios_utils';
-import { sprintf, s__, __ } from './locale';
+import { __ } from './locale';
const updateSidebarClasses = (layoutPage, rightSidebar) => {
if (window.innerWidth >= 992) {
@@ -20,7 +18,6 @@ const updateSidebarClasses = (layoutPage, rightSidebar) => {
};
function Sidebar() {
- this.toggleTodo = this.toggleTodo.bind(this);
this.sidebar = $('aside');
this.removeListeners();
@@ -54,7 +51,6 @@ Sidebar.prototype.addEventListeners = function () {
this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden);
$document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked);
- $(document).off('click', '.js-issuable-todo').on('click', '.js-issuable-todo', this.toggleTodo);
if (window.gon?.features?.movedMrSidebar) {
const layoutPage = document.querySelector('.layout-page');
@@ -105,32 +101,6 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
}
};
-Sidebar.prototype.toggleTodo = function (e) {
- const $this = $(e.currentTarget);
- const ajaxType = $this.data('deletePath') ? 'delete' : 'post';
- const url = String($this.data('deletePath') || $this.data('createPath'));
-
- hide($this);
-
- $('.js-issuable-todo').disable().addClass('is-loading');
-
- axios[ajaxType](url, {
- issuable_id: $this.data('issuableId'),
- issuable_type: $this.data('issuableType'),
- })
- .then(({ data }) => {
- this.todoUpdateDone(data);
- })
- .catch(() =>
- createFlash({
- message: sprintf(__('There was an error %{message} to-do item.'), {
- message:
- ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
- }),
- }),
- );
-};
-
Sidebar.prototype.sidebarCollapseClicked = function (e) {
if ($(e.currentTarget).hasClass('js-dont-change-state')) {
return;
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 f6b7a8b46d7..777a332333d 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -12,10 +12,12 @@ import {
isSearchFiltered,
} from 'ee_else_ce/runner/runner_search_utils';
import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql';
+import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
+import RunnerBulkDeleteCheckbox from '../components/runner_bulk_delete_checkbox.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
import RunnerName from '../components/runner_name.vue';
@@ -37,6 +39,7 @@ export default {
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerBulkDelete,
+ RunnerBulkDeleteCheckbox,
RunnerList,
RunnerListEmptyState,
RunnerName,
@@ -138,11 +141,15 @@ export default {
onToggledPaused() {
// When a runner becomes Paused, the tab count can
// become stale, refetch outdated counts.
- this.$refs['runner-type-tabs'].refetch();
+ this.refetchCounts();
},
onDeleted({ message }) {
+ this.refetchCounts();
this.$root.$toast?.show(message);
},
+ refetchCounts() {
+ this.$apollo.getClient().refetchQueries({ include: [allRunnersCountQuery] });
+ },
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
@@ -152,6 +159,9 @@ export default {
isChecked,
});
},
+ onPaginationInput(value) {
+ this.search.pagination = value;
+ },
},
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
@@ -163,7 +173,6 @@ export default {
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
<runner-type-tabs
- ref="runner-type-tabs"
v-model="search"
:count-scope="$options.INSTANCE_TYPE"
:count-variables="countVariables"
@@ -196,13 +205,20 @@ export default {
:filtered-svg-path="emptyStateFilteredSvgPath"
/>
<template v-else>
- <runner-bulk-delete v-if="isBulkDeleteEnabled" />
+ <runner-bulk-delete
+ v-if="isBulkDeleteEnabled"
+ :runners="runners.items"
+ @deleted="onDeleted"
+ />
<runner-list
:runners="runners.items"
:loading="runnersLoading"
:checkable="isBulkDeleteEnabled"
@checked="onChecked"
>
+ <template v-if="isBulkDeleteEnabled" #head-checkbox>
+ <runner-bulk-delete-checkbox :runners="runners.items" />
+ </template>
<template #runner-name="{ runner }">
<gl-link :href="runner.adminUrl">
<runner-name :runner="runner" />
@@ -217,11 +233,13 @@ export default {
/>
</template>
</runner-list>
- <runner-pagination
- v-model="search.pagination"
- class="gl-mt-3"
- :page-info="runners.pageInfo"
- />
</template>
+
+ <runner-pagination
+ class="gl-mt-3"
+ :disabled="runnersLoading"
+ :page-info="runners.pageInfo"
+ @input="onPaginationInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
index 1eb383a1904..1cd098d6713 100644
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
@@ -59,7 +59,11 @@ export default {
<tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description">
{{ description }}
</tooltip-on-truncate>
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="ipAddress">
+ <tooltip-on-truncate
+ v-if="ipAddress"
+ class="gl-display-block gl-text-truncate"
+ :title="ipAddress"
+ >
<span class="gl-md-display-none gl-lg-display-inline">{{ __('IP Address') }}</span>
<strong>{{ ipAddress }}</strong>
</tooltip-on-truncate>
diff --git a/app/assets/javascripts/runner/components/runner_assigned_item.vue b/app/assets/javascripts/runner/components/runner_assigned_item.vue
index 38bdfecb7df..2fa87bdd776 100644
--- a/app/assets/javascripts/runner/components/runner_assigned_item.vue
+++ b/app/assets/javascripts/runner/components/runner_assigned_item.vue
@@ -1,10 +1,11 @@
<script>
-import { GlAvatar, GlLink } from '@gitlab/ui';
+import { GlAvatar, GlBadge, GlLink } from '@gitlab/ui';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
components: {
GlAvatar,
+ GlBadge,
GlLink,
},
props: {
@@ -25,6 +26,16 @@ export default {
required: false,
default: null,
},
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isOwner: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
AVATAR_SHAPE_OPTION_RECT,
};
@@ -41,7 +52,12 @@ export default {
:size="48"
/>
</gl-link>
-
- <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
+ <div>
+ <div class="gl-mb-1">
+ <gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
+ <gl-badge v-if="isOwner" variant="info">{{ s__('Runner|Owner') }}</gl-badge>
+ </div>
+ <div v-if="description">{{ description }}</div>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
index 50791de0bda..703da01d9c8 100644
--- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue
+++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue
@@ -1,21 +1,31 @@
<script>
-import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
-import { n__, sprintf } from '~/locale';
-import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
-import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import { __, s__, n__, sprintf } from '~/locale';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
+import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql';
+import { RUNNER_TYPENAME } from '../constants';
export default {
components: {
GlButton,
+ GlModal,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
inject: ['localMutations'],
+ props: {
+ runners: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
data() {
return {
+ isDeleting: false,
checkedRunnerIds: [],
};
},
@@ -25,8 +35,13 @@ export default {
},
},
computed: {
+ currentCheckedRunnerIds() {
+ return this.runners
+ .map(({ id }) => id)
+ .filter((id) => this.checkedRunnerIds.indexOf(id) >= 0);
+ },
checkedCount() {
- return this.checkedRunnerIds.length || 0;
+ return this.currentCheckedRunnerIds.length || 0;
},
bannerMessage() {
return sprintf(
@@ -43,48 +58,103 @@ export default {
modalTitle() {
return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount);
},
- modalHtmlMessage() {
+ modalActionPrimary() {
+ return {
+ text: n__(
+ 'Runners|Permanently delete %d runner',
+ 'Runners|Permanently delete %d runners',
+ this.checkedCount,
+ ),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: __('Cancel'),
+ attributes: {
+ loading: this.isDeleting,
+ },
+ };
+ },
+ modalMessage() {
return sprintf(
n__(
'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
this.checkedCount,
),
- {
- strongStart: '<strong>',
- strongEnd: '</strong>',
- count: this.checkedCount,
- },
- false,
+ { count: this.checkedCount },
);
},
- primaryBtnText() {
+ },
+ methods: {
+ toastConfirmationMessage(deletedCount) {
return n__(
- 'Runners|Permanently delete %d runner',
- 'Runners|Permanently delete %d runners',
- this.checkedCount,
+ 'Runners|%d selected runner deleted',
+ 'Runners|%d selected runners deleted',
+ deletedCount,
);
},
- },
- methods: {
onClearChecked() {
this.localMutations.clearChecked();
},
- onClickDelete: ignoreWhilePending(async function onClickDelete() {
- const confirmed = await confirmAction(null, {
- title: this.modalTitle,
- modalHtmlMessage: this.modalHtmlMessage,
- primaryBtnVariant: 'danger',
- primaryBtnText: this.primaryBtnText,
- });
+ async onConfirmDelete(e) {
+ this.isDeleting = true;
+ e.preventDefault(); // don't close modal until deletion is complete
+
+ try {
+ await this.$apollo.mutate({
+ mutation: BulkRunnerDelete,
+ variables: {
+ input: {
+ ids: this.currentCheckedRunnerIds,
+ },
+ },
+ update: (cache, { data }) => {
+ const { errors, deletedIds } = data.bulkRunnerDelete;
+
+ if (errors?.length) {
+ this.onError(new Error(errors.join(' ')));
+ this.$refs.modal.hide();
+ return;
+ }
+
+ this.$emit('deleted', {
+ message: this.toastConfirmationMessage(deletedIds.length),
+ });
- if (confirmed) {
- // TODO Call $apollo.mutate with list of runner
- // ids in `this.checkedRunnerIds`.
- // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
+ // Clean up
+
+ // Remove deleted runners from the cache
+ deletedIds.forEach((id) => {
+ const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id });
+ cache.evict({ id: cacheId });
+ });
+ cache.gc();
+
+ this.$refs.modal.hide();
+ },
+ });
+ } catch (error) {
+ this.onError(error);
+ } finally {
+ this.isDeleting = false;
}
- }),
+ },
+ onError(error) {
+ createAlert({
+ message: s__(
+ 'Runners|Something went wrong while deleting. Please refresh the page to try again.',
+ ),
+ captureError: true,
+ error,
+ });
+ },
},
+ BULK_DELETE_MODAL_ID: 'bulk-delete-modal',
};
</script>
@@ -99,13 +169,28 @@ export default {
</gl-sprintf>
</div>
<div class="gl-ml-auto">
- <gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{
+ <gl-button variant="default" @click="onClearChecked">{{
s__('Runners|Clear selection')
}}</gl-button>
- <gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{
+ <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{
s__('Runners|Delete selected')
}}</gl-button>
</div>
</div>
+ <gl-modal
+ ref="modal"
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :title="modalTitle"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ @primary="onConfirmDelete"
+ >
+ <gl-sprintf :message="modalMessage">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue
new file mode 100644
index 00000000000..dde5a5a4a05
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_bulk_delete_checkbox.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
+
+export default {
+ components: {
+ GlFormCheckbox,
+ },
+ inject: ['localMutations'],
+ props: {
+ runners: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
+ data() {
+ return {
+ checkedRunnerIds: [],
+ };
+ },
+ apollo: {
+ checkedRunnerIds: {
+ query: checkedRunnerIdsQuery,
+ },
+ },
+ computed: {
+ disabled() {
+ return !this.runners.length;
+ },
+ checked() {
+ return Boolean(this.runners.length) && this.runners.every(this.isChecked);
+ },
+ indeterminate() {
+ return !this.checked && this.runners.some(this.isChecked);
+ },
+ },
+ methods: {
+ isChecked({ id }) {
+ return this.checkedRunnerIds.indexOf(id) >= 0;
+ },
+ onChange($event) {
+ this.localMutations.setRunnersChecked({
+ runners: this.runners,
+ isChecked: $event,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-checkbox
+ :indeterminate="indeterminate"
+ :checked="checked"
+ :disabled="disabled"
+ @change="onChange"
+ />
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
index db67acef3db..584f77b7648 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -38,11 +38,10 @@ export default {
</script>
<template>
- <div class="gl-display-flex gl-pb-4">
- <dt class="gl-mr-2">{{ label }}</dt>
- <dd class="gl-mb-0">
- <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots -->
- <template v-if="value || $slots.value">
+ <div class="gl-display-contents">
+ <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</dt>
+ <dd class="gl-mb-5">
+ <template v-if="value || $scopedSlots.value">
<slot name="value">{{ value }}</slot>
</template>
<span v-else class="gl-text-gray-500">{{ emptyValue }}</span>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index 60469d26dd5..d5222f39b81 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -51,6 +51,9 @@ export default {
}
return null;
},
+ tagList() {
+ return this.runner.tagList || [];
+ },
isGroupRunner() {
return this.runner?.runnerType === GROUP_TYPE;
},
@@ -66,14 +69,17 @@ export default {
<div>
<runner-upgrade-status-alert class="gl-my-4" :runner="runner" />
<div class="gl-pt-4">
- <dl class="gl-mb-0" data-testid="runner-details-list">
+ <dl
+ class="gl-mb-0 gl-display-grid runner-details-grid-template"
+ data-testid="runner-details-list"
+ >
<runner-detail :label="s__('Runners|Description')" :value="runner.description" />
<runner-detail
:label="s__('Runners|Last contact')"
:empty-value="s__('Runners|Never contacted')"
>
- <template #value>
- <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ <template v-if="runner.contactedAt" #value>
+ <time-ago :time="runner.contactedAt" />
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Version')">
@@ -87,8 +93,8 @@ export default {
<runner-detail :label="s__('Runners|Architecture')" :value="runner.architectureName" />
<runner-detail :label="s__('Runners|Platform')" :value="runner.platformName" />
<runner-detail :label="s__('Runners|Configuration')">
- <template #value>
- <gl-intersperse v-if="configTextProtected || configTextUntagged">
+ <template v-if="configTextProtected || configTextUntagged" #value>
+ <gl-intersperse>
<span v-if="configTextProtected">{{ configTextProtected }}</span>
<span v-if="configTextUntagged">{{ configTextUntagged }}</span>
</gl-intersperse>
@@ -96,13 +102,8 @@ export default {
</runner-detail>
<runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
<runner-detail :label="s__('Runners|Tags')">
- <template #value>
- <runner-tags
- v-if="runner.tagList && runner.tagList.length"
- class="gl-vertical-align-middle"
- :tag-list="runner.tagList"
- size="sm"
- />
+ <template v-if="tagList.length" #value>
+ <runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" />
</template>
</runner-detail>
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index bff5ec9b238..5a9ab21a457 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -64,19 +64,19 @@ export default {
},
methods: {
onFilter(filters) {
- // Apply new filters, from page 1
+ // Apply new filters, resetting pagination
this.$emit('input', {
...this.value,
filters,
- pagination: { page: 1 },
+ pagination: {},
});
},
onSort(sort) {
- // Apply new sort, from page 1
+ // Apply new sort, resetting pagination
this.$emit('input', {
...this.value,
sort,
- pagination: { page: 1 },
+ pagination: {},
});
},
},
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
index 57afdc4b9be..9003eba3636 100644
--- a/app/assets/javascripts/runner/components/runner_jobs.vue
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -27,9 +27,7 @@ export default {
items: [],
pageInfo: {},
},
- pagination: {
- page: 1,
- },
+ pagination: {},
};
},
apollo: {
@@ -62,6 +60,11 @@ export default {
return this.$apollo.queries.jobs.loading;
},
},
+ methods: {
+ onPaginationInput(value) {
+ this.pagination = value;
+ },
+ },
I18N_NO_JOBS_FOUND,
};
</script>
@@ -74,6 +77,6 @@ export default {
<runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
<p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
- <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" />
+ <runner-pagination :disabled="loading" :page-info="jobs.pageInfo" @input="onPaginationInput" />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index f1f99c728c5..2e406f71792 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
+import { GlFormCheckbox, GlTableLite, GlTooltipDirective, GlSkeletonLoader } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
@@ -23,6 +23,7 @@ const defaultFields = [
export default {
components: {
+ GlFormCheckbox,
GlTableLite,
GlSkeletonLoader,
TooltipOnTruncate,
@@ -123,19 +124,11 @@ export default {
fixed
>
<template #head(checkbox)>
- <!--
- Checkbox to select all to be added here
- See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
- -->
- <span></span>
+ <slot name="head-checkbox"></slot>
</template>
<template #cell(checkbox)="{ item }">
- <input
- type="checkbox"
- :checked="isChecked(item)"
- @change="onCheckboxChange(item, $event.target.checked)"
- />
+ <gl-form-checkbox :checked="isChecked(item)" @change="onCheckboxChange(item, $event)" />
</template>
<template #head(status)="{ label }">
diff --git a/app/assets/javascripts/runner/components/runner_pagination.vue b/app/assets/javascripts/runner/components/runner_pagination.vue
index cfc21d1407b..a5bf3074dd1 100644
--- a/app/assets/javascripts/runner/components/runner_pagination.vue
+++ b/app/assets/javascripts/runner/components/runner_pagination.vue
@@ -1,18 +1,12 @@
<script>
-import { GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
export default {
components: {
- GlPagination,
+ GlKeysetPagination,
},
+ inheritAttrs: false,
props: {
- value: {
- required: false,
- type: Object,
- default: () => ({
- page: 1,
- }),
- },
pageInfo: {
required: false,
type: Object,
@@ -20,46 +14,37 @@ export default {
},
},
computed: {
- prevPage() {
- return this.pageInfo?.hasPreviousPage ? this.value.page - 1 : null;
+ paginationProps() {
+ return { ...this.pageInfo, ...this.$attrs };
},
- nextPage() {
- return this.pageInfo?.hasNextPage ? this.value.page + 1 : null;
+ isShown() {
+ const { hasPreviousPage, hasNextPage } = this.pageInfo;
+ return hasPreviousPage || hasNextPage;
},
},
methods: {
- handlePageChange(page) {
- if (page === 1) {
- // Small optimization for first page
- // If we have loaded using "first",
- // page is already cached.
- this.$emit('input', {
- page,
- });
- } else if (page > this.value.page) {
- this.$emit('input', {
- page,
- after: this.pageInfo.endCursor,
- });
- } else {
- this.$emit('input', {
- page,
- before: this.pageInfo.startCursor,
- });
- }
+ prevPage() {
+ this.$emit('input', {
+ before: this.pageInfo.startCursor,
+ });
+ },
+ nextPage() {
+ this.$emit('input', {
+ after: this.pageInfo.endCursor,
+ });
},
},
};
</script>
<template>
- <gl-pagination
- v-bind="$attrs"
- :value="value.page"
- :prev-page="prevPage"
- :next-page="nextPage"
- align="center"
- class="gl-pagination"
- @input="handlePageChange"
- />
+ <div v-if="isShown" class="gl-text-center">
+ <gl-keyset-pagination
+ v-bind="paginationProps"
+ :prev-text="s__('Pagination|Prev')"
+ :next-text="s__('Pagination|Next')"
+ @prev="prevPage"
+ @next="nextPage"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index c0c0c14e91e..2c1d2fc2b10 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -30,13 +30,12 @@ export default {
data() {
return {
projects: {
+ ownerProjectId: null,
items: [],
pageInfo: {},
count: 0,
},
- pagination: {
- page: 1,
- },
+ pagination: {},
};
},
apollo: {
@@ -48,6 +47,7 @@ export default {
update(data) {
const { runner } = data;
return {
+ ownerProjectId: runner?.ownerProject?.id,
count: runner?.projectCount || 0,
items: runner?.projects?.nodes || [],
pageInfo: runner?.projects?.pageInfo || {},
@@ -76,6 +76,14 @@ export default {
});
},
},
+ methods: {
+ isOwner(projectId) {
+ return projectId === this.projects.ownerProjectId;
+ },
+ onPaginationInput(value) {
+ this.pagination = value;
+ },
+ },
I18N_NONE,
};
</script>
@@ -98,10 +106,16 @@ export default {
:name="project.name"
:full-name="project.nameWithNamespace"
:avatar-url="project.avatarUrl"
+ :description="project.description"
+ :is-owner="isOwner(project.id)"
/>
</template>
<span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
- <runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" />
+ <runner-pagination
+ :disabled="loading"
+ :page-info="projects.pageInfo"
+ @input="onPaginationInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/stat/runner_count.vue b/app/assets/javascripts/runner/components/stat/runner_count.vue
index af18b203f90..37c6f922f9a 100644
--- a/app/assets/javascripts/runner/components/stat/runner_count.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_count.vue
@@ -1,8 +1,9 @@
<script>
import { fetchPolicies } from '~/lib/graphql';
+import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
+import groupRunnersCountQuery from 'ee_else_ce/runner/graphql/list/group_runners_count.query.graphql';
+
import { captureException } from '../../sentry_utils';
-import allRunnersCountQuery from '../../graphql/list/all_runners_count.query.graphql';
-import groupRunnersCountQuery from '../../graphql/list/group_runners_count.query.graphql';
import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants';
/**
@@ -38,7 +39,7 @@ export default {
variables: {
type: Object,
required: false,
- default: () => {},
+ default: () => ({}),
},
skip: {
type: Boolean,
diff --git a/app/assets/javascripts/runner/components/stat/runner_single_stat.vue b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue
new file mode 100644
index 00000000000..ae732b052ac
--- /dev/null
+++ b/app/assets/javascripts/runner/components/stat/runner_single_stat.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { formatNumber } from '~/locale';
+import RunnerCount from './runner_count.vue';
+
+export default {
+ components: {
+ GlSingleStat,
+ RunnerCount,
+ },
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ },
+ variables: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ skip: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ formattedValue(value) {
+ if (typeof value === 'number') {
+ return formatNumber(value);
+ }
+ return '-';
+ },
+ },
+};
+</script>
+<template>
+ <runner-count #default="{ count }" :scope="scope" :variables="variables" :skip="skip">
+ <gl-single-stat v-bind="$attrs" :value="formattedValue(count)" />
+ </runner-count>
+</template>
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index 9e1ca9ba4ee..93e54ebe7f4 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,12 +1,13 @@
<script>
+import { s__ } from '~/locale';
+import RunnerSingleStat from '~/runner/components/stat/runner_single_stat.vue';
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
-import RunnerCount from './runner_count.vue';
-import RunnerStatusStat from './runner_status_stat.vue';
export default {
components: {
- RunnerCount,
- RunnerStatusStat,
+ RunnerSingleStat,
+ RunnerUpgradeStatusStats: () =>
+ import('ee_component/runner/components/stat/runner_upgrade_status_stats.vue'),
},
props: {
scope: {
@@ -16,32 +17,67 @@ export default {
variables: {
type: Object,
required: false,
- default: () => {},
+ default: () => ({}),
},
},
- methods: {
- countVariables(vars) {
- return { ...this.variables, ...vars };
+ computed: {
+ stats() {
+ return [
+ {
+ key: STATUS_ONLINE,
+ props: {
+ skip: this.statusCountSkip(STATUS_ONLINE),
+ variables: { ...this.variables, status: STATUS_ONLINE },
+ variant: 'success',
+ title: s__('Runners|Online runners'),
+ metaText: s__('Runners|online'),
+ },
+ },
+ {
+ key: STATUS_OFFLINE,
+ props: {
+ skip: this.statusCountSkip(STATUS_OFFLINE),
+ variables: { ...this.variables, status: STATUS_OFFLINE },
+ variant: 'muted',
+ title: s__('Runners|Offline runners'),
+ metaText: s__('Runners|offline'),
+ },
+ },
+ {
+ key: STATUS_STALE,
+ props: {
+ skip: this.statusCountSkip(STATUS_STALE),
+ variables: { ...this.variables, status: STATUS_STALE },
+ variant: 'warning',
+ title: s__('Runners|Stale runners'),
+ metaText: s__('Runners|stale'),
+ },
+ },
+ ];
},
+ },
+ methods: {
statusCountSkip(status) {
// Show an empty result when we already filter by another status
return this.variables.status && this.variables.status !== status;
},
},
- STATUS_LIST: [STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE],
};
</script>
<template>
- <div class="gl-display-flex gl-py-6">
- <runner-count
- v-for="status in $options.STATUS_LIST"
- #default="{ count }"
- :key="status"
+ <div class="gl-display-flex gl-flex-wrap gl-py-6">
+ <runner-single-stat
+ v-for="stat in stats"
+ :key="stat.key"
+ :scope="scope"
+ v-bind="stat.props"
+ class="gl-px-5"
+ />
+
+ <runner-upgrade-status-stats
+ class="gl-display-contents"
:scope="scope"
- :variables="countVariables({ status })"
- :skip="statusCountSkip(status)"
- >
- <runner-status-stat class="gl-px-5" :status="status" :value="count" />
- </runner-count>
+ :variables="variables"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue b/app/assets/javascripts/runner/components/stat/runner_status_stat.vue
deleted file mode 100644
index b77bbe15541..00000000000
--- a/app/assets/javascripts/runner/components/stat/runner_status_stat.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { GlSingleStat } from '@gitlab/ui/dist/charts';
-import { s__, formatNumber } from '~/locale';
-import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
-
-export default {
- components: {
- GlSingleStat,
- },
- props: {
- value: {
- type: Number,
- required: false,
- default: null,
- },
- status: {
- type: String,
- required: true,
- },
- },
- computed: {
- formattedValue() {
- if (typeof this.value === 'number') {
- return formatNumber(this.value);
- }
- return '-';
- },
- stat() {
- switch (this.status) {
- case STATUS_ONLINE:
- return {
- variant: 'success',
- title: s__('Runners|Online runners'),
- metaText: s__('Runners|online'),
- };
- case STATUS_OFFLINE:
- return {
- variant: 'muted',
- title: s__('Runners|Offline runners'),
- metaText: s__('Runners|offline'),
- };
- case STATUS_STALE:
- return {
- variant: 'warning',
- title: s__('Runners|Stale runners'),
- metaText: s__('Runners|stale'),
- };
- default:
- return {
- title: s__('Runners|Runners'),
- };
- }
- },
- },
-};
-</script>
-<template>
- <gl-single-stat
- v-if="stat"
- :value="formattedValue"
- :variant="stat.variant"
- :title="stat.title"
- :meta-text="stat.metaText"
- />
-</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 64541729701..ed1afcbf691 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -1,5 +1,7 @@
import { __, s__ } from '~/locale';
+export const RUNNER_TYPENAME = 'CiRunner'; // __typename
+
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
@@ -102,7 +104,6 @@ export const PARAM_KEY_TAG = 'tag';
export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_SORT = 'sort';
-export const PARAM_KEY_PAGE = 'page';
export const PARAM_KEY_AFTER = 'after';
export const PARAM_KEY_BEFORE = 'before';
diff --git a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
index f900a0450e5..29abddf84f5 100644
--- a/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/edit/runner_fields_shared.fragment.graphql
@@ -1,5 +1,4 @@
fragment RunnerFieldsShared on CiRunner {
- __typename
id
shortSha
runnerType
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
index 6bb896dda16..1160596aff3 100644
--- a/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/all_runners.query.graphql
@@ -1,5 +1,4 @@
-#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "~/runner/graphql/list/all_runners_connection.fragment.graphql"
query getAllRunners(
$before: String
@@ -25,14 +24,6 @@ query getAllRunners(
search: $search
sort: $sort
) {
- nodes {
- ...ListItem
- adminUrl
- editAdminUrl
- }
- pageInfo {
- __typename
- ...PageInfo
- }
+ ...AllRunnersConnection
}
}
diff --git a/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql
new file mode 100644
index 00000000000..4440b8e98da
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/all_runners_connection.fragment.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+fragment AllRunnersConnection on CiRunnerConnection {
+ nodes {
+ ...ListItem
+ adminUrl
+ editAdminUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
new file mode 100644
index 00000000000..b73c016b1de
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql
@@ -0,0 +1,6 @@
+mutation bulkRunnerDelete($input: BulkRunnerDeleteInput!) {
+ bulkRunnerDelete(input: $input) {
+ deletedIds
+ errors
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql
new file mode 100644
index 00000000000..baef16a4b41
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/list/group_runner_connection.fragment.graphql
@@ -0,0 +1,16 @@
+#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
+#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+
+fragment GroupRunnerConnection on CiRunnerConnection {
+ edges {
+ webUrl
+ editUrl
+ node {
+ ...ListItem
+ projectCount # Used to determine why some project runners can't be deleted
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+}
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 8755636a7ad..4c519b9b867 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -1,5 +1,4 @@
-#import "ee_else_ce/runner/graphql/list/list_item.fragment.graphql"
-#import "~/graphql_shared/fragments/page_info.fragment.graphql"
+#import "~/runner/graphql/list/group_runner_connection.fragment.graphql"
query getGroupRunners(
$groupFullPath: ID!
@@ -27,18 +26,7 @@ query getGroupRunners(
search: $search
sort: $sort
) {
- edges {
- webUrl
- editUrl
- node {
- ...ListItem
- projectCount # Used to determine why some project runners can't be deleted
- }
- }
- pageInfo {
- __typename
- ...PageInfo
- }
+ ...GroupRunnerConnection
}
}
}
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
index cf925359ffb..ce23bddb898 100644
--- a/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql
@@ -1,11 +1,9 @@
fragment ListItemShared on CiRunner {
- __typename
id
description
runnerType
shortSha
version
- revision
ipAddress
active
locked
diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js
index e87bc72c86a..154af261bba 100644
--- a/app/assets/javascripts/runner/graphql/list/local_state.js
+++ b/app/assets/javascripts/runner/graphql/list/local_state.js
@@ -1,4 +1,5 @@
import { makeVar } from '@apollo/client/core';
+import { RUNNER_TYPENAME } from '../../constants';
import typeDefs from './typedefs.graphql';
/**
@@ -33,10 +34,16 @@ export const createLocalState = () => {
typePolicies: {
Query: {
fields: {
- checkedRunnerIds() {
+ checkedRunnerIds(_, { canRead, toReference }) {
return Object.entries(checkedRunnerIdsVar())
+ .filter(([id]) => {
+ // Some runners may be deleted by the user separately.
+ // Skip dangling references, those not in the cache.
+ // See: https://www.apollographql.com/docs/react/caching/garbage-collection/#dangling-references
+ return canRead(toReference({ __typename: RUNNER_TYPENAME, id }));
+ })
.filter(([, isChecked]) => isChecked)
- .map(([key]) => key);
+ .map(([id]) => id);
},
},
},
@@ -50,6 +57,13 @@ export const createLocalState = () => {
[runner.id]: isChecked,
});
},
+ setRunnersChecked({ runners, isChecked }) {
+ const newVal = runners.reduce(
+ (acc, { id }) => ({ ...acc, [id]: isChecked }),
+ checkedRunnerIdsVar(),
+ );
+ checkedRunnerIdsVar(newVal);
+ },
clearChecked() {
checkedRunnerIdsVar({});
},
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
index b79ad4d9280..499c0156770 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_details_shared.fragment.graphql
@@ -1,5 +1,4 @@
fragment RunnerDetailsShared on CiRunner {
- __typename
id
shortSha
runnerType
diff --git a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
index cb27de7c200..acc4a641565 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
@@ -9,11 +9,15 @@ query getRunnerProjects(
) {
runner(id: $id) {
id
+ ownerProject {
+ id
+ }
projectCount
projects(first: $first, last: $last, before: $before, after: $after) {
nodes {
id
avatarUrl
+ description
name
nameWithNamespace
webUrl
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 e8446dbe345..a82411a2120 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -3,6 +3,14 @@ import { GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql';
+import { upgradeStatusTokenConfig } from 'ee_else_ce/runner/components/search_tokens/upgrade_status_token_config';
+import {
+ fromUrlQueryToSearch,
+ fromSearchToUrl,
+ fromSearchToVariables,
+ isSearchFiltered,
+} from 'ee_else_ce/runner/runner_search_utils';
+import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
@@ -22,13 +30,6 @@ import {
PROJECT_TYPE,
I18N_FETCH_ERROR,
} from '../constants';
-import groupRunnersQuery from '../graphql/list/group_runners.query.graphql';
-import {
- fromUrlQueryToSearch,
- fromSearchToUrl,
- fromSearchToVariables,
- isSearchFiltered,
-} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
export default {
@@ -123,7 +124,7 @@ export default {
return !this.runnersLoading && !this.runners.items.length;
},
searchTokens() {
- return [pausedTokenConfig, statusTokenConfig];
+ return [pausedTokenConfig, statusTokenConfig, upgradeStatusTokenConfig];
},
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
@@ -166,6 +167,9 @@ export default {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
+ onPaginationInput(value) {
+ this.search.pagination = value;
+ },
},
TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE],
GROUP_TYPE,
@@ -225,11 +229,13 @@ export default {
/>
</template>
</runner-list>
- <runner-pagination
- v-model="search.pagination"
- class="gl-mt-3"
- :page-info="runners.pageInfo"
- />
</template>
+
+ <runner-pagination
+ class="gl-mt-3"
+ :disabled="runnersLoading"
+ :page-info="runners.pageInfo"
+ @input="onPaginationInput"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index e01878f355a..dc582ccbac1 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -1,3 +1,4 @@
+import { isEmpty } from 'lodash';
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import {
filterToQueryObject,
@@ -13,7 +14,6 @@ import {
PARAM_KEY_TAG,
PARAM_KEY_SEARCH,
PARAM_KEY_SORT,
- PARAM_KEY_PAGE,
PARAM_KEY_AFTER,
PARAM_KEY_BEFORE,
DEFAULT_SORT,
@@ -41,7 +41,7 @@ import { getPaginationVariables } from './utils';
* sort: 'CREATED_DESC',
*
* // Pagination information
- * pagination: { page: 1 },
+ * pagination: { "after": "..." },
* };
* ```
*
@@ -66,25 +66,16 @@ export const searchValidator = ({ runnerType, filters, sort }) => {
};
const getPaginationFromParams = (params) => {
- const page = parseInt(params[PARAM_KEY_PAGE], 10);
- const after = params[PARAM_KEY_AFTER];
- const before = params[PARAM_KEY_BEFORE];
-
- if (page && (before || after)) {
- return {
- page,
- before,
- after,
- };
- }
return {
- page: 1,
+ after: params[PARAM_KEY_AFTER],
+ before: params[PARAM_KEY_BEFORE],
};
};
// Outdated URL parameters
const STATUS_ACTIVE = 'ACTIVE';
const STATUS_PAUSED = 'PAUSED';
+const PARAM_KEY_PAGE = 'page';
/**
* Replaces params into a URL
@@ -97,6 +88,21 @@ const updateUrlParams = (url, params = {}) => {
return setUrlParams(params, url, false, true, true);
};
+const outdatedStatusParams = (status) => {
+ if (status === STATUS_ACTIVE) {
+ return {
+ [PARAM_KEY_PAUSED]: ['false'],
+ [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
+ };
+ } else if (status === STATUS_PAUSED) {
+ return {
+ [PARAM_KEY_PAUSED]: ['true'],
+ [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
+ };
+ }
+ return {};
+};
+
/**
* Returns an updated URL for old (or deprecated) admin runner URLs.
*
@@ -108,25 +114,22 @@ const updateUrlParams = (url, params = {}) => {
export const updateOutdatedUrl = (url = window.location.href) => {
const urlObj = new URL(url);
const query = urlObj.search;
-
const params = queryToObject(query, { gatherArrays: true });
- const status = params[PARAM_KEY_STATUS]?.[0] || null;
-
- switch (status) {
- case STATUS_ACTIVE:
- return updateUrlParams(url, {
- [PARAM_KEY_PAUSED]: ['false'],
- [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
- });
- case STATUS_PAUSED:
- return updateUrlParams(url, {
- [PARAM_KEY_PAUSED]: ['true'],
- [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop!
- });
- default:
- return null;
+ // Remove `page` completely, not needed for keyset pagination
+ const pageParams = PARAM_KEY_PAGE in params ? { [PARAM_KEY_PAGE]: null } : {};
+
+ const status = params[PARAM_KEY_STATUS]?.[0];
+ const redirectParams = {
+ // Replace paused status (active, paused) with a paused flag
+ ...outdatedStatusParams(status),
+ ...pageParams,
+ };
+
+ if (!isEmpty(redirectParams)) {
+ return updateUrlParams(url, redirectParams);
}
+ return null;
};
/**
@@ -182,13 +185,11 @@ export const fromSearchToUrl = (
}
const isDefaultSort = sort !== DEFAULT_SORT;
- const isFirstPage = pagination?.page === 1;
const otherParams = {
// Sorting & Pagination
[PARAM_KEY_SORT]: isDefaultSort ? sort : null,
- [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page,
- [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before,
- [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after,
+ [PARAM_KEY_BEFORE]: pagination?.before || null,
+ [PARAM_KEY_AFTER]: pagination?.after || null,
};
return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
@@ -247,6 +248,6 @@ export const fromSearchToVariables = ({
*/
export const isSearchFiltered = ({ runnerType = null, filters = [], pagination = {} } = {}) => {
return Boolean(
- runnerType !== null || filters?.length !== 0 || (pagination && pagination?.page !== 1),
+ runnerType !== null || filters?.length !== 0 || pagination?.before || pagination?.after,
);
};
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 6efaf08a178..5a9ef832e05 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -157,6 +157,7 @@ export const SCANNER_NAMES_MAP = {
COVERAGE_FUZZING: COVERAGE_FUZZING_NAME,
SECRET_DETECTION: SECRET_DETECTION_NAME,
DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME,
+ GENERIC: s__('ciReport|Manually Added'),
};
export const securityFeatures = [
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 579316f481c..2cdec8fc481 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -91,7 +91,7 @@ export default {
modalId: 'set-user-status-modal',
noEmoji: true,
availability: isUserBusy(this.currentAvailability),
- clearStatusAfter: statusTimeRanges[0].label,
+ clearStatusAfter: statusTimeRanges[0],
clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
date: this.currentClearStatusAfter,
}),
@@ -178,9 +178,7 @@ export default {
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter:
- clearStatusAfter === statusTimeRanges[0].label
- ? null
- : clearStatusAfter.replace(' ', '_'),
+ clearStatusAfter.label === statusTimeRanges[0].label ? null : clearStatusAfter.shortcut,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
@@ -279,12 +277,12 @@ export default {
<div class="form-group">
<div class="gl-display-flex gl-align-items-baseline">
<span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span>
- <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown">
+ <gl-dropdown :text="clearStatusAfter.label" data-testid="clear-status-at-dropdown">
<gl-dropdown-item
v-for="after in $options.statusTimeRanges"
:key="after.name"
:data-testid="after.name"
- @click="setClearStatusAfter(after.label)"
+ @click="setClearStatusAfter(after)"
>{{ after.label }}</gl-dropdown-item
>
</gl-dropdown>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 3602b5ec4f6..29ea390a81d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -39,9 +39,6 @@ export default {
assignSelf() {
this.$emit('assign-self');
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
};
</script>
@@ -66,12 +63,7 @@ export default {
</template>
</span>
- <uncollapsed-assignee-list
- v-else
- :users="sortedAssigness"
- :issuable-type="issuableType"
- @toggle-attention-requested="toggleAttentionRequested"
- />
+ <uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index 59a4eb54bbe..a94dd128a1a 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -32,11 +32,6 @@ export default {
return this.users.length === 0;
},
},
- methods: {
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
- },
};
</script>
@@ -66,7 +61,6 @@ export default {
:users="users"
:issuable-type="issuableType"
class="gl-text-gray-800 hide-collapsed"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index e596d6292bf..18b26c7d8bd 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -125,9 +125,6 @@ export default {
availability: this.assigneeAvailabilityStatus[username] || '',
}));
},
- toggleAttentionRequested(data) {
- this.mediator.toggleAttentionRequested('assignee', data);
- },
},
};
</script>
@@ -155,7 +152,6 @@ export default {
:editable="store.editable"
:issuable-type="issuableType"
@assign-self="assignSelf"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 14f6c9d3a15..5c432ca0e03 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -149,6 +149,9 @@ export default {
signedIn() {
return this.currentUser.username !== undefined;
},
+ issuableAuthor() {
+ return this.issuable?.author;
+ },
},
watch: {
iid(_, oldIid) {
@@ -266,6 +269,7 @@ export default {
:current-user="currentUser"
:issuable-type="issuableType"
:is-editing="edit"
+ :issuable-author="issuableAuthor"
class="gl-w-full dropdown-menu-user gl-mt-n3"
@toggle="collapseWidget"
@error="showError"
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
index e9c68008143..0ed40f56bea 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue
@@ -1,5 +1,5 @@
<script>
-import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui';
+import { GlAvatarLabeled, GlIcon } from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, sprintf } from '~/locale';
@@ -11,7 +11,6 @@ const AVAILABILITY_STATUS = {
export default {
components: {
GlAvatarLabeled,
- GlAvatarLink,
GlIcon,
},
props: {
@@ -47,23 +46,21 @@ export default {
</script>
<template>
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="userLabel"
- :sub-label="`@${user.username}`"
- :src="user.avatarUrl || user.avatar || user.avatar_url"
- class="gl-align-items-center gl-relative"
- >
- <template #meta>
- <gl-icon
- v-if="hasCannotMergeIcon"
- name="warning-solid"
- aria-hidden="true"
- class="merge-icon"
- :size="12"
- />
- </template>
- </gl-avatar-labeled>
- </gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="userLabel"
+ :sub-label="`@${user.username}`"
+ :src="user.avatarUrl || user.avatar || user.avatar_url"
+ class="gl-align-items-center gl-relative sidebar-participant"
+ >
+ <template #meta>
+ <gl-icon
+ v-if="hasCannotMergeIcon"
+ name="warning-solid"
+ aria-hidden="true"
+ class="merge-icon gl-left-6 gl-bottom-0"
+ :size="12"
+ />
+ </template>
+ </gl-avatar-labeled>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
index b6260418837..0e4d4c74160 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -2,7 +2,6 @@
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { IssuableType } from '~/issues/constants';
import { __, sprintf } from '~/locale';
-import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import AssigneeAvatarLink from './assignee_avatar_link.vue';
import UserNameWithStatus from './user_name_with_status.vue';
@@ -10,7 +9,6 @@ const DEFAULT_RENDER_COUNT = 5;
export default {
components: {
- AttentionRequestedToggle,
AssigneeAvatarLink,
UserNameWithStatus,
},
@@ -46,10 +44,6 @@ export default {
return this.users.length - DEFAULT_RENDER_COUNT;
},
uncollapsedUsers() {
- if (this.showVerticalList) {
- return this.users;
- }
-
const uncollapsedLength = this.showLess
? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
: this.users.length;
@@ -58,9 +52,6 @@ export default {
username() {
return `@${this.firstUser.username}`;
},
- showVerticalList() {
- return this.glFeatures.mrAttentionRequests && this.isMergeRequest;
- },
isMergeRequest() {
return this.issuableType === IssuableType.MergeRequest;
},
@@ -75,9 +66,6 @@ export default {
}
return u?.status?.availability || '';
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
};
</script>
@@ -96,7 +84,7 @@ export default {
<assignee-avatar-link
:user="user"
:issuable-type="issuableType"
- :tooltip-has-name="!showVerticalList"
+ :tooltip-has-name="!isMergeRequest"
class="gl-word-break-word"
data-css-area="user"
>
@@ -107,14 +95,6 @@ export default {
<user-name-with-status :name="user.name" :availability="userAvailability(user)" />
</div>
</assignee-avatar-link>
- <attention-requested-toggle
- v-if="showVerticalList"
- :user="user"
- type="assignee"
- class="gl-mr-2"
- data-css-area="attention"
- @toggle-attention-requested="toggleAttentionRequested"
- />
</div>
</div>
<div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800">
diff --git a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue b/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
deleted file mode 100644
index 974ad189f32..00000000000
--- a/app/assets/javascripts/sidebar/components/attention_requested_toggle.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-
-export default {
- i18n: {
- addAttentionRequest: __('Add attention request'),
- removeAttentionRequest: __('Remove attention request'),
- attentionRequestedNoPermission: __('Attention requested'),
- noAttentionRequestedNoPermission: __('No attention request'),
- },
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- type: {
- type: String,
- required: true,
- },
- user: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- loading: false,
- };
- },
- computed: {
- tooltipTitle() {
- if (this.user.attention_requested) {
- if (this.user.can_update_merge_request) {
- return this.$options.i18n.removeAttentionRequest;
- }
-
- return this.$options.i18n.attentionRequestedNoPermission;
- }
-
- if (this.user.can_update_merge_request) {
- return this.$options.i18n.addAttentionRequest;
- }
-
- return this.$options.i18n.noAttentionRequestedNoPermission;
- },
- request() {
- const state = {
- selected: false,
- icon: 'attention',
- direction: 'add',
- };
-
- if (this.user.attention_requested) {
- Object.assign(state, {
- selected: true,
- icon: 'attention-solid',
- direction: 'remove',
- });
- }
-
- return state;
- },
- },
- methods: {
- toggleAttentionRequired() {
- if (this.loading || !this.user.can_update_merge_request) return;
-
- this.$root.$emit(BV_HIDE_TOOLTIP);
- this.loading = true;
- this.$emit('toggle-attention-requested', {
- user: this.user,
- callback: this.toggleAttentionRequiredComplete,
- direction: this.request.direction,
- });
- },
- toggleAttentionRequiredComplete() {
- this.loading = false;
- },
- },
-};
-</script>
-
-<template>
- <div>
- <span
- v-gl-tooltip.left.viewport="tooltipTitle"
- class="gl-display-inline-block js-attention-request-toggle"
- >
- <gl-button
- :loading="loading"
- :selected="request.selected"
- :icon="request.icon"
- :aria-label="tooltipTitle"
- :class="{ 'gl-pointer-events-none': !user.can_update_merge_request }"
- size="small"
- category="tertiary"
- @click="toggleAttentionRequired"
- />
- </span>
- </div>
-</template>
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 c44ce8b0057..336c291d4f1 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -88,7 +88,10 @@ export default {
.then(
({
data: {
- issuableSetConfidential: { errors },
+ issuableSetConfidential: {
+ issuable: { confidential },
+ errors,
+ },
},
}) => {
if (errors.length) {
@@ -96,7 +99,7 @@ export default {
message: errors[0],
});
} else {
- this.$emit('closeForm');
+ this.$emit('closeForm', { confidential });
}
},
)
diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
index f234c5ea3c9..eec083f23f3 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue
@@ -95,10 +95,10 @@ export default {
confidentialWidget.setConfidentiality = null;
},
methods: {
- closeForm() {
+ closeForm({ confidential } = {}) {
this.$refs.editable.collapse();
this.$el.dispatchEvent(hideDropdownEvent);
- this.$emit('closeForm');
+ this.$emit('closeForm', { confidential });
},
// synchronizing the quick action with the sidebar widget
// this is a temporary solution until we have confidentiality real-time updates
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 9502b2e78b3..6f82178b6fd 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -33,7 +33,7 @@ export default {
return this.users.length > 2;
},
allReviewersCanMerge() {
- return this.users.every((user) => user.can_merge);
+ return this.users.every((user) => user.mergeRequestInteraction?.canMerge);
},
sidebarAvatarCounter() {
if (this.users.length > DEFAULT_MAX_COUNTER) {
@@ -48,7 +48,7 @@ export default {
return this.users.slice(0, collapsedLength);
},
tooltipTitleMergeStatus() {
- const mergeLength = this.users.filter((u) => u.can_merge).length;
+ const mergeLength = this.users.filter((u) => u.mergeRequestInteraction?.canMerge).length;
if (mergeLength === this.users.length) {
return '';
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
index 7961b7cd679..a7db3b3d09f 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
@@ -23,10 +23,10 @@ export default {
return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
},
avatarUrl() {
- return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
+ return this.user.avatarUrl || this.user.avatar_url || gon.default_avatar_url;
},
hasMergeIcon() {
- return !this.user.can_merge;
+ return !this.user.mergeRequestInteraction?.canMerge;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
index c9b0a4ae2b3..f69c027e201 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar_link.vue
@@ -40,7 +40,7 @@ export default {
},
computed: {
cannotMerge() {
- return this.issuableType === 'merge_request' && !this.user.can_merge;
+ return this.issuableType === 'merge_request' && !this.user.mergeRequestInteraction?.canMerge;
},
tooltipTitle() {
if (this.cannotMerge && this.tooltipHasName) {
@@ -59,7 +59,7 @@ export default {
};
},
reviewerUrl() {
- return this.user.web_url;
+ return this.user.webUrl;
},
},
};
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
index b07fd944ff9..5e1172ad835 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue
@@ -36,8 +36,8 @@ export default {
return !this.users.length;
},
sortedReviewers() {
- const canMergeUsers = this.users.filter((user) => user.can_merge);
- const canNotMergeUsers = this.users.filter((user) => !user.can_merge);
+ const canMergeUsers = this.users.filter((user) => user.mergeRequestInteraction?.canMerge);
+ const canNotMergeUsers = this.users.filter((user) => !user.mergeRequestInteraction?.canMerge);
return [...canMergeUsers, ...canNotMergeUsers];
},
@@ -49,9 +49,6 @@ export default {
requestReview(data) {
this.$emit('request-review', data);
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
};
</script>
@@ -73,7 +70,6 @@ export default {
:root-path="rootPath"
:issuable-type="issuableType"
@request-review="requestReview"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
index 2ea63219e92..b0d820ddd15 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue
@@ -1,15 +1,23 @@
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import Vue from 'vue';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import createFlash from '~/flash';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql';
import ReviewerTitle from './reviewer_title.vue';
import Reviewers from './reviewers.vue';
+export const state = Vue.observable({
+ issuable: {},
+ loading: false,
+ initialLoading: true,
+});
+
export default {
name: 'SidebarReviewers',
components: {
@@ -40,18 +48,49 @@ export default {
required: true,
},
},
+ apollo: {
+ issuable: {
+ query: getMergeRequestReviewersQuery,
+ variables() {
+ return {
+ iid: this.issuableIid,
+ fullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable;
+ },
+ result() {
+ this.initialLoading = false;
+ },
+ error() {
+ createFlash({ message: __('An error occurred while fetching reviewers.') });
+ },
+ },
+ },
data() {
- return {
- store: new Store(),
- loading: false,
- };
+ return state;
},
computed: {
relativeUrlRoot() {
return gon.relative_url_root ?? '';
},
+ reviewers() {
+ return this.issuable.reviewers?.nodes || [];
+ },
+ graphqlFetching() {
+ return this.$apollo.queries.issuable.loading;
+ },
+ isLoading() {
+ return this.loading || this.$apollo.queries.issuable.loading;
+ },
+ canUpdate() {
+ return this.issuable.userPermissions?.updateMergeRequest || false;
+ },
},
created() {
+ this.store = new Store();
+
this.removeReviewer = this.store.removeReviewer.bind(this.store);
this.addReviewer = this.store.addReviewer.bind(this.store);
this.removeAllReviewers = this.store.removeAllReviewers.bind(this.store);
@@ -77,6 +116,7 @@ export default {
.then(() => {
this.loading = false;
refreshUserMergeRequestCounts();
+ this.$apollo.queries.issuable.refetch();
})
.catch(() => {
this.loading = false;
@@ -88,9 +128,6 @@ export default {
requestReview(data) {
this.mediator.requestReview(data);
},
- toggleAttentionRequested(data) {
- this.mediator.toggleAttentionRequested('reviewer', data);
- },
},
};
</script>
@@ -98,18 +135,17 @@ export default {
<template>
<div>
<reviewer-title
- :number-of-reviewers="store.reviewers.length"
- :loading="loading || store.isFetching.reviewers"
- :editable="store.editable"
+ :number-of-reviewers="reviewers.length"
+ :loading="isLoading"
+ :editable="canUpdate"
/>
<reviewers
- v-if="!store.isFetching.reviewers"
+ v-if="!initialLoading"
:root-path="relativeUrlRoot"
- :users="store.reviewers"
- :editable="store.editable"
+ :users="reviewers"
+ :editable="canUpdate"
:issuable-type="issuableType"
@request-review="requestReview"
- @toggle-attention-requested="toggleAttentionRequested"
/>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
index 2f58e11c00f..217ca2e2548 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue
@@ -1,8 +1,6 @@
<script>
import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, sprintf, s__ } from '~/locale';
-import AttentionRequestedToggle from '../attention_requested_toggle.vue';
import ReviewerAvatarLink from './reviewer_avatar_link.vue';
const LOADING_STATE = 'loading';
@@ -16,12 +14,10 @@ export default {
GlButton,
GlIcon,
ReviewerAvatarLink,
- AttentionRequestedToggle,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
users: {
type: Array,
@@ -80,9 +76,6 @@ export default {
this.loadingStates[userId] = null;
}
},
- toggleAttentionRequested(data) {
- this.$emit('toggle-attention-requested', data);
- },
},
LOADING_STATE,
SUCCESS_STATE,
@@ -96,7 +89,6 @@ export default {
:key="user.id"
:class="{
'gl-mb-3': index !== users.length - 1,
- 'attention-requests': glFeatures.mrAttentionRequests,
}"
class="gl-display-grid gl-align-items-center reviewer-grid gl-mr-2"
data-testid="reviewer"
@@ -112,16 +104,8 @@ export default {
{{ user.name }}
</div>
</reviewer-avatar-link>
- <attention-requested-toggle
- v-if="glFeatures.mrAttentionRequests"
- :user="user"
- type="reviewer"
- class="gl-mr-2"
- data-css-area="attention"
- @toggle-attention-requested="toggleAttentionRequested"
- />
<gl-icon
- v-if="user.approved"
+ v-if="user.mergeRequestInteraction.approved"
v-gl-tooltip.left
:size="16"
:title="approvedByTooltipTitle(user)"
@@ -137,9 +121,7 @@ export default {
data-testid="re-request-success"
/>
<gl-button
- v-else-if="
- user.can_update_merge_request && user.reviewed && !glFeatures.mrAttentionRequests
- "
+ v-else-if="user.mergeRequestInteraction.canUpdate && user.mergeRequestInteraction.reviewed"
v-gl-tooltip.left
:title="$options.i18n.reRequestReview"
:aria-label="$options.i18n.reRequestReview"
diff --git a/app/assets/javascripts/sidebar/components/severity/severity.vue b/app/assets/javascripts/sidebar/components/severity/severity.vue
index 0db856543d0..776dab98f01 100644
--- a/app/assets/javascripts/sidebar/components/severity/severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/severity.vue
@@ -37,10 +37,10 @@ export default {
<gl-icon
:size="iconSize"
:name="`severity-${severity.icon}`"
- :class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]"
+ :class="[`icon-${severity.icon}`, { 'gl-mr-3 gl-flex-shrink-0': !iconOnly }]"
/>
- <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{
- severity.label
- }}</tooltip-on-truncate>
+ <tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">
+ {{ severity.label }}
+ </tooltip-on-truncate>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index 86e46016534..bf4ba715f85 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -149,23 +149,25 @@ export default {
</div>
<div class="hide-collapsed">
- <p
- class="gl-line-height-20 gl-mb-2 gl-text-gray-900 gl-display-flex gl-justify-content-space-between"
+ <div
+ class="gl-display-flex gl-align-items-center gl-line-height-20 gl-text-gray-900 gl-font-weight-bold"
>
{{ $options.i18n.SEVERITY }}
<gl-button
v-if="canUpdate"
category="tertiary"
size="small"
+ class="gl-ml-auto hide-collapsed gl-mr-n2"
data-testid="editButton"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ $options.i18n.EDIT }}
</gl-button>
- </p>
+ </div>
<gl-dropdown
+ class="gl-mt-3"
:class="dropdownClass"
block
:header-text="__('Assign severity')"
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 3f82fe5ce87..fec4d0e346d 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -27,8 +27,6 @@ import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
-import eventHub from '~/sidebar/event_hub';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
@@ -41,6 +39,7 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin
import { IssuableAttributeType } from './constants';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import CrmContacts from './components/crm_contacts/crm_contacts.vue';
+import SidebarEventHub from './event_hub';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -361,6 +360,13 @@ function mountConfidentialComponent() {
? IssuableType.Issue
: IssuableType.MergeRequest,
},
+ on: {
+ closeForm({ confidential }) {
+ if (confidential !== undefined) {
+ SidebarEventHub.$emit('confidentialityUpdated', confidential);
+ }
+ },
+ },
}),
});
}
@@ -652,13 +658,6 @@ export function mountSidebar(mediator, store) {
mountSeverityComponent();
mountEscalationStatusComponent();
-
- if (window.gon?.features?.mrAttentionRequests) {
- eventHub.$on('removeCurrentUserAttentionRequested', () => {
- mediator.removeCurrentUserAttentionRequested();
- refreshUserMergeRequestCounts();
- });
- }
}
export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
index 4998b2af666..a9d7e9878c6 100644
--- a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql
@@ -1,9 +1,7 @@
query epicConfidential($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
confidential
}
diff --git a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
index 00529042e92..45c15a86961 100644
--- a/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_due_date.query.graphql
@@ -1,9 +1,7 @@
query epicDueDate($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
dueDate
dueDateIsFixed
diff --git a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
index dada7ffc034..d665ca1e084 100644
--- a/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_participants.query.graphql
@@ -3,10 +3,8 @@
query epicParticipants($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
participants {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
index f35ca896ef8..76d570a0f16 100644
--- a/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_reference.query.graphql
@@ -1,9 +1,7 @@
query epicReference($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
reference(full: true)
}
diff --git a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
index 85fc7de8d02..c85ede07fde 100644
--- a/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_start_date.query.graphql
@@ -1,9 +1,7 @@
query epicStartDate($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
startDate
startDateIsFixed
diff --git a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
index a8fe6b8ddc3..b1973075d48 100644
--- a/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_subscribed.query.graphql
@@ -1,10 +1,8 @@
query epicSubscribed($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
emailsDisabled
issuable: epic(iid: $iid) {
- __typename
id
subscribed
}
diff --git a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
index b0ba724e727..3c035bcc6db 100644
--- a/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/epic_todo.query.graphql
@@ -1,9 +1,7 @@
query epicTodos($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
- __typename
id
issuable: epic(iid: $iid) {
- __typename
id
currentUserTodos(state: pending) {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql
index dceab61ed26..6b15fcda2e8 100644
--- a/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/group_milestones.query.graphql
@@ -2,7 +2,6 @@
query groupMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: group(fullPath: $fullPath) {
- __typename
id
attributes: milestones(
searchTitle: $title
diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
index e578cf3bda5..fcdc84c5a06 100644
--- a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql
@@ -1,9 +1,7 @@
query issueConfidential($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
confidential
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
index 48cbff252b3..4369104704a 100644
--- a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql
@@ -1,9 +1,7 @@
query issueDueDate($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
dueDate
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
index c3128d6d961..2c69cc04429 100644
--- a/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_reference.query.graphql
@@ -1,9 +1,7 @@
query issueReference($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
- __typename
issuable: issue(iid: $iid) {
- __typename
id
reference(full: true)
}
diff --git a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
index e2722fc86a4..419036ee15d 100644
--- a/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_subscribed.query.graphql
@@ -1,9 +1,7 @@
query issueSubscribed($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
subscribed
emailsDisabled
diff --git a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
index 059361dd370..f4d0e9b5deb 100644
--- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
@@ -1,9 +1,7 @@
query issueTimeTracking($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
humanTimeEstimate
humanTotalTimeSpent
diff --git a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
index 5cd5d81c439..3211ded66ae 100644
--- a/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_todo.query.graphql
@@ -1,9 +1,7 @@
query issueTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
currentUserTodos(state: pending) {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
index b0a16677cf2..26bf901babf 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_milestone.query.graphql
@@ -2,10 +2,8 @@
query mergeRequestMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
attribute: milestone {
...MilestoneFragment
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
index 7c78f812b67..e42e50ba861 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_reference.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestReference($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
reference(full: true)
}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
index d5e27ca7b69..d29f4d512c5 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_subscribed.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestSubscribed($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
subscribed
}
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
index d480ff3d5ba..5d05cb2f34c 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
humanTimeEstimate
humanTotalTimeSpent
diff --git a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
index 65b9ef45260..906bfcdf9cd 100644
--- a/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/merge_request_todo.query.graphql
@@ -1,9 +1,7 @@
query mergeRequestTodos($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: mergeRequest(iid: $iid) {
- __typename
id
currentUserTodos(state: pending) {
nodes {
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
index 721a71bef63..507221946fa 100644
--- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.mutation.graphql
@@ -2,10 +2,8 @@ mutation projectIssueMilestoneMutation($fullPath: ID!, $iid: String!, $attribute
issuableSetAttribute: updateIssue(
input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
) {
- __typename
errors
issuable: issue {
- __typename
id
attribute: milestone {
title
diff --git a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
index c7f3adc9aca..bcb055d4f0f 100644
--- a/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_issue_milestone.query.graphql
@@ -2,10 +2,8 @@
query projectIssueMilestone($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
attribute: milestone {
...MilestoneFragment
diff --git a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
index d9eab18628d..b75c2138525 100644
--- a/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/project_milestones.query.graphql
@@ -2,7 +2,6 @@
query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
workspace: project(fullPath: $fullPath) {
- __typename
id
attributes: milestones(
searchTitle: $title
diff --git a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql b/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql
deleted file mode 100644
index d9b9c04fd63..00000000000
--- a/app/assets/javascripts/sidebar/queries/remove_attention_request.mutation.graphql
+++ /dev/null
@@ -1,7 +0,0 @@
-mutation mergeRequestRemoveAttentionRequest($projectPath: ID!, $iid: String!, $userId: UserID!) {
- mergeRequestRemoveAttentionRequest(
- input: { projectPath: $projectPath, iid: $iid, userId: $userId }
- ) {
- errors
- }
-}
diff --git a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql b/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql
deleted file mode 100644
index 99a86e4fe5c..00000000000
--- a/app/assets/javascripts/sidebar/queries/request_attention.mutation.graphql
+++ /dev/null
@@ -1,5 +0,0 @@
-mutation mergeRequestRequestAttention($projectPath: ID!, $iid: String!, $userId: UserID!) {
- mergeRequestRequestAttention(input: { projectPath: $projectPath, iid: $iid, userId: $userId }) {
- errors
- }
-}
diff --git a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
index 4675db9153e..51d461989e4 100644
--- a/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/todo_create.mutation.graphql
@@ -1,6 +1,5 @@
mutation issuableTodoCreate($input: TodoCreateInput!) {
todoMutation: todoCreate(input: $input) {
- __typename
todo {
id
}
diff --git a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
index 8253e5e82bc..4a91147c246 100644
--- a/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/todo_mark_done.mutation.graphql
@@ -1,6 +1,5 @@
mutation issuableTodoMarkDone($input: TodoMarkDoneInput!) {
todoMutation: todoMarkDone(input: $input) {
- __typename
todo {
id
}
diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
index 938953ccfb2..2714d815bcd 100644
--- a/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
+++ b/app/assets/javascripts/sidebar/queries/update_merge_request_milestone.mutation.graphql
@@ -2,10 +2,8 @@ mutation mergeRequestSetMilestone($fullPath: ID!, $iid: String!, $attributeId: M
issuableSetAttribute: mergeRequestSetMilestone(
input: { projectPath: $fullPath, iid: $iid, milestoneId: $attributeId }
) {
- __typename
errors
issuable: mergeRequest {
- __typename
id
attribute: milestone {
title
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index 05268a5c89c..beacdeb559c 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -5,8 +5,6 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import axios from '~/lib/utils/axios_utils';
import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql';
import sidebarDetailsMRQuery from '../queries/sidebar_details_mr.query.graphql';
-import requestAttentionMutation from '../queries/request_attention.mutation.graphql';
-import removeAttentionRequestMutation from '../queries/remove_attention_request.mutation.graphql';
const queries = {
merge_request: sidebarDetailsMRQuery,
@@ -93,25 +91,4 @@ export default class SidebarService {
},
});
}
-
- requestAttention(userId) {
- return gqClient.mutate({
- mutation: requestAttentionMutation,
- variables: {
- userId: convertToGraphQLId(TYPE_USER, `${userId}`),
- projectPath: this.fullPath,
- iid: this.iid.toString(),
- },
- });
- }
- removeAttentionRequest(userId) {
- return gqClient.mutate({
- mutation: removeAttentionRequestMutation,
- variables: {
- userId: convertToGraphQLId(TYPE_USER, `${userId}`),
- projectPath: this.fullPath,
- iid: this.iid.toString(),
- },
- });
- }
}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 74ab65e4e04..1be670f7590 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -3,17 +3,7 @@ import Mediator from './sidebar_mediator';
export default (store) => {
const mediator = new Mediator(getSidebarOptions());
- mediator
- .fetch()
- .then(() => {
- if (window.gon?.features?.mrAttentionRequests) {
- return import('~/attention_requests');
- }
-
- return null;
- })
- .then((module) => module?.initSideNavPopover())
- .catch(() => {});
+ mediator.fetch();
mountSidebar(mediator, store);
};
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 4df00903ab6..f7c93b6903c 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,8 +1,7 @@
import Store from '~/sidebar/stores/sidebar_store';
import createFlash from '~/flash';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
import toast from '~/vue_shared/plugins/global_toast';
-import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { visitUrl } from '../lib/utils/url_utility';
import Service from './services/sidebar_service';
@@ -42,7 +41,6 @@ export default class SidebarMediator {
const data = { assignee_ids: assignees };
try {
- const { currentUserHasAttention } = this.store;
const res = await this.service.update(field, data);
this.store.overwrite('assignees', res.data.assignees);
@@ -51,10 +49,6 @@ export default class SidebarMediator {
this.store.overwrite('reviewers', res.data.reviewers);
}
- if (currentUserHasAttention && this.store.isAddingAssignee) {
- toast(__('Assigned user(s). Your attention request was removed.'));
- }
-
return Promise.resolve(res);
} catch (e) {
return Promise.reject(e);
@@ -70,16 +64,11 @@ export default class SidebarMediator {
const data = { reviewer_ids: reviewers };
try {
- const { currentUserHasAttention } = this.store;
const res = await this.service.update(field, data);
this.store.overwrite('reviewers', res.data.reviewers);
this.store.overwrite('assignees', res.data.assignees);
- if (currentUserHasAttention && this.store.isAddingAssignee) {
- toast(__('Requested review. Your attention request was removed.'));
- }
-
return Promise.resolve(res);
} catch (e) {
return Promise.reject();
@@ -97,80 +86,6 @@ export default class SidebarMediator {
.catch(() => callback(userId, false));
}
- removeCurrentUserAttentionRequested() {
- const currentUserId = gon.current_user_id;
-
- const currentUserReviewer = this.store.findReviewer({ id: currentUserId });
- const currentUserAssignee = this.store.findAssignee({ id: currentUserId });
-
- if (currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested) {
- // Update current users attention_requested state
- this.store.updateReviewer(currentUserId, 'attention_requested');
- this.store.updateAssignee(currentUserId, 'attention_requested');
- }
- }
-
- async toggleAttentionRequested(type, { user, callback, direction }) {
- const mutations = {
- add: (id) => this.service.requestAttention(id),
- remove: (id) => this.service.removeAttentionRequest(id),
- };
-
- try {
- const isReviewer = type === 'reviewer';
- const reviewerOrAssignee = isReviewer
- ? this.store.findReviewer(user)
- : this.store.findAssignee(user);
-
- await mutations[direction]?.(user.id);
-
- if (reviewerOrAssignee.attention_requested) {
- toast(
- sprintf(__('Removed attention request from @%{username}'), {
- username: user.username,
- }),
- );
- } else {
- const currentUserId = gon.current_user_id;
- const { currentUserHasAttention } = this.store;
-
- if (currentUserId !== user.id) {
- this.removeCurrentUserAttentionRequested();
- }
-
- toast(
- currentUserHasAttention && currentUserId !== user.id
- ? sprintf(
- __(
- 'Requested attention from @%{username}. Your own attention request was removed.',
- ),
- { username: user.username },
- )
- : sprintf(__('Requested attention from @%{username}'), { username: user.username }),
- );
- }
-
- this.store.updateReviewer(user.id, 'attention_requested');
- this.store.updateAssignee(user.id, 'attention_requested');
-
- refreshUserMergeRequestCounts();
- callback();
- } catch (error) {
- callback();
- createFlash({
- message: sprintf(__('Updating the attention request for %{username} failed.'), {
- username: user.username,
- }),
- error,
- captureError: true,
- actionConfig: {
- title: __('Try again'),
- clickHandler: () => this.toggleAttentionRequired(type, { user, callback, direction }),
- },
- });
- }
- }
-
setMoveToProjectId(projectId) {
this.store.setMoveToProjectId(projectId);
}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 971e2a15c68..e2581a8f30e 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -19,9 +19,7 @@ export default class SidebarStore {
this.humanTimeSpent = '';
this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
- this.addingAssignees = [];
this.reviewers = [];
- this.addingReviewers = [];
this.isFetching = {
assignees: true,
reviewers: true,
@@ -77,20 +75,12 @@ export default class SidebarStore {
if (!this.findAssignee(assignee)) {
this.changing = true;
this.assignees.push(assignee);
-
- if (assignee.id !== this.currentUser.id) {
- this.addingAssignees.push(assignee.id);
- }
}
}
addReviewer(reviewer) {
if (!this.findReviewer(reviewer)) {
this.reviewers.push(reviewer);
-
- if (reviewer.id !== this.currentUser.id) {
- this.addingReviewers.push(reviewer.id);
- }
}
}
@@ -126,14 +116,12 @@ export default class SidebarStore {
if (assignee) {
this.changing = true;
this.assignees = this.assignees.filter(({ id }) => id !== assignee.id);
- this.addingAssignees = this.addingAssignees.filter(({ id }) => id !== assignee.id);
}
}
removeReviewer(reviewer) {
if (reviewer) {
this.reviewers = this.reviewers.filter(({ id }) => id !== reviewer.id);
- this.addingReviewers = this.addingReviewers.filter(({ id }) => id !== reviewer.id);
}
}
@@ -161,26 +149,4 @@ export default class SidebarStore {
setMoveToProjectId(moveToProjectId) {
this.moveToProjectId = moveToProjectId;
}
-
- get currentUserHasAttention() {
- if (!window.gon?.features?.mrAttentionRequests || !this.isMergeRequest) return false;
-
- const currentUserId = this.currentUser.id;
- const currentUserReviewer = this.findReviewer({ id: currentUserId });
- const currentUserAssignee = this.findAssignee({ id: currentUserId });
-
- return currentUserReviewer?.attention_requested || currentUserAssignee?.attention_requested;
- }
-
- get isAddingAssignee() {
- return this.addingAssignees.length > 0;
- }
-
- get isAddingReviewer() {
- return this.addingReviewers.length > 0;
- }
-
- get isMergeRequest() {
- return this.issuableType === 'merge_request';
- }
}
diff --git a/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql
deleted file mode 100644
index d75b4011d1c..00000000000
--- a/app/assets/javascripts/snippets/fragments/snippet_base.fragment.graphql
+++ /dev/null
@@ -1,35 +0,0 @@
-#import '~/graphql_shared/fragments/blobviewer.fragment.graphql'
-
-fragment SnippetBase on Snippet {
- id
- title
- description
- descriptionHtml
- createdAt
- updatedAt
- visibilityLevel
- webUrl
- httpUrlToRepo
- sshUrlToRepo
- blobs {
- nodes {
- binary
- name
- path
- rawPath
- size
- externalStorage
- renderedAsText
- simpleViewer {
- ...BlobViewer
- }
- richViewer {
- ...BlobViewer
- }
- }
- }
- userPermissions {
- adminSnippet
- updateSnippet
- }
-}
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.js b/app/assets/javascripts/surveys/merge_request_experience/app.js
index ea5d8aef3c5..50b1c2c3f39 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.js
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.js
@@ -8,12 +8,17 @@ Vue.use(Translate);
Vue.use(VueApollo);
export const startMrSurveyApp = () => {
+ const mountEl = document.querySelector('#js-mr-experience-survey');
+ if (!mountEl) return;
+
let channel = null;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
+ const { accountAge } = mountEl.dataset;
+
const app = new Vue({
apolloProvider,
data() {
@@ -24,6 +29,9 @@ export const startMrSurveyApp = () => {
render(h) {
if (this.hidden) return null;
return h(MergeRequestExperienceSurveyApp, {
+ props: {
+ accountAge: Number(accountAge),
+ },
on: {
close: () => {
channel?.postMessage('close');
@@ -37,7 +45,7 @@ export const startMrSurveyApp = () => {
},
});
- app.$mount('#js-mr-experience-survey');
+ app.$mount(mountEl);
if (window.BroadcastChannel) {
channel = new BroadcastChannel('mr_survey');
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index 85eed6ae82a..4e4ef49b1c6 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -32,6 +32,12 @@ export default {
tooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
+ props: {
+ accountAge: {
+ type: Number,
+ required: true,
+ },
+ },
i18n: {
survey: s__('MrSurvey|Merge request experience survey'),
close: __('Close'),
@@ -68,6 +74,9 @@ export default {
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
+ extra: {
+ accountAge: this.accountAge,
+ },
});
this.stepIndex += 1;
if (!this.step) {
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index 79a30340856..6e72d95c8e6 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -62,13 +62,21 @@ export default class TaskList {
.prop('disabled', true);
}
+ updateInapplicableTaskListItems(e) {
+ this.getTaskListTarget(e)
+ .find('.task-list-item-checkbox[data-inapplicable]')
+ .prop('disabled', true);
+ }
+
disableTaskListItems(e) {
this.getTaskListTarget(e).taskList('disable');
+ this.updateInapplicableTaskListItems();
}
enableTaskListItems(e) {
this.getTaskListTarget(e).taskList('enable');
this.disableNonMarkdownTaskListItems(e);
+ this.updateInapplicableTaskListItems(e);
}
enable() {
diff --git a/app/assets/javascripts/test_utils/index.js b/app/assets/javascripts/test_utils/index.js
index 2727485fb95..369cf9714e8 100644
--- a/app/assets/javascripts/test_utils/index.js
+++ b/app/assets/javascripts/test_utils/index.js
@@ -1,8 +1,10 @@
+import { editor } from 'monaco-editor';
import { Sortable } from 'sortablejs';
import simulateDrag from './simulate_drag';
import simulateInput from './simulate_input';
// Export to global space for rspec to use
+window.localMonaco = editor;
window.simulateDrag = simulateDrag;
window.simulateInput = simulateInput;
window.Sortable = Sortable;
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 3356cada58a..c2892fb8dac 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -131,7 +131,9 @@ const lazyLaunchPopover = debounce((mountPopover, event) => {
let hasAddedLazyPopovers = false;
export default function addPopovers(mountPopover = (instance) => instance.$mount()) {
- if (!hasAddedLazyPopovers) {
+ // The web request fails for anonymous users so we don't want to show the popover when the user is not signed in.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/351395#note_1039341458
+ if (window.gon?.current_user_id && !hasAddedLazyPopovers) {
document.addEventListener('mouseover', (event) => lazyLaunchPopover(mountPopover, event));
hasAddedLazyPopovers = true;
}
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
new file mode 100644
index 00000000000..65f0eceae55
--- /dev/null
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -0,0 +1,10 @@
+export const VISIBILITY_LEVEL_PRIVATE = 'private';
+export const VISIBILITY_LEVEL_INTERNAL = 'internal';
+export const VISIBILITY_LEVEL_PUBLIC = 'public';
+
+// Matches `lib/gitlab/visibility_level.rb`
+export const VISIBILITY_LEVELS_ENUM = {
+ [VISIBILITY_LEVEL_PRIVATE]: 0,
+ [VISIBILITY_LEVEL_INTERNAL]: 10,
+ [VISIBILITY_LEVEL_PUBLIC]: 20,
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index b76d5d90ead..38f40e8a3c8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -14,7 +14,8 @@ export default {
props: {
widget: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
tertiaryButtons: {
type: Array,
@@ -30,6 +31,8 @@ export default {
},
computed: {
dropdownLabel() {
+ if (!this.widget) return undefined;
+
return sprintf(__('%{widget} options'), { widget: this.widget });
},
},
@@ -85,6 +88,7 @@ export default {
:href="btn.href"
:target="btn.target"
:data-clipboard-text="btn.dataClipboardText"
+ :data-method="btn.dataMethod"
@click="onClickAction(btn)"
>
{{ btn.text }}
@@ -99,11 +103,15 @@ export default {
:title="setTooltip(btn)"
:href="btn.href"
:target="btn.target"
- :class="{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }"
+ :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
:data-clipboard-text="btn.dataClipboardText"
+ :data-qa-selector="btn.dataQaSelector"
+ :data-method="btn.dataMethod"
:icon="btn.icon"
:data-testid="btn.testId || 'extension-actions-button'"
:variant="btn.variant || 'confirm'"
+ :loading="btn.loading"
+ :disabled="btn.loading"
category="tertiary"
size="small"
class="gl-display-none gl-md-display-block gl-float-left"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
index 437d035fbf5..254b280bf14 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue
@@ -2,9 +2,9 @@
import { GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { n__, s__ } from '~/locale';
+import { n__, s__, sprintf } from '~/locale';
-const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit');
+const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} merge commit');
export default {
components: {
@@ -49,40 +49,45 @@ export default {
return escape(this.targetBranch);
},
commitsCountMessage() {
- return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount);
+ const count = this.isSquashEnabled ? 1 : this.commitsCount;
+
+ return sprintf(
+ n__(
+ '%{strongStart}%{count}%{strongEnd} commit',
+ '%{strongStart}%{count}%{strongEnd} commits',
+ count,
+ ),
+ { count },
+ );
},
message() {
- if (this.glFeatures.restructuredMrWidget) {
- if (this.state === 'closed') {
- return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
- } else if (this.isMerged) {
- return s__(
- 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.',
- );
- }
-
- return this.isFastForwardEnabled
- ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
- : s__(
- 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.',
- );
+ if (this.state === 'closed') {
+ return s__('mrWidgetCommitsAdded|The changes were not merged into %{targetBranch}.');
+ } else if (this.isMerged) {
+ return s__(
+ 'mrWidgetCommitsAdded|Changes merged into %{targetBranch} with %{mergeCommitSha}%{squashedCommits}.',
+ );
}
return this.isFastForwardEnabled
- ? s__('mrWidgetCommitsAdded|Adds %{commitCount} to %{targetBranch}.')
+ ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
: s__(
- 'mrWidgetCommitsAdded|Adds %{commitCount} and %{mergeCommitCount} to %{targetBranch}%{squashedCommits}.',
+ 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}%{squashedCommits}.',
);
},
- textDecorativeComponent() {
- return this.glFeatures.restructuredMrWidget ? 'span' : 'strong';
- },
squashCommitMessage() {
if (this.isMerged) {
- return s__('mergedCommitsAdded|(commits were squashed)');
+ return s__('mergedCommitsAdded| (commits were squashed)');
}
- return n__('(squashes %d commit)', '(squashes %d commits)', this.commitsCount);
+ return sprintf(
+ n__(
+ ' (squashes %{strongStart}%{count}%{strongEnd} commit)',
+ ' (squashes %{strongStart}%{count}%{strongEnd} commits)',
+ this.commitsCount,
+ ),
+ { count: this.commitsCount },
+ );
},
},
mergeCommitCount,
@@ -93,25 +98,33 @@ export default {
<span>
<gl-sprintf :message="message">
<template #commitCount>
- <component :is="textDecorativeComponent" class="commits-count-message">{{
- commitsCountMessage
- }}</component>
+ <gl-sprintf :message="commitsCountMessage">
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
+ </gl-sprintf>
</template>
<template #mergeCommitCount>
- <component :is="textDecorativeComponent">{{ $options.mergeCommitCount }}</component>
+ <gl-sprintf :message="$options.mergeCommitCount">
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
+ </gl-sprintf>
</template>
<template #targetBranch>
- <span class="label-branch">{{ targetBranchEscaped }}</span>
+ <span class="label-branch gl-font-weight-bold">{{ targetBranchEscaped }}</span>
</template>
<template #squashedCommits>
- <template v-if="glFeatures.restructuredMrWidget && isSquashEnabled">
- {{ squashCommitMessage }}</template
- ></template
- >
+ <template v-if="isSquashEnabled">
+ <gl-sprintf :message="squashCommitMessage">
+ <template #strong="{ content }">
+ <span class="gl-font-weight-bold">{{ content }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+ </template>
<template #mergeCommitSha>
- <template v-if="glFeatures.restructuredMrWidget"
- ><span class="label-branch">{{ mergeCommitSha }}</span></template
- >
+ <span class="label-branch">{{ mergeCommitSha }}</span>
</template>
</gl-sprintf>
</span>
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 4163d195e0f..f782c28ea19 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
@@ -4,9 +4,6 @@ import createFlash from '~/flash';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__, __ } from '~/locale';
-import sidebarEventHub from '~/sidebar/event_hub';
-import showToast from '~/vue_shared/plugins/global_toast';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
import eventHub from '../../event_hub';
import approvalsMixin from '../../mixins/approvals';
import MrWidgetContainer from '../mr_widget_container.vue';
@@ -192,16 +189,8 @@ export default {
.then((data) => {
this.mr.setApprovals(data);
- if (
- this.glFeatures.mrAttentionRequests &&
- SidebarMediator.singleton?.store.currentUserHasAttention
- ) {
- showToast(__('Approved. Your attention request was removed.'));
- }
-
eventHub.$emit('MRWidgetUpdateRequested');
eventHub.$emit('ApprovalUpdated');
- sidebarEventHub.$emit('removeCurrentUserAttentionRequested');
this.$emit('updated');
})
.catch(errFn)
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
index bb1837399ed..1256b3a8e52 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue
@@ -80,22 +80,26 @@ export default {
},
computeGraphData(metrics, deploymentTime) {
this.loadingMetrics = false;
- const { memory_before, memory_after, memory_values } = metrics;
+ const {
+ memory_before: memoryBefore,
+ memory_after: memoryAfter,
+ memory_values: memoryValues,
+ } = metrics;
// Both `memory_before` and `memory_after` objects
// have peculiar structure where accessing only a specific
// index yeilds correct value that we can use to show memory delta.
- if (memory_before.length > 0) {
- this.memoryFrom = this.getMegabytes(memory_before[0].value[1]);
+ if (memoryBefore.length > 0) {
+ this.memoryFrom = this.getMegabytes(memoryBefore[0].value[1]);
}
- if (memory_after.length > 0) {
- this.memoryTo = this.getMegabytes(memory_after[0].value[1]);
+ if (memoryAfter.length > 0) {
+ this.memoryTo = this.getMegabytes(memoryAfter[0].value[1]);
}
- if (memory_values.length > 0) {
+ if (memoryValues.length > 0) {
this.hasMetrics = true;
- this.memoryMetrics = memory_values[0].values;
+ this.memoryMetrics = memoryValues[0].values;
this.deploymentTime = deploymentTime;
}
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md b/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md
new file mode 100644
index 00000000000..45ebafec8bf
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/README.md
@@ -0,0 +1 @@
+Please see [the Widget Extensions documentation](development/merge_request_concepts/widget_extensions.md) for necessary information regarding development of new MR Widgets.
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 410331004e4..414c5bf9691 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
@@ -12,8 +12,8 @@ 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 Actions from '../action_buttons.vue';
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';
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 38f83a61b30..1eccc7de660 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
@@ -1,7 +1,7 @@
<script>
import { GlBadge, GlLink, GlSafeHtmlDirective, GlModalDirective } from '@gitlab/ui';
+import Actions from '../action_buttons.vue';
import StatusIcon from './status_icon.vue';
-import Actions from './actions.vue';
import { generateText } from './utils';
export default {
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
index b551cd2fd60..bc84459e298 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js
@@ -34,6 +34,36 @@ const nonStandardEvents = {
},
counter: {},
},
+ metrics: {
+ uniqueUser: {
+ expand: ['i_testing_metrics_report_widget_total'],
+ },
+ counter: {},
+ },
+ browserPerformance: {
+ uniqueUser: {
+ expand: ['i_testing_web_performance_widget_total'],
+ },
+ counter: {},
+ },
+ licenseCompliance: {
+ uniqueUser: {
+ expand: ['i_testing_license_compliance_widget_total'],
+ },
+ counter: {},
+ },
+ loadPerformance: {
+ uniqueUser: {
+ expand: ['i_testing_load_performance_widget_total'],
+ },
+ counter: {},
+ },
+ statusChecks: {
+ uniqueUser: {
+ expand: ['i_testing_status_checks_widget'],
+ },
+ counter: {},
+ },
};
function combineDeepArray(path, ...objects) {
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 913aa0e1e34..94a1b805b99 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
@@ -1,7 +1,6 @@
<script>
-import { GlSafeHtmlDirective as SafeHtml, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui';
import { s__, n__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'MRWidgetRelatedLinks',
@@ -10,9 +9,7 @@ export default {
},
components: {
GlLink,
- GlSprintf,
},
- mixins: [glFeatureFlagMixin()],
props: {
relatedLinks: {
type: Object,
@@ -67,42 +64,21 @@ export default {
</script>
<template>
<section>
- <p
- v-if="relatedLinks.closing"
- :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
- >
+ <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0">
{{ closesText }}
<span v-safe-html="relatedLinks.closing"></span>
</p>
- <p
- v-if="relatedLinks.mentioned"
- :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
- >
- <span v-if="relatedLinks.closing && glFeatures.restructuredMrWidget">&middot;</span>
+ <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0">
+ <span v-if="relatedLinks.closing">&middot;</span>
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
<span v-safe-html="relatedLinks.mentioned"></span>
</p>
- <p
- v-if="shouldShowAssignToMeLink"
- :class="{ 'gl-display-inline gl-m-0': glFeatures.restructuredMrWidget }"
- >
+ <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0">
<span>
<gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{
assignIssueText
}}</gl-link>
</span>
</p>
- <div
- v-if="divergedCommitsCount > 0 && !glFeatures.restructuredMrWidget"
- class="diverged-commits-count"
- >
- <gl-sprintf :message="s__('mrWidget|The source branch is %{link} the target branch')">
- <template #link>
- <gl-link :href="targetBranchPath">{{
- n__('%d commit behind', '%d commits behind', divergedCommitsCount)
- }}</gl-link>
- </template>
- </gl-sprintf>
- </div>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 7ff1eb6e73a..5b8acb4ebf8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,25 +1,17 @@
<script>
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { GlLoadingIcon } from '@gitlab/ui';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
export default {
components: {
ciIcon,
- GlButton,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
status: {
type: String,
required: true,
},
- showDisabledButton: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
isLoading() {
@@ -42,15 +34,5 @@ export default {
</div>
<ci-icon v-else :status="statusObj" :size="24" />
</div>
-
- <gl-button
- v-if="!glFeatures.restructuredMrWidget && showDisabledButton"
- category="primary"
- variant="confirm"
- data-testid="disabled-merge-button"
- :disabled="true"
- >
- {{ s__('mrWidget|Merge') }}
- </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
new file mode 100644
index 00000000000..4a5a03fb598
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -0,0 +1,55 @@
+<script>
+import StatusIcon from './mr_widget_status_icon.vue';
+import Actions from './action_buttons.vue';
+
+export default {
+ components: {
+ StatusIcon,
+ Actions,
+ },
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ status: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ actions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <slot name="loading"></slot>
+ </div>
+ <template v-else>
+ <slot name="icon">
+ <status-icon :status="status" />
+ </slot>
+ <div
+ :class="{ 'gl-display-flex': actions.length, 'gl-md-display-flex': !actions.length }"
+ class="media-body"
+ >
+ <slot></slot>
+ <div
+ :class="{ 'gl-flex-direction-column-reverse': !actions.length }"
+ class="gl-display-flex gl-md-display-block gl-font-size-0 gl-ml-auto gl-mt-1"
+ >
+ <slot name="actions">
+ <actions v-if="actions.length" :tertiary-buttons="actions" />
+ </slot>
+ </div>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
index 18761d04c2e..515a7cf51a1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -1,8 +1,5 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-
export default {
- mixins: [glFeatureFlagMixin()],
props: {
value: {
type: String,
@@ -23,10 +20,7 @@ export default {
<template>
<li>
<div class="commit-message-editor">
- <div
- :class="{ 'gl-mb-3': glFeatures.restructuredMrWidget }"
- class="d-flex flex-wrap align-items-center justify-content-between"
- >
+ <div class="d-flex flex-wrap align-items-center justify-content-between gl-mb-3">
<label class="col-form-label" :for="inputId">
<strong>{{ label }}</strong>
</label>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
index 071920856a8..f74826f95d3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -7,7 +6,6 @@ export default {
components: {
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
};
</script>
<template>
@@ -16,7 +14,7 @@ export default {
<status-icon status="warning" show-disabled-button />
</div>
<div class="media-body">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
{{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index aabbeac564a..690acc9a6dc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -1,15 +1,15 @@
<script>
-import { GlSkeletonLoader, GlIcon, GlButton, GlSprintf } from '@gitlab/ui';
+import { GlSkeletonLoader, GlIcon, GlSprintf } from '@gitlab/ui';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql';
import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import MrWidgetAuthor from '../mr_widget_author.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetAutoMergeEnabled',
@@ -29,8 +29,8 @@ export default {
MrWidgetAuthor,
GlSkeletonLoader,
GlIcon,
- GlButton,
GlSprintf,
+ StateContainer,
},
mixins: [autoMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
props: {
@@ -78,18 +78,25 @@ export default {
autoMergeStrategy() {
return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy;
},
- canRemoveSourceBranch() {
- const { currentUserId } = this.mr;
- const mergeUserId = this.glFeatures.mergeRequestWidgetGraphql
- ? getIdFromGraphQLId(this.state.mergeUser?.id)
- : this.mr.mergeUserId;
- const canRemoveSourceBranch = this.glFeatures.mergeRequestWidgetGraphql
- ? this.state.userPermissions.removeSourceBranch
- : this.mr.canRemoveSourceBranch;
+ actions() {
+ const actions = [];
- return (
- !this.shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId
- );
+ if (this.loading) {
+ return actions;
+ }
+
+ if (this.mr.canCancelAutomaticMerge) {
+ actions.push({
+ text: this.cancelButtonText,
+ loading: this.isCancellingAutoMerge,
+ dataQaSelector: 'cancel_auto_merge_button',
+ class: 'js-cancel-auto-merge',
+ testId: 'cancelAutomaticMergeButton',
+ onClick: () => this.cancelAutomaticMerge(),
+ });
+ }
+
+ return actions;
},
},
methods: {
@@ -144,56 +151,25 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <div v-if="loading" class="gl-w-full mr-conflict-loader">
+ <state-container status="scheduled" :is-loading="loading" :actions="actions">
+ <template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="3" width="24" height="24" rx="4" />
<rect x="32" y="7" width="150" height="16" rx="4" />
<rect x="190" y="7" width="144" height="16" rx="4" />
</gl-skeleton-loader>
- </div>
- <template v-else>
+ </template>
+ <template v-if="!loading">
+ <h4 class="gl-mr-3" data-testid="statusText">
+ <gl-sprintf :message="statusText" data-testid="statusText">
+ <template #merge_author>
+ <mr-widget-author :author="mergeUser" />
+ </template>
+ </gl-sprintf>
+ </h4>
+ </template>
+ <template v-if="!loading" #icon>
<gl-icon name="status_scheduled" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- <div class="media-body">
- <h4 class="gl-display-flex">
- <span class="gl-mr-3">
- <gl-sprintf :message="statusText" data-testid="statusText">
- <template #merge_author>
- <mr-widget-author :author="mergeUser" />
- </template>
- </gl-sprintf>
- </span>
- <gl-button
- v-if="mr.canCancelAutomaticMerge"
- :loading="isCancellingAutoMerge"
- size="small"
- class="js-cancel-auto-merge"
- data-qa-selector="cancel_auto_merge_button"
- data-testid="cancelAutomaticMergeButton"
- @click="cancelAutomaticMerge"
- >
- {{ cancelButtonText }}
- </gl-button>
- </h4>
- <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list">
- <p v-if="shouldRemoveSourceBranch">
- {{ s__('mrWidget|Deletes the source branch') }}
- </p>
- <p v-else class="gl-display-flex">
- <span class="gl-mr-3">{{ s__('mrWidget|Does not delete the source branch') }}</span>
- <gl-button
- v-if="canRemoveSourceBranch"
- :loading="isRemovingSourceBranch"
- size="small"
- class="js-remove-source-branch"
- data-testid="removeSourceBranchButton"
- @click="removeSourceBranch"
- >
- {{ s__('mrWidget|Delete source branch') }}
- </gl-button>
- </p>
- </section>
- </div>
</template>
- </div>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
index 1a764d3d091..b0cda85f361 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue
@@ -1,17 +1,15 @@
<script>
-import { GlLoadingIcon, GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import autoMergeFailedQuery from '../../queries/states/auto_merge_failed.query.graphql';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetAutoMergeFailed',
components: {
- statusIcon,
- GlLoadingIcon,
- GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -38,6 +36,17 @@ export default {
isRefreshing: false,
};
},
+ computed: {
+ actions() {
+ return [
+ {
+ text: s__('mrWidget|Refresh'),
+ loading: this.isRefreshing,
+ onClick: () => this.refreshWidget(),
+ },
+ ];
+ },
+ },
methods: {
refreshWidget() {
this.isRefreshing = true;
@@ -49,23 +58,10 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon status="warning" />
- <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center">
- <span class="bold">
- <template v-if="mergeError">{{ mergeError }}</template>
- {{ s__('mrWidget|This merge request failed to be merged automatically') }}
- </span>
- <gl-button
- :disabled="isRefreshing"
- category="secondary"
- variant="default"
- size="small"
- @click="refreshWidget"
- >
- <gl-loading-icon v-if="isRefreshing" size="sm" :inline="true" />
- {{ s__('mrWidget|Refresh') }}
- </gl-button>
- </div>
- </div>
+ <state-container status="warning" :actions="actions">
+ <span class="bold gl-ml-0!">
+ <template v-if="mergeError">{{ mergeError }}</template>
+ {{ s__('mrWidget|This merge request failed to be merged automatically') }}
+ </span>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
index fd42fa0421f..e2d87d8d536 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
@@ -7,14 +6,13 @@ export default {
components: {
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
};
</script>
<template>
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="loading" />
<div class="media-body space-children">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
{{ s__('mrWidget|Checking if merge request can be merged…') }}
</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index d50e52f5ac1..61f7d26f51e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -1,5 +1,4 @@
<script>
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -9,7 +8,6 @@ export default {
MrWidgetAuthorTime,
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
/* TODO: This is providing all store and service down when it
only needs metrics and targetBranch */
@@ -30,13 +28,6 @@ export default {
:date-title="mr.metrics.closedAt"
:date-readable="mr.metrics.readableClosedAt"
/>
-
- <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list">
- <p>
- {{ s__('mrWidget|The changes were not merged into') }}
- <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a>
- </p>
- </section>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index def30dacf8a..8abd915b93e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -4,14 +4,14 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import userPermissionsQuery from '../../queries/permissions.query.graphql';
import conflictsStateQuery from '../../queries/states/conflicts.query.graphql';
-import StatusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetConflicts',
components: {
GlSkeletonLoader,
- StatusIcon,
GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -86,29 +86,23 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
-
- <div v-if="isLoading" class="gl-ml-4 gl-w-full mr-conflict-loader">
+ <state-container status="warning" :is-loading="isLoading">
+ <template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="7" width="150" height="16" rx="4" />
<rect x="158" y="7" width="84" height="16" rx="4" />
<rect x="250" y="7" width="84" height="16" rx="4" />
</gl-skeleton-loader>
- </div>
- <div v-else class="media-body space-children gl-display-flex gl-align-items-center">
- <span
- v-if="shouldBeRebased"
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="bold"
- >
+ </template>
+ <template v-if="!isLoading">
+ <span v-if="shouldBeRebased" class="bold gl-ml-0! gl-text-body!">
{{
s__(`mrWidget|Merge blocked: fast-forward merge is not possible.
To merge this request, first rebase locally.`)
}}
</span>
<template v-else>
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="bold gl-ml-0! gl-text-body! gl-flex-grow-1 gl-w-full gl-md-w-auto gl-mr-2">
{{ s__('mrWidget|Merge blocked: merge conflicts must be resolved.') }}
<span v-if="!canMerge">
{{
@@ -118,23 +112,30 @@ export default {
}}
</span>
</span>
- <gl-button
- v-if="showResolveButton"
- :href="mr.conflictResolutionPath"
- :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
- data-testid="resolve-conflicts-button"
- >
- {{ s__('mrWidget|Resolve conflicts') }}
- </gl-button>
- <gl-button
- v-if="canMerge"
- :size="glFeatures.restructuredMrWidget ? 'small' : 'medium'"
- data-testid="merge-locally-button"
- class="js-check-out-modal-trigger"
- >
- {{ s__('mrWidget|Resolve locally') }}
- </gl-button>
</template>
- </div>
- </div>
+ </template>
+ <template v-if="!isLoading && !shouldBeRebased" #actions>
+ <gl-button
+ v-if="canMerge"
+ size="small"
+ variant="confirm"
+ category="secondary"
+ data-testid="merge-locally-button"
+ class="js-check-out-modal-trigger gl-align-self-start"
+ :class="{ 'gl-mr-2': showResolveButton }"
+ >
+ {{ s__('mrWidget|Resolve locally') }}
+ </gl-button>
+ <gl-button
+ v-if="showResolveButton"
+ :href="mr.conflictResolutionPath"
+ size="small"
+ variant="confirm"
+ class="gl-mb-2 gl-md-mb-0 gl-align-self-start"
+ data-testid="resolve-conflicts-button"
+ >
+ {{ s__('mrWidget|Resolve conflicts') }}
+ </gl-button>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 42e9261b82c..18103ac4a0e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -30,7 +30,7 @@ export default {
computed: {
mergeError() {
- const mergeError = this.mr.mergeError ? stripHtml(this.mr.mergeError, ' ').trim() : '';
+ const mergeError = this.prepareMergeError(this.mr.mergeError);
return sprintf(
s__('mrWidget|%{mergeError}.'),
@@ -76,6 +76,13 @@ export default {
this.refresh();
}
},
+ prepareMergeError(mergeError) {
+ return mergeError
+ ? stripHtml(mergeError, ' ')
+ .replace(/(\.$|\s+)/g, ' ')
+ .trim()
+ : '';
+ },
},
};
</script>
@@ -89,7 +96,9 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
<span class="bold">
- <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }} </span>
+ <span v-if="mr.mergeError" class="has-error-message" data-testid="merge-error">
+ {{ mergeError }}
+ </span>
<span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
<span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index bf036f562ed..4416123cd51 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,15 +1,13 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlLoadingIcon, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import api from '~/api';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '~/projects/commit/constants';
import modalEventHub from '~/projects/commit/event_hub';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import eventHub from '../../event_hub';
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetMerged',
@@ -19,11 +17,8 @@ export default {
components: {
MrWidgetAuthorTime,
GlIcon,
- ClipboardButton,
- GlLoadingIcon,
- GlButton,
+ StateContainer,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -78,6 +73,53 @@ export default {
cherryPickLabel() {
return s__('mrWidget|Cherry-pick');
},
+ actions() {
+ const actions = [];
+
+ if (this.mr.canRevertInCurrentMR) {
+ actions.push({
+ text: this.revertLabel,
+ tooltipText: this.revertTitle,
+ dataQaSelector: 'revert_button',
+ onClick: () => this.openRevertModal(),
+ });
+ } else if (this.mr.revertInForkPath) {
+ actions.push({
+ text: this.revertLabel,
+ tooltipText: this.revertTitle,
+ href: this.mr.revertInForkPath,
+ dataQaSelector: 'revert_button',
+ dataMethod: 'post',
+ });
+ }
+
+ if (this.mr.canCherryPickInCurrentMR) {
+ actions.push({
+ text: this.cherryPickLabel,
+ tooltipText: this.cherryPickTitle,
+ dataQaSelector: 'cherry_pick_button',
+ onClick: () => this.openCherryPickModal(),
+ });
+ } else if (this.mr.cherryPickInForkPath) {
+ actions.push({
+ text: this.cherryPickLabel,
+ tooltipText: this.cherryPickTitle,
+ href: this.mr.cherryPickInForkPath,
+ dataQaSelector: 'cherry_pick_button',
+ dataMethod: 'post',
+ });
+ }
+
+ if (this.shouldShowRemoveSourceBranch) {
+ actions.push({
+ text: s__('mrWidget|Delete source branch'),
+ class: 'js-remove-branch-button',
+ onClick: () => this.removeSourceBranch(),
+ });
+ }
+
+ return actions;
+ },
},
mounted() {
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
@@ -121,103 +163,15 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- <div class="media-body">
- <div class="space-children">
- <mr-widget-author-time
- :action-text="s__('mrWidget|Merged by')"
- :author="mr.metrics.mergedBy"
- :date-title="mr.metrics.mergedAt"
- :date-readable="mr.metrics.readableMergedAt"
- />
- <gl-button
- v-if="mr.canRevertInCurrentMR"
- v-gl-tooltip.hover
- :title="revertTitle"
- size="small"
- category="secondary"
- data-qa-selector="revert_button"
- @click="openRevertModal"
- >
- {{ revertLabel }}
- </gl-button>
- <gl-button
- v-else-if="mr.revertInForkPath"
- v-gl-tooltip.hover
- :href="mr.revertInForkPath"
- :title="revertTitle"
- size="small"
- category="secondary"
- data-method="post"
- >
- {{ revertLabel }}
- </gl-button>
- <gl-button
- v-if="mr.canCherryPickInCurrentMR"
- v-gl-tooltip.hover
- :title="cherryPickTitle"
- size="small"
- data-qa-selector="cherry_pick_button"
- @click="openCherryPickModal"
- >
- {{ cherryPickLabel }}
- </gl-button>
- <gl-button
- v-else-if="mr.cherryPickInForkPath"
- v-gl-tooltip.hover
- :href="mr.cherryPickInForkPath"
- :title="cherryPickTitle"
- size="small"
- data-method="post"
- >
- {{ cherryPickLabel }}
- </gl-button>
- <gl-button
- v-if="shouldShowRemoveSourceBranch"
- :disabled="isMakingRequest"
- size="small"
- class="js-remove-branch-button"
- @click="removeSourceBranch"
- >
- {{ s__('mrWidget|Delete source branch') }}
- </gl-button>
- </div>
- <section
- v-if="!glFeatures.restructuredMrWidget"
- class="mr-info-list"
- data-qa-selector="merged_status_content"
- >
- <p>
- {{ s__('mrWidget|The changes were merged into') }}
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
- </span>
- <template v-if="mr.mergeCommitSha">
- with
- <a
- :href="mr.mergeCommitPath"
- class="commit-sha js-mr-merged-commit-sha"
- v-text="mr.shortMergeCommitSha"
- >
- </a>
- <clipboard-button
- :title="__('Copy commit SHA')"
- :text="mr.mergeCommitSha"
- css-class="js-mr-merged-copy-sha"
- category="tertiary"
- size="small"
- />
- </template>
- </p>
- <p v-if="mr.sourceBranchRemoved">
- {{ s__('mrWidget|The source branch has been deleted') }}
- </p>
- <p v-if="shouldShowSourceBranchRemoving">
- <gl-loading-icon size="sm" :inline="true" />
- <span> {{ s__('mrWidget|The source branch is being deleted') }} </span>
- </p>
- </section>
- </div>
- </div>
+ <state-container :actions="actions">
+ <template #icon>
+ <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
+ </template>
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Merged by')"
+ :author="mr.metrics.mergedBy"
+ :date-title="mr.metrics.mergedAt"
+ :date-readable="mr.metrics.readableMergedAt"
+ />
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
index b86ab69af3f..c7574a41bb8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue
@@ -1,6 +1,5 @@
<script>
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import eventHub from '../../event_hub';
@@ -15,7 +14,6 @@ export default {
components: {
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -90,14 +88,6 @@ export default {
{{ mergeStatus.message }}
<gl-emoji :data-name="mergeStatus.emoji" />
</h4>
- <section v-if="!glFeatures.restructuredMrWidget" class="mr-info-list">
- <p>
- {{ s__('mrWidget|Merges changes into') }}
- <span class="label-branch">
- <a :href="mr.targetBranchPath">{{ mr.targetBranch }}</a>
- </span>
- </p>
- </section>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index cadbd9c28a9..659d12d1160 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -74,13 +74,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span
- :class="{
- 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
- }"
- class="bold js-branch-text"
- data-testid="widget-content"
- >
+ <span class="gl-ml-0! gl-text-body! bold js-branch-text" data-testid="widget-content">
<gl-sprintf :message="warning">
<template #code="{ content }">
<code>{{ content }}</code>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
index 34c5a2ff2c8..e99ee59b877 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue
@@ -14,7 +14,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
{{
s__(
`mrWidget|Merge blocked: pipeline must succeed. It's waiting for a manual action to continue.`,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 59767eb2e6e..6c5fc916799 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -8,7 +8,7 @@ import simplePoll from '~/lib/utils/simple_poll';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetRebase',
@@ -25,9 +25,9 @@ export default {
},
},
components: {
- statusIcon,
GlSkeletonLoader,
GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
props: {
@@ -51,9 +51,6 @@ export default {
isLoading() {
return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading;
},
- showRebaseWithoutCi() {
- return this.glFeatures?.rebaseWithoutCiUi;
- },
rebaseInProgress() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.state.rebaseInProgress;
@@ -76,6 +73,10 @@ export default {
return this.mr.targetBranch;
},
status() {
+ if (this.isLoading) {
+ return undefined;
+ }
+
if (this.rebaseInProgress || this.isMakingRequest) {
return 'loading';
}
@@ -148,92 +149,70 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <div v-if="isLoading" class="gl-w-full mr-conflict-loader">
+ <state-container :status="status" :is-loading="isLoading">
+ <template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="3" width="24" height="24" rx="4" />
<rect x="32" y="5" width="302" height="20" rx="4" />
</gl-skeleton-loader>
- </div>
- <template v-else>
- <status-icon :status="status" :show-disabled-button="showDisabledButton" />
-
- <div class="rebase-state-find-class-convention media media-body space-children">
+ </template>
+ <template v-if="!isLoading">
+ <span
+ v-if="rebaseInProgress || isMakingRequest"
+ class="gl-ml-0! gl-text-body! gl-font-weight-bold"
+ data-testid="rebase-message"
+ >{{ __('Rebase in progress') }}</span
+ >
+ <span
+ v-if="!rebaseInProgress && !canPushToSourceBranch"
+ class="gl-text-body! gl-font-weight-bold gl-ml-0!"
+ data-testid="rebase-message"
+ >{{ fastForwardMergeText }}</span
+ >
+ <div
+ v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
+ class="accept-merge-holder clearfix js-toggle-container media gl-md-display-flex gl-flex-wrap gl-flex-grow-1"
+ >
<span
- v-if="rebaseInProgress || isMakingRequest"
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
+ v-if="!rebasingError"
+ class="gl-font-weight-bold gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-ml-0! gl-text-body! gl-md-mr-3"
data-testid="rebase-message"
- >{{ __('Rebase in progress') }}</span
+ data-qa-selector="no_fast_forward_message_content"
+ >{{
+ __('Merge blocked: the source branch must be rebased onto the target branch.')
+ }}</span
>
<span
- v-if="!rebaseInProgress && !canPushToSourceBranch"
- :class="{ 'gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold gl-ml-0!"
+ v-else
+ class="gl-font-weight-bold danger gl-w-100 gl-md-w-auto gl-flex-grow-1 gl-md-mr-3"
data-testid="rebase-message"
- >{{ fastForwardMergeText }}</span
- >
- <div
- v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
- class="accept-merge-holder clearfix js-toggle-container accept-action media space-children gl-align-items-center"
+ >{{ rebasingError }}</span
>
- <gl-button
- v-if="!glFeatures.restructuredMrWidget"
- :loading="isMakingRequest"
- variant="confirm"
- data-qa-selector="mr_rebase_button"
- data-testid="standard-rebase-button"
- @click="rebase"
- >
- {{ __('Rebase') }}
- </gl-button>
- <gl-button
- v-if="!glFeatures.restructuredMrWidget && showRebaseWithoutCi"
- :loading="isMakingRequest"
- variant="confirm"
- category="secondary"
- data-testid="rebase-without-ci-button"
- @click="rebaseWithoutCi"
- >
- {{ __('Rebase without pipeline') }}
- </gl-button>
- <span
- v-if="!rebasingError"
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
- data-testid="rebase-message"
- data-qa-selector="no_fast_forward_message_content"
- >{{
- __('Merge blocked: the source branch must be rebased onto the target branch.')
- }}</span
- >
- <span v-else class="gl-font-weight-bold danger" data-testid="rebase-message">{{
- rebasingError
- }}</span>
- <gl-button
- v-if="glFeatures.restructuredMrWidget"
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- data-qa-selector="mr_rebase_button"
- class="gl-ml-3!"
- @click="rebase"
- >
- {{ __('Rebase') }}
- </gl-button>
- <gl-button
- v-if="glFeatures.restructuredMrWidget && showRebaseWithoutCi"
- :loading="isMakingRequest"
- variant="confirm"
- size="small"
- category="secondary"
- data-testid="rebase-without-ci-button"
- @click="rebaseWithoutCi"
- >
- {{ __('Rebase without pipeline') }}
- </gl-button>
- </div>
</div>
</template>
- </div>
+ <template v-if="!isLoading" #actions>
+ <gl-button
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ category="secondary"
+ data-testid="rebase-without-ci-button"
+ class="gl-align-self-start gl-mr-2"
+ @click="rebaseWithoutCi"
+ >
+ {{ __('Rebase without pipeline') }}
+ </gl-button>
+ <gl-button
+ :loading="isMakingRequest"
+ variant="confirm"
+ size="small"
+ data-qa-selector="mr_rebase_button"
+ data-testid="standard-rebase-button"
+ class="gl-mb-2 gl-md-mb-0 gl-align-self-start"
+ @click="rebase"
+ >
+ {{ __('Rebase') }}
+ </gl-button>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
index d204befef58..d507e5f232b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue
@@ -1,7 +1,6 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { s__ } from '~/locale';
import statusIcon from '../mr_widget_status_icon.vue';
@@ -12,7 +11,6 @@ export default {
GlSprintf,
statusIcon,
},
- mixins: [glFeatureFlagMixin()],
computed: {
troubleshootingDocsPath() {
return helpPagePath('ci/troubleshooting', { anchor: 'merge-request-status-messages' });
@@ -30,7 +28,7 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
- <span :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" class="bold">
+ <span class="gl-ml-0! gl-text-body! bold">
<gl-sprintf :message="$options.i18n.failedMessage">
<template #link="{ content }">
<gl-link :href="troubleshootingDocsPath" target="_blank">
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 cf482410bef..d2c85b14999 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
@@ -31,12 +31,10 @@ import {
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import MergeRequestStore from '../../stores/mr_widget_store';
-import statusIcon from '../mr_widget_status_icon.vue';
import AddedCommitMessage from '../added_commit_message.vue';
import RelatedLinks from '../mr_widget_related_links.vue';
import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
-import CommitsHeader from './commits_header.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue';
@@ -96,9 +94,7 @@ export default {
},
},
components: {
- statusIcon,
SquashBeforeMerge,
- CommitsHeader,
CommitEdit,
CommitMessageDropdown,
GlIcon,
@@ -320,34 +316,27 @@ export default {
showDangerMessageForMergeTrain() {
return this.preferredAutoMergeStrategy === MT_MERGE_STRATEGY && this.isPipelineFailed;
},
- restructuredWidgetShowMergeButtons() {
- if (this.glFeatures.restructuredMrWidget) {
- return (
- (this.isMergeAllowed || this.isAutoMergeAvailable) &&
- this.state.userPermissions.canMerge &&
- !this.mr.mergeOngoing &&
- !this.mr.autoMergeEnabled
- );
- }
-
- return true;
+ shouldShowMergeControls() {
+ return (
+ (this.isMergeAllowed || this.isAutoMergeAvailable) &&
+ (this.stateData.userPermissions?.canMerge || this.mr.canMerge) &&
+ !this.mr.mergeOngoing &&
+ !this.mr.autoMergeEnabled
+ );
},
sourceBranchDeletedText() {
- if (this.glFeatures.restructuredMrWidget) {
- if (this.removeSourceBranch) {
- return this.mr.state === 'merged'
- ? __('Deleted the source branch.')
- : __('Source branch will be deleted.');
- }
-
+ if (this.removeSourceBranch) {
return this.mr.state === 'merged'
- ? __('Did not delete the source branch.')
- : __('Source branch will not be deleted.');
+ ? __('Deleted the source branch.')
+ : __('Source branch will be deleted.');
}
- return this.removeSourceBranch
- ? __('Deletes the source branch.')
- : __('Does not delete the source branch.');
+ return this.mr.state === 'merged'
+ ? __('Did not delete the source branch.')
+ : __('Source branch will not be deleted.');
+ },
+ showMergeDetailsHeader() {
+ return ['readyToMerge'].indexOf(this.mr.state) >= 0;
},
},
mounted() {
@@ -525,10 +514,7 @@ export default {
<template>
<div
data-testid="ready_to_merge_state"
- :class="{
- 'gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base':
- glFeatures.restructuredMrWidget,
- }"
+ class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
>
<div v-if="loading" class="mr-widget-body">
<div class="gl-w-full mr-ready-to-merge-loader">
@@ -541,16 +527,10 @@ export default {
</div>
</div>
<template v-else>
- <div
- class="mr-widget-body media"
- :class="{
- 'mr-widget-body-line-height-1': glFeatures.restructuredMrWidget,
- }"
- >
- <status-icon v-if="!glFeatures.restructuredMrWidget" :status="iconClass" />
+ <div class="mr-widget-body mr-widget-body-ready-merge media mr-widget-body-line-height-1">
<div class="media-body">
<div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap">
- <gl-button-group v-if="restructuredWidgetShowMergeButtons" class="gl-align-self-start">
+ <gl-button-group v-if="shouldShowMergeControls" class="gl-align-self-start">
<gl-button
size="medium"
category="primary"
@@ -603,19 +583,14 @@ export default {
<merge-train-helper-icon v-if="shouldRenderMergeTrainHelperIcon" class="gl-mx-3" />
<div
v-if="shouldShowMergeControls"
- :class="{ 'gl-w-full gl-order-n1 gl-mb-5': glFeatures.restructuredMrWidget }"
- class="gl-display-flex gl-align-items-center gl-flex-wrap"
+ class="gl-display-flex gl-align-items-center gl-flex-wrap gl-w-full gl-order-n1 gl-mb-5"
>
<gl-form-checkbox
v-if="canRemoveSourceBranch"
id="remove-source-branch-input"
v-model="removeSourceBranch"
:disabled="isRemoveSourceBranchButtonDisabled"
- :class="{
- 'gl-mx-3': !glFeatures.restructuredMrWidget,
- 'gl-mr-5': glFeatures.restructuredMrWidget,
- }"
- class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center"
+ class="js-remove-source-branch-checkbox gl-display-flex gl-align-items-center gl-mr-5"
>
{{ __('Delete source branch') }}
</gl-form-checkbox>
@@ -626,16 +601,11 @@ export default {
v-model="squashBeforeMerge"
:help-path="mr.squashBeforeMergeHelpPath"
:is-disabled="isSquashReadOnly"
- :class="{
- 'gl-mx-3': !glFeatures.restructuredMrWidget,
- 'gl-mr-5': glFeatures.restructuredMrWidget,
- }"
+ class="gl-mr-5"
/>
<gl-form-checkbox
- v-if="
- glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)
- "
+ v-if="shouldShowSquashEdit || shouldShowMergeEdit"
v-model="editCommitMessage"
data-testid="widget_edit_commit_message"
class="gl-display-flex gl-align-items-center"
@@ -644,198 +614,113 @@ export default {
</gl-form-checkbox>
</div>
<div
- v-else-if="!glFeatures.restructuredMrWidget"
- class="bold js-resolve-mr-widget-items-message gl-ml-3"
+ v-if="editCommitMessage"
+ class="gl-w-full gl-order-n1"
+ data-testid="edit_commit_message"
>
- <div
- v-if="hasPipelineMustSucceedConflict"
- class="gl-display-flex gl-align-items-center"
- data-testid="pipeline-succeed-conflict"
- >
- <gl-sprintf :message="pipelineMustSucceedConflictText" />
- <gl-link
- :href="mr.pipelineMustSucceedDocsPath"
- target="_blank"
- class="gl-display-flex gl-ml-2"
+ <ul class="border-top commits-list flex-list gl-list-style-none gl-p-0 gl-pt-4">
+ <commit-edit
+ v-if="shouldShowSquashEdit"
+ :value="squashCommitMessage"
+ :label="__('Squash commit message')"
+ input-id="squash-message-edit"
+ class="gl-m-0! gl-p-0!"
+ @input="setSquashCommitMessage"
>
- <gl-icon name="question" />
- </gl-link>
- </div>
- <gl-sprintf v-else :message="mergeDisabledText" />
+ <template #header>
+ <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
+ </template>
+ </commit-edit>
+ <commit-edit
+ v-if="shouldShowMergeEdit"
+ :value="commitMessage"
+ :label="__('Merge commit message')"
+ input-id="merge-message-edit"
+ class="gl-m-0! gl-p-0!"
+ @input="setCommitMessage"
+ />
+ <li class="gl-m-0! gl-p-0!">
+ <p class="form-text text-muted">
+ <gl-sprintf :message="commitTemplateHintText">
+ <template #link="{ content }">
+ <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </li>
+ </ul>
</div>
- <template v-if="glFeatures.restructuredMrWidget">
- <div
- v-if="editCommitMessage"
- class="gl-w-full gl-order-n1"
- data-testid="edit_commit_message"
- >
- <ul
- :class="{
- 'content-list': !glFeatures.restructuredMrWidget,
- 'gl-list-style-none gl-p-0 gl-pt-4': glFeatures.restructuredMrWidget,
- }"
- class="border-top commits-list flex-list"
- >
- <commit-edit
- v-if="shouldShowSquashEdit"
- :value="squashCommitMessage"
- :label="__('Squash commit message')"
- input-id="squash-message-edit"
- class="gl-m-0! gl-p-0!"
- @input="setSquashCommitMessage"
+ <div
+ v-if="!shouldShowMergeControls"
+ class="gl-w-full gl-order-n1 mr-widget-merge-details"
+ data-qa-selector="merged_status_content"
+ >
+ <p v-if="showMergeDetailsHeader" class="gl-mb-3 gl-text-gray-900">
+ {{ __('Merge details') }}
+ </p>
+ <ul class="gl-pl-4 gl-mb-0 gl-ml-3 gl-text-gray-600">
+ <li v-if="mr.divergedCommitsCount > 0" class="gl-line-height-normal">
+ <gl-sprintf
+ :message="s__('mrWidget|The source branch is %{link} the target branch')"
>
- <template #header>
- <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
+ <template #link>
+ <gl-link :href="mr.targetBranchPath">{{
+ n__('%d commit behind', '%d commits behind', mr.divergedCommitsCount)
+ }}</gl-link>
</template>
- </commit-edit>
- <commit-edit
- v-if="shouldShowMergeEdit"
- :value="commitMessage"
- :label="__('Merge commit message')"
- input-id="merge-message-edit"
- class="gl-m-0! gl-p-0!"
- @input="setCommitMessage"
+ </gl-sprintf>
+ </li>
+ <li class="gl-line-height-normal">
+ <added-commit-message
+ :state="mr.state"
+ :merge-commit-sha="mr.shortMergeCommitSha"
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="stateData.targetBranch"
/>
- <li class="gl-m-0! gl-p-0!">
- <p class="form-text text-muted">
- <gl-sprintf :message="commitTemplateHintText">
- <template #link="{ content }">
- <gl-link
- :href="commitTemplateHelpPage"
- class="inline-link"
- target="_blank"
- >
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </li>
- </ul>
- </div>
- <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" class="gl-line-height-normal">
- <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>
- </li>
- <li class="gl-line-height-normal">
- <added-commit-message
- :state="mr.state"
- :merge-commit-sha="mr.shortMergeCommitSha"
- :is-squash-enabled="squashBeforeMerge"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- />
- </li>
- <li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
- {{ sourceBranchDeletedText }}
- </li>
- <li v-if="mr.relatedLinks" class="gl-line-height-normal">
- <related-links
- :state="mr.state"
- :related-links="mr.relatedLinks"
- :show-assign-to-me="false"
- class="mr-ready-merge-related-links gl-display-inline"
- />
- </li>
- </ul>
- </div>
- <div
- v-else
- :class="{ 'gl-mb-5': restructuredWidgetShowMergeButtons }"
- class="gl-w-full gl-order-n1 gl-text-gray-500"
- >
- <added-commit-message
- :is-squash-enabled="squashBeforeMerge"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- />
- <template v-if="mr.relatedLinks">
- &middot;
+ </li>
+ <li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
+ {{ sourceBranchDeletedText }}
+ </li>
+ <li v-if="mr.relatedLinks" class="gl-line-height-normal">
<related-links
:state="mr.state"
:related-links="mr.relatedLinks"
:show-assign-to-me="false"
- :diverged-commits-count="mr.divergedCommitsCount"
- :target-branch-path="mr.targetBranchPath"
class="mr-ready-merge-related-links gl-display-inline"
/>
- </template>
- </div>
- </template>
- </div>
- <div
- v-if="showDangerMessageForMergeTrain && !glFeatures.restructuredMrWidget"
- class="gl-mt-5 gl-text-gray-500"
- data-testid="failed-pipeline-merge-train-text"
- >
- {{ __('The latest pipeline for this merge request did not complete successfully.') }}
+ </li>
+ </ul>
+ </div>
+ <div
+ v-else
+ :class="{ 'gl-mb-5': shouldShowMergeControls }"
+ class="gl-w-full gl-order-n1 gl-text-gray-500"
+ >
+ <added-commit-message
+ :is-squash-enabled="squashBeforeMerge"
+ :is-fast-forward-enabled="!shouldShowMergeEdit"
+ :commits-count="commitsCount"
+ :target-branch="stateData.targetBranch"
+ />
+ <template v-if="mr.relatedLinks">
+ &middot;
+ <related-links
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ :show-assign-to-me="false"
+ :diverged-commits-count="mr.divergedCommitsCount"
+ :target-branch-path="mr.targetBranchPath"
+ class="mr-ready-merge-related-links gl-display-inline"
+ />
+ </template>
+ </div>
</div>
</div>
</div>
- <template v-if="shouldShowMergeControls && !glFeatures.restructuredMrWidget">
- <div v-if="!shouldShowMergeEdit" class="mr-fast-forward-message">
- {{ __('Fast-forward merge without a merge commit') }}
- </div>
- <commits-header
- v-if="!glFeatures.restructuredMrWidget && (shouldShowSquashEdit || shouldShowMergeEdit)"
- :is-squash-enabled="squashBeforeMerge"
- :commits-count="commitsCount"
- :target-branch="stateData.targetBranch"
- :is-fast-forward-enabled="!shouldShowMergeEdit"
- :class="{ 'border-bottom': stateData.mergeError }"
- >
- <ul class="border-top content-list commits-list flex-list">
- <commit-edit
- v-if="shouldShowSquashEdit"
- :value="squashCommitMessage"
- :label="__('Squash commit message')"
- input-id="squash-message-edit"
- squash
- @input="setSquashCommitMessage"
- >
- <template #header>
- <commit-message-dropdown :commits="commits" @input="setSquashCommitMessage" />
- </template>
- </commit-edit>
- <commit-edit
- v-if="shouldShowMergeEdit"
- :value="commitMessage"
- :label="__('Merge commit message')"
- input-id="merge-message-edit"
- @input="setCommitMessage"
- />
- <li>
- <p class="form-text text-muted">
- <gl-sprintf :message="commitTemplateHintText">
- <template #link="{ content }">
- <gl-link :href="commitTemplateHelpPage" class="inline-link" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </p>
- </li>
- </ul>
- </commits-header>
- </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
index b1fbe150fcf..d149f5208fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue
@@ -1,19 +1,17 @@
<script>
import { GlButton } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { I18N_SHA_MISMATCH } from '../../i18n';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'ShaMismatch',
components: {
- statusIcon,
GlButton,
+ StateContainer,
},
i18n: {
I18N_SHA_MISMATCH,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -24,25 +22,24 @@ export default {
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="false" status="warning" />
- <div class="media-body">
- <span
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
- data-qa-selector="head_mismatch_content"
- >
- {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }}
- </span>
+ <state-container status="warning">
+ <span
+ class="gl-font-weight-bold gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!"
+ data-qa-selector="head_mismatch_content"
+ >
+ {{ $options.i18n.I18N_SHA_MISMATCH.warningMessage }}
+ </span>
+ <template #actions>
<gl-button
- class="gl-ml-3"
data-testid="action-button"
size="small"
category="primary"
variant="confirm"
+ class="gl-align-self-start"
:href="mr.mergeRequestDiffsPath"
- >{{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }}</gl-button
>
- </div>
- </div>
+ {{ $options.i18n.I18N_SHA_MISMATCH.actionButtonLabel }}
+ </gl-button>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index c6227c4394d..1413a46b4b9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,6 +1,5 @@
<script>
import { GlIcon, GlTooltipDirective, GlFormCheckbox, GlLink } from '@gitlab/ui';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { SQUASH_BEFORE_MERGE } from '../../i18n';
export default {
@@ -12,7 +11,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagsMixin()],
i18n: {
...SQUASH_BEFORE_MERGE,
},
@@ -36,9 +34,6 @@ export default {
tooltipTitle() {
return this.isDisabled ? this.$options.i18n.tooltipTitle : null;
},
- helpIconName() {
- return this.glFeatures.restructuredMrWidget ? 'question-o' : 'question';
- },
},
};
</script>
@@ -62,10 +57,10 @@ export default {
v-gl-tooltip
:href="helpPath"
:title="$options.i18n.helpLabel"
- :class="{ 'gl-text-blue-600': glFeatures.restructuredMrWidget }"
+ class="gl-text-blue-600"
target="_blank"
>
- <gl-icon :name="helpIconName" />
+ <gl-icon name="question-o" />
<span class="sr-only">
{{ $options.i18n.helpLabel }}
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index 25ba4bf12af..035d62eaa59 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -1,16 +1,14 @@
<script>
import { GlButton } from '@gitlab/ui';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import notesEventHub from '~/notes/event_hub';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'UnresolvedDiscussions',
components: {
- statusIcon,
GlButton,
+ StateContainer,
},
- mixins: [glFeatureFlagMixin()],
props: {
mr: {
type: Object,
@@ -26,38 +24,33 @@ export default {
</script>
<template>
- <div class="mr-widget-body media gl-flex-wrap">
- <status-icon show-disabled-button status="warning" />
- <div class="media-body">
- <span
- :class="{
- 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget,
- 'gl-display-block': !glFeatures.restructuredMrWidget,
- }"
- class="gl-ml-3 gl-font-weight-bold gl-w-100"
- >
- {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
- </span>
+ <state-container status="warning">
+ <span
+ class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"
+ >
+ {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }}
+ </span>
+ <template #actions>
<gl-button
- data-testid="jump-to-first"
- class="gl-ml-3"
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="js-create-issue gl-align-self-start gl-vertical-align-top gl-mr-2"
size="small"
- :icon="glFeatures.restructuredMrWidget ? undefined : 'comment-next'"
- :variant="glFeatures.restructuredMrWidget ? 'confirm' : 'default'"
- :category="glFeatures.restructuredMrWidget ? 'secondary' : 'primary'"
- @click="jumpToFirstUnresolvedDiscussion"
+ variant="confirm"
+ category="secondary"
>
- {{ s__('mrWidget|Jump to first unresolved thread') }}
+ {{ s__('mrWidget|Create issue to resolve all threads') }}
</gl-button>
<gl-button
- v-if="mr.createIssueToResolveDiscussionsPath"
- :href="mr.createIssueToResolveDiscussionsPath"
- class="js-create-issue gl-ml-3"
+ data-testid="jump-to-first"
+ class="gl-mb-2 gl-md-mb-0 gl-align-self-start gl-vertical-align-top"
size="small"
- :icon="glFeatures.restructuredMrWidget ? undefined : 'issue-new'"
+ variant="confirm"
+ category="primary"
+ @click="jumpToFirstUnresolvedDiscussion"
>
- {{ s__('mrWidget|Create issue to resolve all threads') }}
+ {{ s__('mrWidget|Jump to first unresolved thread') }}
</gl-button>
- </div>
- </div>
+ </template>
+ </state-container>
</template>
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 5bd7745d704..cf7f83c014a 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
@@ -12,13 +12,13 @@ import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_va
import getStateQuery from '../../queries/get_state.query.graphql';
import draftQuery from '../../queries/states/draft.query.graphql';
import removeDraftMutation from '../../queries/toggle_draft.mutation.graphql';
-import StatusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'WorkInProgress',
components: {
- StatusIcon,
GlButton,
+ StateContainer,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -163,29 +163,22 @@ export default {
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :show-disabled-button="canUpdate" status="warning" />
- <div class="media-body">
- <div class="float-left">
- <span
- :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
- class="gl-font-weight-bold"
- >
- {{
- __("Merge blocked: merge request must be marked as ready. It's still marked as draft.")
- }}
- </span>
- </div>
+ <state-container status="warning">
+ <span class="gl-font-weight-bold gl-ml-0! gl-text-body! gl-flex-grow-1">
+ {{ __("Merge blocked: merge request must be marked as ready. It's still marked as draft.") }}
+ </span>
+ <template #actions>
<gl-button
v-if="canUpdate"
size="small"
:disabled="isMakingRequest"
:loading="isMakingRequest"
- class="js-remove-draft gl-ml-3"
+ variant="confirm"
+ class="js-remove-draft gl-md-ml-3 gl-align-self-start"
@click="handleRemoveDraft"
>
{{ s__('mrWidget|Mark as ready') }}
</gl-button>
- </div>
- </div>
+ </template>
+ </state-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
new file mode 100644
index 00000000000..f1c1bde256f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -0,0 +1,27 @@
+<script>
+export default {
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ widgets() {
+ return [].filter((w) => w);
+ },
+ },
+};
+</script>
+
+<template>
+ <section role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app">
+ <component
+ :is="widget"
+ v-for="(widget, index) in widgets"
+ :key="widget.name || index"
+ :mr="mr"
+ :class="{ 'mr-widget-border-top': index === 0 }"
+ />
+ </section>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
new file mode 100644
index 00000000000..9c8819327e6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -0,0 +1,158 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import Poll from '~/lib/utils/poll';
+import StatusIcon from '../extensions/status_icon.vue';
+import { EXTENSION_ICON_NAMES } from '../../constants';
+
+const FETCH_TYPE_COLLAPSED = 'collapsed';
+
+export default {
+ components: {
+ StatusIcon,
+ },
+ props: {
+ /**
+ * @param {value.collapsed} Object
+ * @param {value.extended} Object
+ */
+ value: {
+ type: Object,
+ required: true,
+ },
+ loadingText: {
+ type: String,
+ required: false,
+ default: __('Loading'),
+ },
+ errorText: {
+ type: String,
+ required: false,
+ default: __('Failed to load'),
+ },
+ fetchCollapsedData: {
+ type: Function,
+ required: true,
+ },
+ fetchExtendedData: {
+ type: Function,
+ required: false,
+ default: undefined,
+ },
+ // If the summary slot is not used, this value will be used as a fallback.
+ summary: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ // If the content slot is not used, this value will be used as a fallback.
+ content: {
+ type: Object,
+ required: false,
+ default: undefined,
+ },
+ multiPolling: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ statusIconName: {
+ type: String,
+ default: 'neutral',
+ required: false,
+ validator: (value) => Object.keys(EXTENSION_ICON_NAMES).indexOf(value) > -1,
+ },
+ widgetName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ error: null,
+ };
+ },
+ watch: {
+ isLoading(newValue) {
+ this.$emit('is-loading', newValue);
+ },
+ },
+ async mounted() {
+ this.isLoading = true;
+
+ try {
+ await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
+ } catch {
+ this.error = this.errorText;
+ }
+
+ this.isLoading = false;
+ },
+ methods: {
+ fetch(handler, dataType) {
+ const requests = this.multiPolling ? handler() : [handler];
+
+ const promises = requests.map((request) => {
+ return new Promise((resolve, reject) => {
+ const poll = new Poll({
+ resource: {
+ fetchData: () => request(),
+ },
+ method: 'fetchData',
+ successCallback: (response) => {
+ const headers = normalizeHeaders(response.headers);
+
+ if (headers['POLL-INTERVAL']) {
+ return;
+ }
+
+ resolve(response.data);
+ },
+ errorCallback: (e) => {
+ Sentry.captureException(e);
+ reject(e);
+ },
+ });
+
+ poll.makeRequest();
+ });
+ });
+
+ return Promise.all(promises).then((data) => {
+ this.$emit('input', { ...this.value, [dataType]: this.multiPolling ? data : data[0] });
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="media-section" data-testid="widget-extension">
+ <div class="media gl-p-5">
+ <status-icon
+ :level="1"
+ :name="widgetName"
+ :is-loading="isLoading"
+ :icon-name="statusIconName"
+ />
+ <div
+ class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
+ data-testid="widget-extension-top-level"
+ >
+ <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary">
+ <slot name="summary">{{ isLoading ? loadingText : summary }}</slot>
+ </div>
+ <!-- actions will go here -->
+ <!-- toggle button will go here -->
+ </div>
+ </div>
+ <div
+ class="mr-widget-grouped-section gl-relative"
+ data-testid="widget-extension-collapsed-section"
+ >
+ <slot name="content">{{ content }}</slot>
+ </div>
+ </section>
+</template>
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 22e907f7e48..0fb5e13ad82 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,7 +6,6 @@ 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'),
@@ -76,9 +75,9 @@ export default {
return sprintf(s__('AccessibilityReport|Message: %{message}'), { message });
},
prepareReports() {
- const { new_errors, existing_errors, resolved_errors } = this.collapsedData;
+ const { collapsedData } = this;
- const newErrors = new_errors.map((error) => {
+ const newErrors = collapsedData.new_errors.map((error) => {
return {
header: __('New'),
id: uniqueId('new-error-'),
@@ -92,7 +91,7 @@ export default {
};
});
- const existingErrors = existing_errors.map((error) => {
+ const existingErrors = collapsedData.existing_errors.map((error) => {
return {
id: uniqueId('existing-error-'),
text: this.formatText(error.code),
@@ -105,7 +104,7 @@ export default {
};
});
- const resolvedErrors = resolved_errors.map((error) => {
+ const resolvedErrors = collapsedData.resolved_errors.map((error) => {
return {
id: uniqueId('resolved-error-'),
text: this.formatText(error.code),
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 4ffd06de61f..6896f8831e8 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
@@ -51,14 +51,14 @@ export const recentFailuresTextBuilder = (summary = {}) => {
return i18n.recentFailureSummary(recentlyFailed, failed);
};
-export const reportSubTextBuilder = ({ suite_errors, summary }) => {
- if (suite_errors?.head || suite_errors?.base) {
+export const reportSubTextBuilder = ({ suite_errors: suiteErrors, summary }) => {
+ if (suiteErrors?.head || suiteErrors?.base) {
const errors = [];
- if (suite_errors?.head) {
- errors.push(`${i18n.headReportParsingError} ${suite_errors.head}`);
+ if (suiteErrors?.head) {
+ errors.push(`${i18n.headReportParsingError} ${suiteErrors.head}`);
}
- if (suite_errors?.base) {
- errors.push(`${i18n.baseReportParsingError} ${suite_errors.base}`);
+ if (suiteErrors?.base) {
+ errors.push(`${i18n.baseReportParsingError} ${suiteErrors.base}`);
}
return errors.join('<br />');
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 627ddb0445e..d964b4bacac 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -20,13 +20,6 @@ export default {
this.mr.preventMerge,
);
},
- shouldShowMergeControls() {
- if (this.glFeatures.restructuredMrWidget) {
- return this.restructuredWidgetShowMergeButtons;
- }
-
- return this.isMergeAllowed || this.isAutoMergeAvailable;
- },
mergeDisabledText() {
if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) {
return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT;
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 3e0ac236fdf..1e25143e15c 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,7 +1,6 @@
<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';
@@ -17,7 +16,6 @@ import { setFaviconOverlay } from '../lib/utils/favicon';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
-import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
import ArchivedState from './components/states/mr_widget_archived.vue';
@@ -40,6 +38,7 @@ import ShaMismatch from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import WorkInProgressState from './components/states/work_in_progress.vue';
import ExtensionsContainer from './components/extensions/container';
+import WidgetContainer from './components/widget/app.vue';
import { STATE_MACHINE, stateToComponentMap } from './constants';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
@@ -59,9 +58,9 @@ export default {
components: {
Loading,
ExtensionsContainer,
+ WidgetContainer,
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
MrWidgetPipelineContainer,
- 'mr-widget-related-links': WidgetRelatedLinks,
MrWidgetAlertMessage,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
@@ -73,9 +72,7 @@ export default {
'mr-widget-nothing-to-merge': NothingToMergeState,
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
- 'mr-widget-ready-to-merge': window.gon?.features?.restructuredMrWidget
- ? () => import('./components/states/new_ready_to_merge.vue')
- : ReadyToMergeState,
+ 'mr-widget-ready-to-merge': () => import('./components/states/new_ready_to_merge.vue'),
'sha-mismatch': ShaMismatch,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
@@ -163,12 +160,6 @@ export default {
shouldRenderCodeQuality() {
return this.mr?.codequalityReportsPath;
},
- shouldRenderRelatedLinks() {
- return (
- (Boolean(this.mr.relatedLinks) || this.mr.divergedCommitsCount > 0) &&
- !this.mr.isNothingToMergeState
- );
- },
shouldRenderSourceBranchRemovalStatus() {
return (
!this.mr.canRemoveSourceBranch &&
@@ -239,9 +230,6 @@ export default {
shouldShowCodeQualityExtension() {
return window.gon?.features?.refactorCodeQualityExtension;
},
- isRestructuredMrWidgetEnabled() {
- return window.gon?.features?.restructuredMrWidget;
- },
},
watch: {
'mr.machineValue': {
@@ -275,11 +263,6 @@ export default {
this.registerTestReportExtension();
}
},
- shouldRenderSecurityReport(newVal) {
- if (newVal) {
- this.registerSecurityReportExtension();
- }
- },
},
mounted() {
MRWidgetService.fetchInitialData()
@@ -535,11 +518,6 @@ export default {
registerExtension(testReportExtension);
}
},
- registerSecurityReportExtension() {
- if (this.shouldRenderSecurityReport && this.shouldShowSecurityExtension) {
- registerExtension(securityReportExtension);
- }
- },
},
};
</script>
@@ -600,7 +578,11 @@ export default {
</template>
</mr-widget-alert-message>
</div>
+
<extensions-container :mr="mr" />
+
+ <widget-container v-if="mr" :mr="mr" />
+
<grouped-codequality-reports-app
v-if="shouldRenderCodeQuality && !shouldShowCodeQualityExtension"
:head-blob-path="mr.headBlobPath"
@@ -638,23 +620,7 @@ export default {
<div class="mr-widget-section" data-qa-selector="mr_widget_content">
<component :is="componentName" :mr="mr" :service="service" />
- <ready-to-merge
- v-if="isRestructuredMrWidgetEnabled && mr.commitsCount"
- :mr="mr"
- :service="service"
- />
- <div v-else class="mr-widget-info">
- <mr-widget-related-links
- v-if="shouldRenderRelatedLinks"
- :state="mr.state"
- :related-links="mr.relatedLinks"
- :diverged-commits-count="mr.divergedCommitsCount"
- :target-branch-path="mr.targetBranchPath"
- class="mr-info-list gl-ml-7 gl-pb-5"
- />
-
- <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
- </div>
+ <ready-to-merge v-if="mr.commitsCount" :mr="mr" :service="service" />
</div>
</div>
<mr-widget-pipeline-container
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
index efc0673bc26..54770e6579a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql
@@ -1,11 +1,9 @@
fragment ReadyToMerge on Project {
- __typename
id
onlyAllowMergeIfPipelineSucceeds
mergeRequestsFfOnlyEnabled
squashReadOnly
mergeRequest(iid: $iid) {
- __typename
id
autoMergeEnabled
shouldRemoveSourceBranch
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 18d955652ba..7a458f9ce7e 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
@@ -27,8 +27,6 @@ export default function deviseState() {
return stateKey.shaMismatch;
} else if (this.autoMergeEnabled && !this.mergeError) {
return stateKey.autoMergeEnabled;
- } else if (!this.canMerge && !window.gon?.features?.restructuredMrWidget) {
- return stateKey.notAllowedToMerge;
} else if (this.canBeMerged) {
return stateKey.readyToMerge;
}
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 03c9a01cc7a..146cf7e11a7 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,7 +33,6 @@ export default class MergeRequestStore {
this.setData(data);
this.initCodeQualityReport(data);
- this.initSecurityReport(data);
this.setGitpodData(data);
}
@@ -42,19 +41,6 @@ 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/components/confirm_danger/confirm_danger.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
index e12e06a2454..5b9efff1c06 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger.vue
@@ -53,6 +53,7 @@ export default {
:variant="buttonVariant"
:disabled="disabled"
:data-testid="buttonTestid"
+ data-qa-selector="confirm_danger_button"
>{{ buttonText }}</gl-button
>
<confirm-danger-modal
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
index 37e480f7e41..7a982bc035a 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue
@@ -66,7 +66,13 @@ export default {
actionPrimary() {
return {
text: this.confirmButtonText,
- attributes: [{ variant: 'danger', disabled: !this.isValid, class: 'qa-confirm-button' }],
+ attributes: [
+ {
+ variant: 'danger',
+ disabled: !this.isValid,
+ 'data-qa-selector': 'confirm_danger_modal_button',
+ },
+ ],
};
},
actionCancel() {
@@ -122,7 +128,8 @@ export default {
<gl-form-input
id="confirm_name_input"
v-model="confirmationPhrase"
- class="form-control qa-confirm-input"
+ class="form-control"
+ data-qa-selector="confirm_danger_field"
data-testid="confirm-danger-input"
type="text"
/>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
index f4317ba90a2..7c4e372dda1 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/store/modules/filters/actions.js
@@ -30,12 +30,12 @@ export function fetchBranches({ commit, state }, search = '') {
});
}
-export const fetchMilestones = ({ commit, state }, search_title = '') => {
+export const fetchMilestones = ({ commit, state }, searchTitle = '') => {
commit(types.REQUEST_MILESTONES);
const { milestonesEndpoint } = state;
return axios
- .get(milestonesEndpoint, { params: { search_title } })
+ .get(milestonesEndpoint, { params: { search_title: searchTitle } })
.then((response) => {
commit(types.RECEIVE_MILESTONES_SUCCESS, response.data);
return response;
diff --git a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
index acddf16bd27..72148a0aa7c 100644
--- a/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
+++ b/app/assets/javascripts/vue_shared/components/gitlab_version_check.vue
@@ -2,6 +2,7 @@
import { GlBadge } from '@gitlab/ui';
import { s__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
const STATUS_TYPES = {
SUCCESS: 'success',
@@ -45,7 +46,7 @@ export default {
methods: {
checkGitlabVersion() {
axios
- .get('/admin/version_check.json')
+ .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json'))
.then((res) => {
if (res.data) {
this.status = res.data.severity;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 4fdf7f45643..1d1b65aa1af 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -156,6 +156,14 @@ export default {
})
.catch(() => {});
},
+ handleAttachFile(e) {
+ e.preventDefault();
+ const $gfmForm = $(this.$el).closest('.gfm-form');
+ const $gfmTextarea = $gfmForm.find('.js-gfm-input');
+
+ $gfmForm.find('.div-dropzone').click();
+ $gfmTextarea.focus();
+ },
},
shortcuts: {
bold: keysFor(BOLD_TEXT),
@@ -195,6 +203,44 @@ export default {
:class="{ 'gl-display-none!': previewMarkdown }"
class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"
>
+ <template v-if="canSuggest">
+ <toolbar-button
+ ref="suggestButton"
+ :tag="mdSuggestion"
+ :prepend="true"
+ :button-title="__('Insert suggestion')"
+ :cursor-offset="4"
+ :tag-content="lineContent"
+ icon="doc-code"
+ data-qa-selector="suggestion_button"
+ class="js-suggestion-btn"
+ @click="handleSuggestDismissed"
+ />
+ <gl-popover
+ v-if="suggestPopoverVisible"
+ :target="$refs.suggestButton.$el"
+ :css-classes="['diff-suggest-popover']"
+ placement="bottom"
+ :show="suggestPopoverVisible"
+ >
+ <strong>{{ __('New! Suggest changes directly') }}</strong>
+ <p class="mb-2">
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
+ </p>
+ <gl-button
+ variant="confirm"
+ category="primary"
+ size="small"
+ @click="handleSuggestDismissed"
+ >
+ {{ __('Got it') }}
+ </gl-button>
+ </gl-popover>
+ </template>
<toolbar-button
tag="**"
:button-title="
@@ -237,44 +283,6 @@ export default {
icon="quote"
@click="handleQuote"
/>
- <template v-if="canSuggest">
- <toolbar-button
- ref="suggestButton"
- :tag="mdSuggestion"
- :prepend="true"
- :button-title="__('Insert suggestion')"
- :cursor-offset="4"
- :tag-content="lineContent"
- icon="doc-code"
- data-qa-selector="suggestion_button"
- class="js-suggestion-btn"
- @click="handleSuggestDismissed"
- />
- <gl-popover
- v-if="suggestPopoverVisible"
- :target="$refs.suggestButton.$el"
- :css-classes="['diff-suggest-popover']"
- placement="bottom"
- :show="suggestPopoverVisible"
- >
- <strong>{{ __('New! Suggest changes directly') }}</strong>
- <p class="mb-2">
- {{
- __(
- 'Suggest code changes which can be immediately applied in one click. Try it out!',
- )
- }}
- </p>
- <gl-button
- variant="confirm"
- category="primary"
- size="small"
- @click="handleSuggestDismissed"
- >
- {{ __('Got it') }}
- </gl-button>
- </gl-popover>
- </template>
<toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
<toolbar-button
tag="[{text}](url)"
@@ -306,7 +314,7 @@ export default {
v-if="!restrictedToolBarItems.includes('task-list')"
:prepend="true"
tag="- [ ] "
- :button-title="__('Add a task list')"
+ :button-title="__('Add a checklist')"
icon="list-task"
/>
<toolbar-button
@@ -324,6 +332,15 @@ export default {
:button-title="__('Add a table')"
icon="table"
/>
+ <gl-button
+ v-if="!restrictedToolBarItems.includes('attach-file')"
+ v-gl-tooltip
+ :title="__('Attach a file or image')"
+ data-testid="button-attach-file"
+ category="tertiary"
+ icon="paperclip"
+ @click="handleAttachFile"
+ />
<toolbar-button
v-if="!restrictedToolBarItems.includes('full-screen')"
class="js-zen-enter"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 6c99a749edc..aa325862f06 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -74,7 +74,7 @@ export default {
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
- <gl-icon name="media" />
+ <gl-icon name="paperclip" />
<span class="attaching-file-message"></span>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<span class="uploading-progress">0%</span>
@@ -82,7 +82,7 @@ export default {
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
- <gl-icon name="media" />
+ <gl-icon name="paperclip" />
</span>
<span class="uploading-error-message"></span>
@@ -114,14 +114,6 @@ export default {
</gl-sprintf>
</span>
<gl-button
- icon="media"
- variant="link"
- category="primary"
- class="markdown-selector button-attach-file gl-vertical-align-text-bottom"
- >
- {{ __('Attach a file') }}
- </gl-button>
- <gl-button
variant="link"
category="primary"
class="button-cancel-uploading-files gl-vertical-align-baseline hide"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index 6a83939795c..49217e38a1b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -88,6 +88,6 @@ export default {
category="tertiary"
class="js-md"
data-container="body"
- @click="() => $emit('click')"
+ @click="$emit('click', $event)"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
index 521b1a1075a..e9f278a5db5 100644
--- a/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
+++ b/app/assets/javascripts/vue_shared/components/namespace_select/namespace_select.vue
@@ -5,6 +5,8 @@ import {
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
} from '@gitlab/ui';
import { __ } from '~/locale';
@@ -32,6 +34,8 @@ export default {
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
+ GlIntersectionObserver,
+ GlLoadingIcon,
},
props: {
groupNamespaces: {
@@ -69,6 +73,26 @@ export default {
required: false,
default: false,
},
+ hasNextPageOfGroups: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isLoadingMoreGroups: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isSearchLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ shouldFilterNamespaces: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -84,10 +108,12 @@ export default {
return this.groupNamespaces.length;
},
filteredGroupNamespaces() {
+ if (!this.shouldFilterNamespaces) return this.groupNamespaces;
if (!this.hasGroupNamespaces) return [];
return filterByName(this.groupNamespaces, this.searchTerm);
},
filteredUserNamespaces() {
+ if (!this.shouldFilterNamespaces) return this.userNamespaces;
if (!this.hasUserNamespaces) return [];
return filterByName(this.userNamespaces, this.searchTerm);
},
@@ -107,9 +133,15 @@ export default {
return emptyNamespaceTitle.toLowerCase().includes(searchTerm.toLowerCase());
},
},
+ watch: {
+ searchTerm() {
+ this.$emit('search', this.searchTerm);
+ },
+ },
methods: {
handleSelect(item) {
this.selectedNamespace = item;
+ this.searchTerm = '';
this.$emit('select', item);
},
handleSelectEmptyNamespace() {
@@ -122,7 +154,11 @@ export default {
<template>
<gl-dropdown :text="selectedNamespaceText" :block="fullWidth" data-qa-selector="namespaces_list">
<template #header>
- <gl-search-box-by-type v-model.trim="searchTerm" />
+ <gl-search-box-by-type
+ v-model.trim="searchTerm"
+ :is-loading="isSearchLoading"
+ data-qa-selector="namespaces_list_search"
+ />
</template>
<div v-if="filteredEmptyNamespaceTitle">
<gl-dropdown-item
@@ -133,29 +169,40 @@ export default {
</gl-dropdown-item>
<gl-dropdown-divider />
</div>
- <div v-if="hasGroupNamespaces" data-qa-selector="namespaces_list_groups">
+ <div
+ v-if="hasUserNamespaces"
+ data-qa-selector="namespaces_list_users"
+ data-testid="namespace-list-users"
+ >
<gl-dropdown-section-header v-if="includeHeaders">{{
- $options.i18n.GROUPS
+ $options.i18n.USERS
}}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="item in filteredGroupNamespaces"
+ v-for="item in filteredUserNamespaces"
:key="item.id"
data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
- <div v-if="hasUserNamespaces" data-qa-selector="namespaces_list_users">
+ <div
+ v-if="hasGroupNamespaces"
+ data-qa-selector="namespaces_list_groups"
+ data-testid="namespace-list-groups"
+ >
<gl-dropdown-section-header v-if="includeHeaders">{{
- $options.i18n.USERS
+ $options.i18n.GROUPS
}}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="item in filteredUserNamespaces"
+ v-for="item in filteredGroupNamespaces"
:key="item.id"
data-qa-selector="namespaces_list_item"
@click="handleSelect(item)"
>{{ item.humanName }}</gl-dropdown-item
>
</div>
+ <gl-intersection-observer v-if="hasNextPageOfGroups" @appear="$emit('load-more-groups')">
+ <gl-loading-icon v-if="isLoadingMoreGroups" class="gl-mb-3" size="sm" />
+ </gl-intersection-observer>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.vue b/app/assets/javascripts/vue_shared/components/project_avatar.vue
index 402e75962d2..f65cc8bf2f3 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar } from '@gitlab/ui';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
export default {
@@ -7,6 +8,14 @@ export default {
GlAvatar,
},
props: {
+ projectId: {
+ type: [Number, String],
+ default: 0,
+ required: false,
+ validator(value) {
+ return typeof value === 'string' ? isGid(value) : true;
+ },
+ },
projectName: {
type: String,
required: true,
@@ -31,6 +40,9 @@ export default {
avatarAlt() {
return this.alt ?? this.projectName;
},
+ entityId() {
+ return isGid(this.projectId) ? getIdFromGraphQLId(this.projectId) : this.projectId;
+ },
},
AVATAR_SHAPE_OPTION_RECT,
};
@@ -39,6 +51,7 @@ export default {
<template>
<gl-avatar
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ :entity-id="entityId"
:entity-name="projectName"
:src="projectAvatarUrl"
:alt="avatarAlt"
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 19ffbe37ce7..66643ff4026 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -53,6 +53,7 @@ export default {
>
<gl-icon v-if="selected" class="js-selected-icon" name="mobile-issue-close" />
<project-avatar
+ :project-id="project.id"
:project-avatar-url="projectAvatarUrl"
:project-name="projectNameWithNamespace"
class="gl-mr-3"
diff --git a/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue b/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue
new file mode 100644
index 00000000000..424a11bf88b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_timestamp_tooltip.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlTooltip } from '@gitlab/ui';
+
+import { formatDate } from '~/lib/utils/datetime_utility';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlTooltip,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ target: {
+ type: [Object, HTMLElement, SVGElement, String, Function],
+ required: true,
+ },
+ rawTimestamp: {
+ type: String,
+ required: true,
+ },
+ timestampTypeText: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ timestampInWords() {
+ return this.rawTimestamp ? this.timeFormatted(this.rawTimestamp) : '';
+ },
+ timestamp() {
+ return this.rawTimestamp ? formatDate(new Date(this.rawTimestamp)) : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-tooltip :target="target">
+ <div class="bold" data-testid="header-text">{{ timestampTypeText }} {{ timestampInWords }}</div>
+ <div class="text-tertiary" data-testid="body-text">{{ timestamp }}</div>
+ </gl-tooltip>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
index be270e440ed..4af07366a6d 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
@@ -3,11 +3,13 @@
query issueAssignees($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
+ author {
+ ...User
+ ...UserAvailability
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 96a40e597ee..445817d3e52 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -3,10 +3,8 @@
query issueParticipants($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
- __typename
id
issuable: issue(iid: $iid) {
- __typename
id
participants {
nodes {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
index dffcc053fac..b127b8ec5a9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
@@ -2,7 +2,6 @@
query issueTimeTrackingReport($id: IssueID!) {
issuable: issue(id: $id) {
- __typename
id
title
timelogs {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql
new file mode 100644
index 00000000000..05de680ab05
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql
@@ -0,0 +1,26 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query mergeRequestReviewers($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ id
+ issuable: mergeRequest(iid: $iid) {
+ id
+ reviewers {
+ nodes {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ canUpdate
+ approved
+ reviewed
+ }
+ }
+ }
+ userPermissions {
+ updateMergeRequest
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
index 7127940bb05..f70cd723f2e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -6,6 +6,13 @@ query getMrAssignees($fullPath: ID!, $iid: String!) {
id
issuable: mergeRequest(iid: $iid) {
id
+ author {
+ ...User
+ ...UserAvailability
+ mergeRequestInteraction {
+ canMerge
+ }
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
index ede9b75d765..17f548b44b5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
@@ -2,7 +2,6 @@
query mrTimeTrackingReport($id: MergeRequestID!) {
issuable: mergeRequest(id: $id) {
- __typename
id
title
timelogs {
diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue
index 6a0bf07c8b4..1925c5d4064 100644
--- a/app/assets/javascripts/vue_shared/components/source_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/source_editor.vue
@@ -1,5 +1,5 @@
<script>
-import { debounce } from 'lodash';
+import { debounce, isEmpty } from 'lodash';
import { CONTENT_UPDATE_DEBOUNCE, EDITOR_READY_EVENT } from '~/editor/constants';
import Editor from '~/editor/source_editor';
@@ -37,9 +37,9 @@ export default {
default: '',
},
extensions: {
- type: [String, Array],
+ type: [Object, Array],
required: false,
- default: () => null,
+ default: () => ({}),
},
editorOptions: {
type: Object,
@@ -74,11 +74,13 @@ export default {
blobPath: this.fileName,
blobContent: this.value,
blobGlobalId: this.fileGlobalId,
- extensions: this.extensions,
...this.editorOptions,
});
this.editor.onDidChangeModelContent(debounce(this.onFileChange.bind(this), this.debounceValue));
+ if (!isEmpty(this.extensions)) {
+ this.editor.use(this.extensions);
+ }
},
beforeDestroy() {
this.editor.dispose();
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
index 6babbca58c3..9683288f937 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue
@@ -51,6 +51,10 @@ export default {
required: false,
default: null,
},
+ blamePath: {
+ type: String,
+ required: true,
+ },
},
computed: {
lines() {
@@ -76,6 +80,7 @@ export default {
:number="startingFrom + index + 1"
:content="line"
:language="language"
+ :blame-path="blamePath"
/>
</div>
<div v-else class="gl-display-flex">
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 7b62f0cdb7d..257b9f57222 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
@@ -1,15 +1,14 @@
<script>
-import { GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlSafeHtmlDirective } from '@gitlab/ui';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { setAttributes } from '~/lib/utils/dom_utils';
import { BIDI_CHARS, BIDI_CHARS_CLASS_LIST, BIDI_CHAR_TOOLTIP } from '../constants';
export default {
- components: {
- GlLink,
- },
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [glFeatureFlagMixin()],
props: {
number: {
type: Number,
@@ -23,6 +22,10 @@ export default {
type: String,
required: true,
},
+ blamePath: {
+ type: String,
+ required: true,
+ },
},
computed: {
formattedContent() {
@@ -36,9 +39,6 @@ export default {
return content;
},
- firstLineClass() {
- return { 'gl-mt-3!': this.number === 1 };
- },
},
methods: {
wrapBidiChar(bidiChar) {
@@ -59,21 +59,26 @@ export default {
</script>
<template>
<div class="gl-display-flex">
- <div class="gl-p-0! gl-absolute gl-z-index-3 gl-border-r diff-line-num line-numbers">
- <gl-link
+ <div
+ class="gl-p-0! gl-absolute gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers"
+ >
+ <a
+ v-if="glFeatures.fileLineBlame"
+ class="gl-user-select-none gl-shadow-none! file-line-blame"
+ :href="`${blamePath}#L${number}`"
+ ></a>
+ <a
:id="`L${number}`"
- class="gl-user-select-none gl-ml-5 gl-pr-3 gl-shadow-none! file-line-num diff-line-num"
- :class="firstLineClass"
- :to="`#L${number}`"
+ class="gl-user-select-none gl-shadow-none! file-line-num"
+ :href="`#L${number}`"
:data-line-number="number"
>
{{ number }}
- </gl-link>
+ </a>
</div>
<pre
- class="gl-p-0! gl-w-full gl-overflow-visible! gl-ml-11! gl-border-none! code highlight gl-line-height-normal"
- :class="firstLineClass"
+ class="gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-normal"
><code><span :id="`LC${number}`" v-safe-html="formattedContent" :lang="language" class="line" data-testid="content"></span></code></pre>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 3ac35abcf3a..cc930d67fa4 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -147,3 +147,4 @@ export const HLJS_COMMENT_SELECTOR = 'hljs-comment';
export const HLJS_ON_AFTER_HIGHLIGHT = 'after:highlight';
export const NPM_URL = 'https://npmjs.com/package';
+export const GEM_URL = 'https://rubygems.org/gems';
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
index 5b7650c56ae..d957990fe7f 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js
@@ -1,7 +1,9 @@
import packageJsonLinker from './utils/package_json_linker';
+import gemspecLinker from './utils/gemspec_linker';
const DEPENDENCY_LINKERS = {
package_json: packageJsonLinker,
+ gemspec: gemspecLinker,
};
/**
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
index 56ad55ef553..dbe6812cf16 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/dependency_linker_util.js
@@ -7,9 +7,10 @@ export const createLink = (href, innerText) => {
const link = document.createElement('a');
setAttributes(link, { href: escape(href), rel });
- link.innerText = escape(innerText);
+ link.textContent = innerText;
return link.outerHTML;
};
-export const generateHLJSOpenTag = (type) => `<span class="hljs-${escape(type)}">&quot;`;
+export const generateHLJSOpenTag = (type, delimiter = '&quot;') =>
+ `<span class="hljs-${escape(type)}">${delimiter}`;
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js
new file mode 100644
index 00000000000..35de8fd13d6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/gemspec_linker.js
@@ -0,0 +1,39 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+import { GEM_URL } from '../../constants';
+import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
+
+const methodRegex = '.*add_dependency.*|.*add_runtime_dependency.*|.*add_development_dependency.*';
+const openTagRegex = generateHLJSOpenTag('string', '(&.*;)');
+const closeTagRegex = '&.*</span>';
+
+const DEPENDENCY_REGEX = new RegExp(
+ /*
+ * Detects gemspec dependencies inside of content that is highlighted by Highlight.js
+ * Example: s.add_dependency(<span class="hljs-string">&#x27;rugged&#x27;</span>, <span class="hljs-string">&#x27;~&gt; 0.24.0&#x27;</span>)
+ *
+ * Group 1 (method) : s.add_dependency(
+ * Group 2 (delimiter) : &#x27;
+ * Group 3 (packageName): rugged
+ * Group 4 (closeTag) : &#x27;</span>
+ * Group 5 (rest) : , <span class="hljs-string">&#x27;~&gt; 0.24.0&#x27;</span>)
+ */
+ `(${methodRegex})${openTagRegex}(.*)(${closeTagRegex})(.*${closeTagRegex})`,
+ 'gm',
+);
+
+const handleReplace = (method, delimiter, packageName, closeTag, rest) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ const openTag = generateHLJSOpenTag('string linked', delimiter);
+ const href = joinPaths(GEM_URL, packageName);
+ const packageLink = createLink(href, packageName);
+
+ return `${method}${openTag}${packageLink}${closeTag}${rest}`;
+};
+
+export default (result) => {
+ return result.value.replace(
+ DEPENDENCY_REGEX,
+ (_, method, delimiter, packageName, closeTag, rest) =>
+ handleReplace(method, delimiter, packageName, closeTag, rest),
+ );
+};
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
index d013d077ba3..3c6fc23c138 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/package_json_linker.js
@@ -1,3 +1,4 @@
+import { unescape } from 'lodash';
import { joinPaths } from '~/lib/utils/url_utility';
import { NPM_URL } from '../../constants';
import { createLink, generateHLJSOpenTag } from './dependency_linker_util';
@@ -17,13 +18,15 @@ const DEPENDENCY_REGEX = new RegExp(
);
const handleReplace = (original, packageName, version, dependenciesToLink) => {
- const href = joinPaths(NPM_URL, packageName);
- const packageLink = createLink(href, packageName);
- const versionLink = createLink(href, version);
+ const unescapedPackageName = unescape(packageName);
+ const unescapedVersion = unescape(version);
+ const href = joinPaths(NPM_URL, unescapedPackageName);
+ const packageLink = createLink(href, unescapedPackageName);
+ const versionLink = createLink(href, unescapedVersion);
const closeAndOpenTag = `${closeTag}: ${attrOpenTag}`;
- const dependencyToLink = dependenciesToLink[packageName];
+ const dependencyToLink = dependenciesToLink[unescapedPackageName];
- if (dependencyToLink && dependencyToLink === version) {
+ if (dependencyToLink && dependencyToLink === unescapedVersion) {
return `${attrOpenTag}${packageLink}${closeAndOpenTag}${versionLink}${closeTag}`;
}
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 1bdae40332f..ccc8b44942a 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
@@ -199,6 +199,7 @@ export default {
:starting-from="firstChunk.startingFrom"
:is-highlighted="firstChunk.isHighlighted"
:language="firstChunk.language"
+ :blame-path="blob.blamePath"
/>
<gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" />
@@ -213,6 +214,7 @@ export default {
:is-highlighted="chunk.isHighlighted"
:chunk-index="index"
:language="chunk.language"
+ :blame-path="blob.blamePath"
@appear="highlightChunk"
/>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index d07f65cf5c1..c1e618620d8 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -50,7 +50,7 @@ export default {
default: __('user avatar'),
},
size: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -64,12 +64,19 @@ export default {
required: false,
default: 'top',
},
+ enforceGlAvatar: {
+ type: Boolean,
+ required: false,
+ },
},
};
</script>
<template>
- <user-avatar-image-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <user-avatar-image-new
+ v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar"
+ v-bind="$props"
+ >
<slot></slot>
</user-avatar-image-new>
<user-avatar-image-old v-else v-bind="$props">
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
index 707b0bbec67..cd610314292 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue
@@ -16,6 +16,7 @@
*/
import { GlTooltip, GlAvatar } from '@gitlab/ui';
+import { isObject } from 'lodash';
import defaultAvatarUrl from 'images/no_avatar.png';
import { __ } from '~/locale';
import { placeholderImage } from '~/lazy_loader';
@@ -48,7 +49,7 @@ export default {
default: __('user avatar'),
},
size: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -71,9 +72,16 @@ export default {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
// Only adds the width to the URL if its not a base64 data image
if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
- baseSrc += `?width=${this.size}`;
+ baseSrc += `?width=${this.maximumSize}`;
return baseSrc;
},
+ maximumSize() {
+ if (isObject(this.size)) {
+ return Math.max(...Object.values(this.size));
+ }
+
+ return this.size;
+ },
resultantSrcAttribute() {
return this.lazy ? placeholderImage : this.sanitizedSource;
},
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
index 887deff17c9..f80abed4d69 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue
@@ -55,7 +55,7 @@ export default {
default: '',
},
imgSize: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -74,12 +74,19 @@ export default {
required: false,
default: '',
},
+ enforceGlAvatar: {
+ type: Boolean,
+ required: false,
+ },
},
};
</script>
<template>
- <user-avatar-link-new v-if="glFeatures.glAvatarForAllUserAvatars" v-bind="$props">
+ <user-avatar-link-new
+ v-if="glFeatures.glAvatarForAllUserAvatars || enforceGlAvatar"
+ v-bind="$props"
+ >
<slot></slot>
<template #avatar-badge>
<slot name="avatar-badge"></slot>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
index 3b459569274..83551c689c4 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link_new.vue
@@ -56,7 +56,7 @@ export default {
default: '',
},
imgSize: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
@@ -75,6 +75,10 @@ export default {
required: false,
default: '',
},
+ enforceGlAvatar: {
+ type: Boolean,
+ required: false,
+ },
},
computed: {
shouldShowUsername() {
@@ -97,6 +101,7 @@ export default {
:tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement"
:lazy="lazy"
+ :enforce-gl-avatar="enforceGlAvatar"
>
<slot></slot>
</user-avatar-image>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
index 60b26d688b2..9da298ad705 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -21,7 +21,7 @@ export default {
default: 10,
},
imgSize: {
- type: Number,
+ type: [Number, Object],
required: false,
default: 20,
},
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 a0d8ca117a4..2b9804796ae 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,6 +14,7 @@ import { glEmojiTag } from '~/emoji';
import createFlash from '~/flash';
import { followUser, unfollowUser } from '~/rest_api';
import { isUserBusy } from '~/set_status_modal/utils';
+import Tracking from '~/tracking';
import { USER_POPOVER_DELAY } from './constants';
const MAX_SKELETON_LINES = 4;
@@ -37,6 +38,7 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
+ mixins: [Tracking.mixin()],
props: {
target: {
type: HTMLElement,
@@ -117,6 +119,11 @@ export default {
},
async follow() {
this.toggleFollowLoading = true;
+
+ this.track('click_button', {
+ label: 'follow_from_user_popover',
+ });
+
try {
await followUser(this.user.id);
this.$emit('follow');
@@ -132,6 +139,11 @@ export default {
},
async unfollow() {
this.toggleFollowLoading = true;
+
+ this.track('click_button', {
+ label: 'unfollow_from_user_popover',
+ });
+
try {
await unfollowUser(this.user.id);
this.$emit('unfollow');
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
index 91f20863089..43a590c2367 100644
--- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -77,6 +77,11 @@ export default {
required: false,
default: null,
},
+ issuableAuthor: {
+ type: Object,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -178,7 +183,7 @@ export default {
[],
);
- return this.moveCurrentUserToStart(mergedSearchResults);
+ return this.moveCurrentUserAndAuthorToStart(mergedSearchResults);
},
isSearchEmpty() {
return this.search === '';
@@ -196,14 +201,21 @@ export default {
showCurrentUser() {
return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
},
+ showAuthor() {
+ return (
+ this.issuableAuthor &&
+ !this.users.some((user) => user.id === this.issuableAuthor.id) &&
+ this.isSearchEmpty
+ );
+ },
selectedFiltered() {
if (this.shouldShowParticipants) {
- return this.moveCurrentUserToStart(this.value);
+ return this.moveCurrentUserAndAuthorToStart(this.value);
}
const foundUsernames = this.users.map(({ username }) => username);
const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
- return this.moveCurrentUserToStart(filtered);
+ return this.moveCurrentUserAndAuthorToStart(filtered);
},
selectedUserNames() {
return this.value.map(({ username }) => username);
@@ -254,20 +266,22 @@ export default {
showDivider(list) {
return list.length > 0 && this.isSearchEmpty;
},
- moveCurrentUserToStart(users) {
- if (!users) {
- return [];
+ moveCurrentUserAndAuthorToStart(users = []) {
+ let sortedUsers = [...users];
+
+ const author = sortedUsers.find((user) => user.id === this.issuableAuthor?.id);
+ if (author) {
+ sortedUsers = [author, ...sortedUsers.filter((user) => user.id !== author.id)];
}
- const usersCopy = [...users];
- const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
+
+ const currentUser = sortedUsers.find((user) => user.username === this.currentUser.username);
if (currentUser) {
currentUser.canMerge = this.currentUser.canMerge;
- const index = usersCopy.indexOf(currentUser);
- usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ sortedUsers = [currentUser, ...sortedUsers.filter((user) => user.id !== currentUser.id)];
}
- return usersCopy;
+ return sortedUsers;
},
setSearchKey(value) {
this.search = value.trim();
@@ -298,7 +312,7 @@ export default {
<gl-loading-icon
v-if="isLoading"
data-testid="loading-participants"
- size="lg"
+ size="md"
class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
/>
<template v-else>
@@ -312,8 +326,8 @@ export default {
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
$options.i18n.unassigned
- }}</span></gl-dropdown-item
- >
+ }}</span>
+ </gl-dropdown-item>
</template>
<gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
<gl-dropdown-item
@@ -342,7 +356,17 @@ export default {
/>
</gl-dropdown-item>
</template>
- <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
+ <gl-dropdown-item
+ v-if="showAuthor"
+ data-testid="issuable-author"
+ @click.native.capture.stop="selectAssignee(issuableAuthor)"
+ >
+ <sidebar-participant
+ :user="issuableAuthor"
+ :issuable-type="issuableType"
+ class="gl-pl-6!"
+ />
+ </gl-dropdown-item>
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
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 cac0d5a45c9..6d179b3dc92 100644
--- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue
+++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue
@@ -10,6 +10,21 @@ const KEY_WEB_IDE = 'webide';
const KEY_GITPOD = 'gitpod';
const KEY_PIPELINE_EDITOR = 'pipeline_editor';
+export const i18n = {
+ modal: {
+ title: __('Enable Gitpod?'),
+ content: s__(
+ 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.',
+ ),
+ actionCancelText: __('Cancel'),
+ actionPrimaryText: __('Enable Gitpod'),
+ },
+ webIdeText: s__('WebIDE|Quickly and easily edit multiple files in your project.'),
+ webIdeTooltip: s__(
+ 'WebIDE|Quickly and easily edit multiple files in your project. Press . to open',
+ ),
+};
+
export default {
components: {
ActionsButton,
@@ -19,16 +34,7 @@ export default {
GlLink,
ConfirmForkModal,
},
- i18n: {
- modal: {
- title: __('Enable Gitpod?'),
- content: s__(
- 'Gitpod|To use Gitpod you must first enable the feature in the integrations section of your %{linkStart}user preferences%{linkEnd}.',
- ),
- actionCancelText: __('Cancel'),
- actionPrimaryText: __('Enable Gitpod'),
- },
- },
+ i18n,
props: {
isFork: {
type: Boolean,
@@ -207,8 +213,8 @@ export default {
return {
key: KEY_WEB_IDE,
text: this.webIdeActionText,
- secondaryText: __('Quickly and easily edit multiple files in your project.'),
- tooltip: '',
+ secondaryText: this.$options.i18n.webIdeText,
+ tooltip: this.$options.i18n.webIdeTooltip,
attrs: {
'data-qa-selector': 'web_ide_button',
'data-track-action': 'click_consolidated_edit_ide',
diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js
index 14328b1f25f..b6d69faebb5 100644
--- a/app/assets/javascripts/vue_shared/constants.js
+++ b/app/assets/javascripts/vue_shared/constants.js
@@ -1,4 +1,4 @@
-import { __, sprintf } from '~/locale';
+import { __, n__, sprintf } from '~/locale';
import { IssuableType, WorkspaceType } from '~/issues/constants';
const INTERVALS = {
@@ -15,51 +15,62 @@ export const ISO_SHORT_FORMAT = 'yyyy-mm-dd';
export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT];
+const getTimeLabel = (days) => n__('1 day', '%d days', days);
+
+/* eslint-disable @gitlab/require-i18n-strings */
export const timeRanges = [
{
- label: __('30 minutes'),
+ label: n__('1 minute', '%d minutes', 30),
+ shortcut: '30_minutes',
duration: { seconds: 60 * 30 },
name: 'thirtyMinutes',
interval: INTERVALS.minute,
},
{
- label: __('3 hours'),
+ label: n__('1 hour', '%d hours', 3),
+ shortcut: '3_hours',
duration: { seconds: 60 * 60 * 3 },
name: 'threeHours',
interval: INTERVALS.hour,
},
{
- label: __('8 hours'),
+ label: n__('1 hour', '%d hours', 8),
+ shortcut: '8_hours',
duration: { seconds: 60 * 60 * 8 },
name: 'eightHours',
default: true,
interval: INTERVALS.hour,
},
{
- label: __('1 day'),
+ label: getTimeLabel(1),
+ shortcut: '1_day',
duration: { seconds: 60 * 60 * 24 * 1 },
name: 'oneDay',
interval: INTERVALS.hour,
},
{
- label: __('3 days'),
+ label: getTimeLabel(3),
+ shortcut: '3_days',
duration: { seconds: 60 * 60 * 24 * 3 },
name: 'threeDays',
interval: INTERVALS.hour,
},
{
- label: __('7 days'),
+ label: getTimeLabel(7),
+ shortcut: '7_days',
duration: { seconds: 60 * 60 * 24 * 7 * 1 },
name: 'oneWeek',
interval: INTERVALS.day,
},
{
- label: __('30 days'),
+ label: getTimeLabel(30),
+ shortcut: '30_days',
duration: { seconds: 60 * 60 * 24 * 30 },
name: 'oneMonth',
interval: INTERVALS.day,
},
];
+/* eslint-enable @gitlab/require-i18n-strings */
export const defaultTimeRange = timeRanges.find((tr) => tr.default);
export const getTimeWindow = (timeWindowName) =>
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 b616b390032..38083327593 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
@@ -7,6 +7,7 @@ import { differenceInSeconds, getTimeago, SECONDS_IN_DAY } from '~/lib/utils/dat
import { isExternal, setUrlFragment } from '~/lib/utils/url_utility';
import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
@@ -17,6 +18,7 @@ export default {
GlFormCheckbox,
GlSprintf,
IssuableAssignees,
+ WorkItemTypeIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -50,6 +52,11 @@ export default {
required: false,
default: false,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
issuableId() {
@@ -118,8 +125,8 @@ export default {
return sprintf(
n__(
- '%{completedCount} of %{count} task completed',
- '%{completedCount} of %{count} tasks completed',
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
count,
),
{ completedCount, count },
@@ -225,6 +232,7 @@ export default {
</span>
</div>
<div class="issuable-info">
+ <work-item-type-icon v-if="showWorkItemTypeIcon" :work-item-type="issuable.type" />
<slot v-if="hasSlotContents('reference')" name="reference"></slot>
<span v-else data-testid="issuable-reference" class="issuable-reference">
{{ reference }}
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 189bbb56432..bc10f84b819 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
@@ -182,6 +182,11 @@ export default {
required: false,
default: false,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -344,6 +349,7 @@ export default {
:label-filter-param="labelFilterParam"
:show-checkbox="showBulkEditSidebar"
:checked="issuableChecked(issuable)"
+ :show-work-item-type-icon="showWorkItemTypeIcon"
@checked-input="handleIssuableCheckedInput(issuable, $event)"
>
<template #reference>
diff --git a/app/assets/javascripts/vue_shared/issuable/list/constants.js b/app/assets/javascripts/vue_shared/issuable/list/constants.js
index 507f333a34e..f6b864dfde0 100644
--- a/app/assets/javascripts/vue_shared/issuable/list/constants.js
+++ b/app/assets/javascripts/vue_shared/issuable/list/constants.js
@@ -46,13 +46,6 @@ 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_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue
index cdc5903b934..1f23fdfaafd 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
@@ -12,6 +12,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isExternal } from '~/lib/utils/url_utility';
import { n__, sprintf } from '~/locale';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import { IssuableStates } from '~/vue_shared/issuable/list/constants';
export default {
@@ -22,6 +23,7 @@ export default {
GlAvatarLink,
GlAvatarLabeled,
TimeAgoTooltip,
+ WorkItemTypeIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -65,6 +67,16 @@ export default {
required: false,
default: null,
},
+ issuableType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
badgeVariant() {
@@ -81,8 +93,8 @@ export default {
return sprintf(
n__(
- '%{completedCount} of %{count} task completed',
- '%{completedCount} of %{count} tasks completed',
+ '%{completedCount} of %{count} checklist item completed',
+ '%{completedCount} of %{count} checklist items completed',
count,
),
{ completedCount, count },
@@ -122,7 +134,13 @@ export default {
</div>
</div>
<span>
- {{ __('Created') }}
+ <template v-if="showWorkItemTypeIcon">
+ <work-item-type-icon :work-item-type="issuableType" show-text />
+ {{ __('created') }}
+ </template>
+ <template v-else>
+ {{ __('Created') }}
+ </template>
<time-ago-tooltip data-testid="startTimeItem" :time="createdAt" />
{{ __('by') }}
</span>
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 7ed93c042f8..2bc57ecba55 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
@@ -87,6 +87,11 @@ export default {
required: false,
default: 0,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
methods: {
handleKeydownTitle(e, issuableMeta) {
@@ -110,6 +115,8 @@ export default {
:created-at="issuable.createdAt"
:author="issuable.author"
:task-completion-status="taskCompletionStatus"
+ :issuable-type="issuable.type"
+ :show-work-item-type-icon="showWorkItemTypeIcon"
>
<template #status-badge>
<slot name="status-badge"></slot>
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 8e9b8ef3e6f..232749a2d01 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
@@ -125,7 +125,7 @@ export default {
<h4>{{ activePanel.title }}</h4>
<p v-if="hasTextDetails">{{ details }}</p>
- <component :is="details" v-else />
+ <component :is="details" v-else v-bind="activePanel.detailProps || {}" />
<slot name="extra-description"></slot>
</div>
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 0c55cc2f8a6..9e5361e8302 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -79,7 +79,7 @@ export default {
@bottomReached="bottomReached"
>
<template #items>
- <feature v-for="feature in features" :key="feature.title" :feature="feature" />
+ <feature v-for="feature in features" :key="feature.name" :feature="feature" />
</template>
</gl-infinite-scroll>
</template>
diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue
index 90f6230ef72..c954a86e593 100644
--- a/app/assets/javascripts/whats_new/components/feature.vue
+++ b/app/assets/javascripts/whats_new/components/feature.vue
@@ -30,7 +30,6 @@ export default {
return dateInWords(date);
},
},
- safeHtmlConfig: { ADD_ATTR: ['target'] },
};
</script>
@@ -38,35 +37,35 @@ export default {
<div class="gl-py-6 gl-px-6 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
<gl-link
v-if="feature.image_url"
- :href="feature.url"
+ :href="feature.documentation_link"
target="_blank"
class="gl-display-block"
data-testid="whats-new-image-link"
data-track-action="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
+ :data-track-label="feature.name"
+ :data-track-property="feature.documentation_link"
>
<div
class="whats-new-item-image gl-bg-size-cover"
:style="`background-image: url(${feature.image_url});`"
>
- <span class="gl-sr-only">{{ feature.title }}</span>
+ <span class="gl-sr-only">{{ feature.name }}</span>
</div>
</gl-link>
<gl-link
- :href="feature.url"
+ :href="feature.documentation_link"
target="_blank"
class="whats-new-item-title-link gl-display-block gl-mt-4 gl-mb-1"
data-track-action="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
+ :data-track-label="feature.name"
+ :data-track-property="feature.documentation_link"
>
- <h5 class="gl-font-lg gl-my-0" data-test-id="feature-title">{{ feature.title }}</h5>
+ <h5 class="gl-font-lg gl-my-0" data-test-id="feature-name">{{ feature.name }}</h5>
</gl-link>
<div v-if="releaseDate" class="gl-mb-3" data-testid="release-date">{{ releaseDate }}</div>
- <div v-if="feature.packages" class="gl-mb-3">
+ <div v-if="feature.available_in" class="gl-mb-3">
<gl-badge
- v-for="packageName in feature.packages"
+ v-for="packageName in feature.available_in"
:key="packageName"
size="md"
variant="tier"
@@ -77,15 +76,15 @@ export default {
</gl-badge>
</div>
<div
- v-safe-html:[$options.safeHtmlConfig]="feature.body"
+ v-safe-html:[$options.safeHtmlConfig]="feature.description"
class="gl-pt-3 gl-line-height-20"
></div>
<gl-button
- :href="feature.url"
+ :href="feature.documentation_link"
target="_blank"
data-track-action="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
+ :data-track-label="feature.name"
+ :data-track-property="feature.documentation_link"
>
{{ __('Learn more') }} <gl-icon name="arrow-right" />
</gl-button>
diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue
index 2dc8e3a1101..2a0913e380a 100644
--- a/app/assets/javascripts/work_items/components/item_state.vue
+++ b/app/assets/javascripts/work_items/components/item_state.vue
@@ -26,7 +26,7 @@ export default {
type: String,
required: true,
},
- loading: {
+ disabled: {
type: Boolean,
required: false,
default: false,
@@ -61,15 +61,17 @@ export default {
:id="$options.labelId"
:value="state"
:options="$options.states"
- :disabled="loading"
- class="gl-w-auto hide-select-decoration"
+ :disabled="disabled"
+ class="gl-w-auto hide-select-decoration gl-pl-3"
+ :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }"
@change="setState"
/>
</gl-form-group>
</template>
<style>
-.hide-select-decoration:not(:focus, :hover) {
+.hide-select-decoration:not(:focus, :hover),
+.hide-select-decoration:disabled {
background-image: none;
box-shadow: none;
}
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 1cdc9c28f05..551ebbadb21 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -36,7 +36,7 @@ export default {
<template>
<h2
class="gl-font-weight-normal gl-sm-font-weight-bold gl-mb-5 gl-mt-0 gl-w-full"
- :class="{ 'gl-cursor-not-allowed': disabled }"
+ :class="{ 'gl-cursor-text': disabled }"
aria-labelledby="item-title"
>
<div
@@ -46,7 +46,8 @@ export default {
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
- 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"
+ class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base"
+ :class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }"
@blur="handleBlur"
@keyup="handleInput"
@keydown.enter.exact="handleSubmit"
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 77002eeaf55..2753c3fa388 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -1,15 +1,24 @@
<script>
-import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
export default {
i18n: {
deleteTask: s__('WorkItem|Delete task'),
+ enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
+ disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
},
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownDivider,
GlModal,
},
directives: {
@@ -22,14 +31,33 @@ export default {
required: false,
default: null,
},
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
canDelete: {
type: Boolean,
required: false,
default: false,
},
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isParentConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- emits: ['deleteWorkItem'],
+ emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
methods: {
+ handleToggleWorkItemConfidentiality() {
+ this.track('click_toggle_work_item_confidentiality');
+ this.$emit('toggleWorkItemConfidentiality', !this.isConfidential);
+ },
handleDeleteWorkItem() {
this.track('click_delete_work_item');
this.$emit('deleteWorkItem');
@@ -44,7 +72,7 @@ export default {
</script>
<template>
- <div v-if="canDelete">
+ <div>
<gl-dropdown
icon="ellipsis_v"
text-sr-only
@@ -53,9 +81,24 @@ export default {
no-caret
right
>
- <gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
- $options.i18n.deleteTask
- }}</gl-dropdown-item>
+ <template v-if="canUpdate && !isParentConfidential">
+ <gl-dropdown-item
+ data-testid="confidentiality-toggle-action"
+ @click="handleToggleWorkItemConfidentiality"
+ >{{
+ isConfidential
+ ? $options.i18n.disableTaskConfidentiality
+ : $options.i18n.enableTaskConfidentiality
+ }}</gl-dropdown-item
+ >
+ <gl-dropdown-divider v-if="canDelete" />
+ </template>
+ <gl-dropdown-item
+ v-if="canDelete"
+ v-gl-modal="'work-item-confirm-delete'"
+ data-testid="delete-action"
+ >{{ $options.i18n.deleteTask }}</gl-dropdown-item
+ >
</gl-dropdown>
<gl-modal
modal-id="work-item-confirm-delete"
diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue
index 9ff424aa20f..7342f215b5e 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -18,11 +18,17 @@ import { n__, s__ } from '~/locale';
import Tracking from '~/tracking';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
function isTokenSelectorElement(el) {
- return el?.classList.contains('gl-token-close') || el?.classList.contains('dropdown-item');
+ return (
+ el?.classList.contains('gl-token-close') ||
+ el?.classList.contains('dropdown-item') ||
+ // TODO: replace this logic when we have a class added to clear-all button in GitLab UI
+ (el?.classList.contains('gl-button') &&
+ el?.closest('.form-control')?.classList.contains('gl-token-selector'))
+ );
}
function addClass(el) {
@@ -69,6 +75,11 @@ export default {
required: false,
default: false,
},
+ canInviteMembers: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -130,7 +141,7 @@ export default {
if (this.searchUsers.some((user) => user.username === this.currentUser.username)) {
return this.moveCurrentUserToStart(this.searchUsers);
}
- return [this.currentUser, ...this.searchUsers];
+ return [addClass(this.currentUser), ...this.searchUsers];
}
return this.searchUsers;
},
@@ -138,16 +149,25 @@ export default {
return this.searchKey.length === 0;
},
addAssigneesText() {
+ if (!this.canUpdate) {
+ return s__('WorkItem|None');
+ }
return this.allowsMultipleAssignees
? s__('WorkItem|Add assignees')
: s__('WorkItem|Add assignee');
},
+ assigneeIds() {
+ return this.localAssignees.map(({ id }) => id);
+ },
},
watch: {
- assignees(newVal) {
- if (!this.isEditing) {
- this.localAssignees = newVal.map(addClass);
- }
+ assignees: {
+ handler(newVal) {
+ if (!this.isEditing) {
+ this.localAssignees = newVal.map(addClass);
+ }
+ },
+ deep: true,
},
},
created() {
@@ -169,19 +189,33 @@ export default {
handleBlur(e) {
if (isTokenSelectorElement(e.relatedTarget) || !this.isEditing) return;
this.isEditing = false;
- this.setAssignees(this.localAssignees);
+ this.setAssignees(this.assigneeIds);
},
- setAssignees(assignees) {
- this.$apollo.mutate({
- mutation: localUpdateWorkItemMutation,
- variables: {
- input: {
- id: this.workItemId,
- assignees,
+ async setAssignees(assigneeIds) {
+ try {
+ const {
+ data: {
+ workItemUpdate: { errors },
},
- },
- });
- this.track('updated_assignees');
+ } = await this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ assigneesWidget: {
+ assigneeIds,
+ },
+ },
+ },
+ });
+ if (errors.length > 0) {
+ this.throwUpdateError();
+ return;
+ }
+ this.track('updated_assignees');
+ } catch {
+ this.throwUpdateError();
+ }
},
handleFocus() {
this.isEditing = true;
@@ -205,13 +239,25 @@ export default {
},
moveCurrentUserToStart(users = []) {
if (this.currentUser) {
- return [this.currentUser, ...users.filter((user) => user.id !== this.currentUser.id)];
+ return [
+ addClass(this.currentUser),
+ ...users.filter((user) => user.id !== this.currentUser.id),
+ ];
}
return users;
},
closeDropdown() {
this.$refs.tokenSelector.closeDropdown();
},
+ assignToCurrentUser() {
+ this.setAssignees([this.currentUser.id]);
+ this.localAssignees = [addClass(this.currentUser)];
+ },
+ throwUpdateError() {
+ this.$emit('error', i18n.updateError);
+ // If mutation is rejected, we're rolling back to initial state
+ this.localAssignees = this.assignees.map(addClass);
+ },
},
};
</script>
@@ -227,11 +273,12 @@ export default {
ref="tokenSelector"
:selected-tokens="localAssignees"
:container-class="containerClass"
- class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0!"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
:dropdown-items="dropdownItems"
:loading="isLoadingUsers"
:view-only="!canUpdate"
+ :allow-clear-all="isEditing"
+ class="assignees-selector gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2"
@input="handleAssigneesInput"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
@@ -241,7 +288,7 @@ export default {
>
<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"
+ class="add-assignees gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-300 gl-pr-4 gl-pl-2 gl-top-2"
data-testid="empty-state"
>
<gl-icon name="profile" />
@@ -251,7 +298,7 @@ export default {
size="small"
class="assign-myself"
data-testid="assign-self"
- @click.stop="setAssignees([currentUser])"
+ @click.stop="assignToCurrentUser"
>{{ __('Assign myself') }}</gl-button
>
</div>
@@ -262,7 +309,7 @@ export default {
: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"
+ class="gl-ml-n2 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>
@@ -279,7 +326,7 @@ export default {
<rect width="280" height="20" x="10" y="130" rx="4" />
</gl-skeleton-loader>
</template>
- <template #dropdown-footer>
+ <template v-if="canInviteMembers" #dropdown-footer>
<gl-dropdown-divider />
<gl-dropdown-item @click="closeDropdown">
<invite-members-trigger
diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue
index 90e3cd45cb4..cf59789ce2d 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -172,7 +172,7 @@ export default {
<template>
<gl-form-group
v-if="isEditing"
- class="gl-my-5"
+ class="gl-my-5 gl-border-t gl-pt-6"
:label="__('Description')"
label-for="work-item-description"
>
@@ -182,7 +182,7 @@ export default {
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
- class="gl-p-3 bordered-box"
+ class="gl-p-3 bordered-box gl-mt-5"
>
<template #textarea>
<textarea
@@ -217,9 +217,9 @@ export default {
}}</gl-button>
</div>
</gl-form-group>
- <div v-else class="gl-mb-5">
- <div class="gl-display-flex gl-align-items-center gl-mb-5">
- <h3 class="gl-font-base gl-my-0">{{ __('Description') }}</h3>
+ <div v-else class="gl-mb-5 gl-border-t">
+ <div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
+ <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
<gl-button
v-if="canEdit"
class="gl-ml-auto"
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 ad90fe88947..a5580c14a7a 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -1,7 +1,16 @@
<script>
-import { GlAlert, GlSkeletonLoader, GlIcon, GlButton } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlSkeletonLoader,
+ GlLoadingIcon,
+ GlIcon,
+ GlBadge,
+ GlButton,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import {
i18n,
WIDGET_TYPE_ASSIGNEES,
@@ -11,8 +20,12 @@ import {
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
+
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
+import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
+
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
@@ -24,9 +37,14 @@ import WorkItemInformation from './work_item_information.vue';
export default {
i18n,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
components: {
GlAlert,
+ GlBadge,
GlButton,
+ GlLoadingIcon,
GlSkeletonLoader,
GlIcon,
WorkItemAssignees,
@@ -38,6 +56,7 @@ export default {
WorkItemWeight,
WorkItemInformation,
LocalStorageSync,
+ WorkItemTypeIcon,
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -62,6 +81,7 @@ export default {
error: undefined,
workItem: {},
showInfoBanner: true,
+ updateInProgress: false,
};
},
apollo: {
@@ -114,7 +134,7 @@ export default {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
workItemWeight() {
- return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
+ return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
workItemHierarchy() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY);
@@ -122,9 +142,15 @@ export default {
parentWorkItem() {
return this.workItemHierarchy?.parent;
},
+ parentWorkItemConfidentiality() {
+ return this.parentWorkItem?.confidential;
+ },
parentUrl() {
return `../../issues/${this.parentWorkItem?.iid}`;
},
+ workItemIconName() {
+ return this.workItem?.workItemType?.iconName;
+ },
},
beforeDestroy() {
/** make sure that if the user has not even dismissed the alert ,
@@ -135,6 +161,54 @@ export default {
dismissBanner() {
this.showInfoBanner = false;
},
+ toggleConfidentiality(confidentialStatus) {
+ this.updateInProgress = true;
+ let updateMutation = updateWorkItemMutation;
+ let inputVariables = {
+ id: this.workItemId,
+ confidential: confidentialStatus,
+ };
+
+ if (this.parentWorkItem) {
+ updateMutation = updateWorkItemTaskMutation;
+ inputVariables = {
+ id: this.parentWorkItem.id,
+ taskData: {
+ id: this.workItemId,
+ confidential: confidentialStatus,
+ },
+ };
+ }
+
+ this.$apollo
+ .mutate({
+ mutation: updateMutation,
+ variables: {
+ input: inputVariables,
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors, workItem, task },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+
+ this.$emit('workItemUpdated', {
+ confidential: workItem?.confidential || task?.confidential,
+ });
+ },
+ )
+ .catch((error) => {
+ this.error = error.message;
+ })
+ .finally(() => {
+ this.updateInProgress = false;
+ });
+ },
},
WORK_ITEM_VIEWED_STORAGE_KEY,
};
@@ -142,7 +216,7 @@ export default {
<template>
<section class="gl-pt-5">
- <gl-alert v-if="error" variant="danger" @dismiss="error = undefined">
+ <gl-alert v-if="error" class="gl-mb-3" variant="danger" @dismiss="error = undefined">
{{ error }}
</gl-alert>
@@ -153,33 +227,61 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div class="gl-display-flex gl-align-items-center">
+ <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
<ul
v-if="parentWorkItem"
- class="list-unstyled gl-display-flex gl-mr-auto"
+ class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0"
data-testid="work-item-parent"
>
- <li class="gl-ml-n4">
- <gl-button icon="issues" category="tertiary" :href="parentUrl">{{
- parentWorkItem.title
- }}</gl-button>
- <gl-icon name="chevron-right" :size="16" />
+ <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden">
+ <gl-button
+ v-gl-tooltip.hover
+ class="gl-text-truncate gl-max-w-full"
+ icon="issues"
+ category="tertiary"
+ :href="parentUrl"
+ :title="parentWorkItem.title"
+ >{{ parentWorkItem.title }}</gl-button
+ >
+ <gl-icon name="chevron-right" :size="16" class="gl-flex-shrink-0" />
</li>
- <li class="gl-px-4 gl-py-3 gl-line-height-0">
- <gl-icon name="task-done" />
+ <li
+ class="gl-px-4 gl-py-3 gl-line-height-0 gl-display-flex gl-align-items-center gl-overflow-hidden gl-flex-shrink-0"
+ >
+ <work-item-type-icon
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ />
{{ workItemType }}
</li>
</ul>
- <span
+ <work-item-type-icon
v-else
+ :work-item-icon-name="workItemIconName"
+ :work-item-type="workItemType && workItemType.toUpperCase()"
+ show-text
class="gl-font-weight-bold gl-text-secondary gl-mr-auto"
data-testid="work-item-type"
- >{{ workItemType }}</span
+ />
+ <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
+ <gl-badge
+ v-if="workItem.confidential"
+ v-gl-tooltip.bottom
+ :title="$options.i18n.confidentialTooltip"
+ variant="warning"
+ icon="eye-slash"
+ class="gl-mr-3 gl-cursor-help"
+ >{{ __('Confidential') }}</gl-badge
>
<work-item-actions
+ v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
:can-delete="canDelete"
+ :can-update="canUpdate"
+ :is-confidential="workItem.confidential"
+ :is-parent-confidential="parentWorkItemConfidentiality"
@deleteWorkItem="$emit('deleteWorkItem')"
+ @toggleWorkItemConfidentiality="toggleConfidentiality"
@error="error = $event"
/>
<gl-button
@@ -206,11 +308,13 @@ export default {
:work-item-title="workItem.title"
:work-item-type="workItemType"
:work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
@error="error = $event"
/>
<work-item-state
:work-item="workItem"
:work-item-parent-id="workItemParentId"
+ :can-update="canUpdate"
@error="error = $event"
/>
<template v-if="workItemsMvc2Enabled">
@@ -221,6 +325,7 @@ export default {
:assignees="workItemAssignees.assignees.nodes"
:allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
:work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
@error="error = $event"
/>
<work-item-labels
@@ -229,15 +334,16 @@ export default {
:can-update="canUpdate"
@error="error = $event"
/>
- <work-item-weight
- v-if="workItemWeight"
- class="gl-mb-5"
- :can-update="canUpdate"
- :weight="workItemWeight.weight"
- :work-item-id="workItem.id"
- :work-item-type="workItemType"
- />
</template>
+ <work-item-weight
+ v-if="workItemWeight"
+ class="gl-mb-5"
+ :can-update="canUpdate"
+ :weight="workItemWeight.weight"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
+ @error="error = $event"
+ />
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
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 df7c6cab7ef..39a662a6c54 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
@@ -2,9 +2,13 @@
import { GlAlert, GlModal } from '@gitlab/ui';
import { s__ } from '~/locale';
import deleteWorkItemFromTaskMutation from '../graphql/delete_task_from_work_item.mutation.graphql';
+import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
import WorkItemDetail from './work_item_detail.vue';
export default {
+ i18n: {
+ errorMessage: s__('WorkItem|Something went wrong when deleting the task. Please try again.'),
+ },
components: {
GlAlert,
GlModal,
@@ -45,6 +49,13 @@ export default {
},
methods: {
deleteWorkItem() {
+ if (this.lockVersion != null && this.lineNumberStart && this.lineNumberEnd) {
+ this.deleteWorkItemWithTaskData();
+ } else {
+ this.deleteWorkItemWithoutTaskData();
+ }
+ },
+ deleteWorkItemWithTaskData() {
this.$apollo
.mutate({
mutation: deleteWorkItemFromTaskMutation,
@@ -70,17 +81,33 @@ export default {
},
}) => {
if (errors?.length) {
- throw new Error(errors[0].message);
+ throw new Error(errors[0]);
}
this.$emit('workItemDeleted', descriptionHtml);
- this.$refs.modal.hide();
+ this.hide();
},
)
- .catch((e) => {
- this.error =
- e.message ||
- s__('WorkItem|Something went wrong when deleting the task. Please try again.');
+ .catch((error) => {
+ this.setErrorMessage(error.message);
+ });
+ },
+ deleteWorkItemWithoutTaskData() {
+ this.$apollo
+ .mutate({
+ mutation: deleteWorkItemMutation,
+ variables: { input: { id: this.workItemId } },
+ })
+ .then(({ data }) => {
+ if (data.workItemDelete.errors?.length) {
+ throw new Error(data.workItemDelete.errors[0]);
+ }
+
+ this.$emit('workItemDeleted', this.workItemId);
+ this.hide();
+ })
+ .catch((error) => {
+ this.setErrorMessage(error.message);
});
},
closeModal() {
@@ -91,7 +118,7 @@ export default {
this.$refs.modal.hide();
},
setErrorMessage(message) {
- this.error = message;
+ this.error = message || this.$options.i18n.errorMessage;
},
show() {
this.$refs.modal.show();
diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue
index 78ed67998d7..e73488bbd70 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -202,7 +202,8 @@ export default {
:dropdown-items="searchLabels"
:loading="isLoading"
:view-only="!canUpdate"
- class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!"
+ :class="{ 'gl-hover-border-gray-200': canUpdate }"
@input="focusTokenSelector"
@text-input="debouncedSearchKeyUpdate"
@focus="handleFocus"
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
index 176f84f6c1a..86f03583ea3 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/index.js
+++ b/app/assets/javascripts/work_items/components/work_item_links/index.js
@@ -1,16 +1,10 @@
import Vue from 'vue';
-import VueApollo from 'vue-apollo';
import { GlToast } from '@gitlab/ui';
-import createDefaultClient from '~/lib/graphql';
+import { createApolloProvider } from '../../graphql/provider';
import WorkItemLinks from './work_item_links.vue';
-Vue.use(VueApollo);
Vue.use(GlToast);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
export default function initWorkItemLinks() {
if (!window.gon.features.workItemsHierarchy) {
return;
@@ -22,16 +16,20 @@ export default function initWorkItemLinks() {
return;
}
+ const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset;
+
// eslint-disable-next-line no-new
new Vue({
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
- apolloProvider,
+ apolloProvider: createApolloProvider(),
components: {
workItemLinks: WorkItemLinks,
},
provide: {
- projectPath: workItemLinksRoot.dataset.projectPath,
+ projectPath,
+ fullPath: projectPath,
+ hasIssueWeightsFeature: wiHasIssueWeightsFeature,
},
render: (createElement) =>
createElement('work-item-links', {
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
index 89f086cfca5..534ebabee08 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue
@@ -1,8 +1,14 @@
<script>
-import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlIcon, GlAlert, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { produce } from 'immer';
import { s__ } from '~/locale';
-import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
+import { isMetaKey } from '~/lib/utils/common_utils';
+import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
+import SidebarEventHub from '~/sidebar/event_hub';
+
import {
STATE_OPEN,
WIDGET_ICONS,
@@ -10,18 +16,26 @@ import {
WIDGET_TYPE_HIERARCHY,
} from '../../constants';
import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
+import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
+import workItemQuery from '../../graphql/work_item.query.graphql';
+import WorkItemDetailModal from '../work_item_detail_modal.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
components: {
GlButton,
- GlBadge,
GlIcon,
+ GlAlert,
GlLoadingIcon,
WorkItemLinksForm,
WorkItemLinksMenu,
+ WorkItemDetailModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
+ inject: ['projectPath'],
props: {
workItemId: {
type: String,
@@ -35,32 +49,44 @@ export default {
},
},
apollo: {
- children: {
+ workItem: {
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;
},
+ error(e) {
+ this.error = e.message || this.$options.i18n.fetchError;
+ },
},
},
data() {
return {
isShownAddForm: false,
isOpen: true,
- children: [],
+ activeChildId: null,
+ activeToast: null,
+ prefetchedWorkItem: null,
+ error: undefined,
};
},
computed: {
+ children() {
+ return (
+ this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
+ .nodes ?? []
+ );
+ },
+ canUpdate() {
+ return this.workItem?.userPermissions.updateWorkItem || false;
+ },
+ confidential() {
+ return this.workItem?.confidential || false;
+ },
// Only used for children for now but should be extended later to support parents and siblings
isChildrenEmpty() {
return this.children?.length === 0;
@@ -77,28 +103,149 @@ export default {
return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null;
},
isLoading() {
- return this.$apollo.queries.children.loading;
+ return this.$apollo.queries.workItem.loading;
},
childrenIds() {
return this.children.map((c) => c.id);
},
+ childrenCountLabel() {
+ return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
+ },
+ },
+ mounted() {
+ SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems);
+ },
+ destroyed() {
+ SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems);
},
methods: {
- badgeVariant(state) {
- return state === STATE_OPEN ? 'success' : 'info';
+ refetchWorkItems() {
+ this.$apollo.queries.workItem.refetch();
+ },
+ iconClass(state) {
+ return state === STATE_OPEN ? 'gl-text-green-500' : 'gl-text-blue-500';
+ },
+ iconName(state) {
+ return state === STATE_OPEN ? 'issue-open-m' : 'issue-close';
},
toggle() {
this.isOpen = !this.isOpen;
},
- toggleAddForm() {
- this.isShownAddForm = !this.isShownAddForm;
+ showAddForm() {
+ this.isOpen = true;
+ this.isShownAddForm = true;
+ this.$nextTick(() => {
+ this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus();
+ });
+ },
+ hideAddForm() {
+ this.isShownAddForm = false;
},
addChild(child) {
- this.children = [child, ...this.children];
+ const { defaultClient: client } = this.$apollo.provider.clients;
+ this.toggleChildFromCache(child, child.id, client);
+ },
+ openChild(childItemId, e) {
+ if (isMetaKey(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.activeChildId = childItemId;
+ this.$refs.modal.show();
+ this.updateWorkItemIdUrlQuery(childItemId);
+ },
+ closeModal() {
+ this.activeChildId = null;
+ this.updateWorkItemIdUrlQuery(undefined);
+ },
+ handleWorkItemDeleted(childId) {
+ const { defaultClient: client } = this.$apollo.provider.clients;
+ this.toggleChildFromCache(null, childId, client);
+ this.activeToast = this.$toast.show(s__('WorkItem|Task deleted'));
+ },
+ updateWorkItemIdUrlQuery(childItemId) {
+ updateHistory({
+ url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }),
+ replace: true,
+ });
+ },
+ childPath(childItemId) {
+ return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`;
+ },
+ toggleChildFromCache(workItem, childId, store) {
+ const sourceData = store.readQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.issuableGid },
+ });
+
+ const newData = produce(sourceData, (draftState) => {
+ const widgetHierarchy = draftState.workItem.widgets.find(
+ (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
+ );
+
+ const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId);
+
+ if (index >= 0) {
+ widgetHierarchy.children.nodes.splice(index, 1);
+ } else {
+ widgetHierarchy.children.nodes.push(workItem);
+ }
+ });
+
+ store.writeQuery({
+ query: getWorkItemLinksQuery,
+ variables: { id: this.issuableGid },
+ data: newData,
+ });
+ },
+ async updateWorkItem(workItem, childId, parentId) {
+ return this.$apollo.mutate({
+ mutation: updateWorkItemMutation,
+ variables: { input: { id: childId, hierarchyWidget: { parentId } } },
+ update: (store) => this.toggleChildFromCache(workItem, childId, store),
+ });
+ },
+ async undoChildRemoval(workItem, childId) {
+ const { data } = await this.updateWorkItem(workItem, childId, this.issuableGid);
+
+ if (data.workItemUpdate.errors.length === 0) {
+ this.activeToast?.hide();
+ }
+ },
+ async removeChild(childId) {
+ const { data } = await this.updateWorkItem(null, childId, null);
+
+ if (data.workItemUpdate.errors.length === 0) {
+ this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
+ action: {
+ text: s__('WorkItem|Undo'),
+ onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId),
+ },
+ });
+ }
+ },
+ prefetchWorkItem(id) {
+ this.prefetch = setTimeout(
+ () =>
+ this.$apollo.addSmartQuery('prefetchedWorkItem', {
+ query: workItemQuery,
+ variables: {
+ id,
+ },
+ update: (data) => data.workItem,
+ }),
+ DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
+ );
+ },
+ clearPrefetching() {
+ clearTimeout(this.prefetch);
},
},
i18n: {
title: s__('WorkItem|Child items'),
+ fetchError: s__(
+ 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.',
+ ),
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!',
),
@@ -112,21 +259,32 @@ export default {
<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-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between"
:class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }"
>
- <h5 class="gl-m-0 gl-line-height-32 gl-flex-grow-1">{{ $options.i18n.title }}</h5>
+ <div class="gl-display-flex gl-flex-grow-1">
+ <h5 class="gl-m-0 gl-line-height-24">{{ $options.i18n.title }}</h5>
+ <span
+ class="gl-display-inline-flex gl-align-items-center gl-line-height-24 gl-ml-3"
+ data-testid="children-count"
+ >
+ <gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-2 gl-text-gray-500" />
+ {{ childrenCountLabel }}
+ </span>
+ </div>
<gl-button
- v-if="!isShownAddForm"
+ v-if="canUpdate"
category="secondary"
+ size="small"
data-testid="toggle-add-form"
- @click="toggleAddForm"
+ @click="showAddForm"
>
{{ $options.i18n.addChildButtonLabel }}
</gl-button>
- <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-50 gl-pl-4 gl-ml-3">
+ <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3">
<gl-button
category="tertiary"
+ size="small"
:icon="toggleIcon"
:aria-label="toggleLabel"
data-testid="toggle-links"
@@ -134,48 +292,81 @@ export default {
/>
</div>
</div>
+ <gl-alert v-if="error && !isLoading" variant="danger" @dismiss="error = undefined">
+ {{ error }}
+ </gl-alert>
<div
v-if="isOpen"
- class="gl-bg-gray-10 gl-p-4 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base"
+ :class="{ 'gl-p-5 gl-pb-3': !error }"
data-testid="links-body"
>
<gl-loading-icon v-if="isLoading" color="dark" class="gl-my-3" />
<template v-else>
- <div v-if="isChildrenEmpty && !isShownAddForm" data-testid="links-empty">
- <p class="gl-my-3">
+ <div v-if="isChildrenEmpty && !isShownAddForm && !error" data-testid="links-empty">
+ <p class="gl-mt-3 gl-mb-4">
{{ $options.i18n.emptyStateMessage }}
</p>
</div>
<work-item-links-form
v-if="isShownAddForm"
+ ref="wiLinksForm"
data-testid="add-links-form"
:issuable-gid="issuableGid"
:children-ids="childrenIds"
- @cancel="toggleAddForm"
+ :parent-confidential="confidential"
+ @cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
<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 gl-line-height-32"
+ class="gl-relative gl-display-flex 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 gl-line-height-32"
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 class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <gl-icon
+ :name="iconName(child.state)"
+ class="gl-mr-3"
+ :class="iconClass(child.state)"
+ />
+ <gl-icon
+ v-if="child.confidential"
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-mr-2 gl-text-orange-500"
+ data-testid="confidential-icon"
+ :title="__('Confidential')"
+ />
+ <gl-button
+ :href="childPath(child.id)"
+ category="tertiary"
+ variant="link"
+ class="gl-text-truncate gl-max-w-80 gl-text-black-normal!"
+ @click="openChild(child.id, $event)"
+ @mouseover="prefetchWorkItem(child.id)"
+ @mouseout="clearPrefetching"
+ >
+ {{ child.title }}
+ </gl-button>
</div>
- <div
- class="gl-ml-0 gl-sm-ml-auto! gl-mt-3 gl-sm-mt-0 gl-display-inline-flex gl-align-items-center"
- >
- <gl-badge :variant="badgeVariant(child.state)">
- <span class="gl-sm-display-block">{{
- $options.WORK_ITEM_STATUS_TEXT[child.state]
- }}</span>
- </gl-badge>
- <work-item-links-menu :work-item-id="child.id" :parent-work-item-id="issuableGid" />
+ <div class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center">
+ <work-item-links-menu
+ v-if="canUpdate"
+ :work-item-id="child.id"
+ :parent-work-item-id="issuableGid"
+ data-testid="links-menu"
+ @removeChild="removeChild(child.id)"
+ />
</div>
</div>
+ <work-item-detail-modal
+ ref="modal"
+ :work-item-id="activeChildId"
+ @close="closeModal"
+ @workItemDeleted="handleWorkItemDeleted(activeChildId)"
+ />
</template>
</div>
</div>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
index fadba0753db..8b848995d44 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue
@@ -1,9 +1,11 @@
<script>
-import { GlAlert, GlForm, GlFormCombobox, GlButton } from '@gitlab/ui';
+import { GlAlert, GlFormGroup, GlForm, GlFormCombobox, GlButton, GlFormInput } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
-import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql';
+import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql';
+import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql';
+import { TASK_TYPE_NAME } from '../../constants';
export default {
components: {
@@ -11,6 +13,8 @@ export default {
GlForm,
GlFormCombobox,
GlButton,
+ GlFormGroup,
+ GlFormInput,
},
inject: ['projectPath'],
props: {
@@ -24,24 +28,22 @@ export default {
required: false,
default: () => [],
},
+ parentConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
apollo: {
- availableWorkItems: {
- query: projectWorkItemsQuery,
+ workItemTypes: {
+ query: projectWorkItemTypesQuery,
variables() {
return {
- projectPath: this.projectPath,
- searchTerm: this.search?.title || this.search,
- types: ['TASK'],
+ fullPath: this.projectPath,
};
},
- skip() {
- return this.search.length === 0;
- },
update(data) {
- return data.workspace.workItems.edges
- .filter((wi) => !this.childrenIds.includes(wi.node.id))
- .map((wi) => wi.node);
+ return data.workspace?.workItemTypes?.nodes;
},
},
},
@@ -50,8 +52,32 @@ export default {
availableWorkItems: [],
search: '',
error: null,
+ childToCreateTitle: null,
};
},
+ computed: {
+ actionsList() {
+ return [
+ {
+ label: this.$options.i18n.createChildOptionLabel,
+ fn: () => {
+ this.childToCreateTitle = this.search?.title || this.search;
+ },
+ },
+ ];
+ },
+ addOrCreateButtonLabel() {
+ return this.childToCreateTitle
+ ? this.$options.i18n.createChildOptionLabel
+ : this.$options.i18n.addTaskButtonLabel;
+ },
+ addOrCreateMethod() {
+ return this.childToCreateTitle ? this.createChild : this.addChild;
+ },
+ taskWorkItemType() {
+ return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id;
+ },
+ },
methods: {
getIdFromGraphQLId,
unsetError() {
@@ -79,35 +105,78 @@ export default {
}
})
.catch(() => {
- this.error = this.$options.i18n.errorMessage;
+ this.error = this.$options.i18n.addChildErrorMessage;
})
.finally(() => {
this.search = '';
});
},
+ createChild() {
+ this.$apollo
+ .mutate({
+ mutation: createWorkItemMutation,
+ variables: {
+ input: {
+ title: this.search?.title || this.search,
+ projectPath: this.projectPath,
+ workItemTypeId: this.taskWorkItemType,
+ hierarchyWidget: {
+ parentId: this.issuableGid,
+ },
+ confidential: this.parentConfidential,
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemCreate?.errors?.length) {
+ [this.error] = data.workItemCreate.errors;
+ } else {
+ this.unsetError();
+ this.$emit('addWorkItemChild', data.workItemCreate.workItem);
+ }
+ })
+ .catch(() => {
+ this.error = this.$options.i18n.createChildErrorMessage;
+ })
+ .finally(() => {
+ this.search = '';
+ this.childToCreateTitle = null;
+ });
+ },
},
i18n: {
- inputLabel: __('Children'),
- errorMessage: s__(
+ inputLabel: __('Title'),
+ addTaskButtonLabel: s__('WorkItem|Add task'),
+ addChildErrorMessage: s__(
'WorkItem|Something went wrong when trying to add a child. Please try again.',
),
+ createChildOptionLabel: s__('WorkItem|Create task'),
+ createChildErrorMessage: s__(
+ 'WorkItem|Something went wrong when trying to create a child. Please try again.',
+ ),
+ placeholder: s__('WorkItem|Add a title'),
+ fieldValidationMessage: __('Maximum of 255 characters'),
},
};
</script>
<template>
<gl-form
- class="gl-mb-3 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base"
+ class="gl-bg-white gl-mb-3 gl-p-4 gl-border gl-border-gray-100 gl-rounded-base"
+ @submit.prevent="createChild"
>
<gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError">
{{ error }}
</gl-alert>
+ <!-- Follow up issue to turn this functionality back on https://gitlab.com/gitlab-org/gitlab/-/issues/368757 -->
<gl-form-combobox
+ v-if="false"
v-model="search"
:token-list="availableWorkItems"
match-value-to-attr="title"
class="gl-mb-4"
:label-text="$options.i18n.inputLabel"
+ :action-list="actionsList"
label-sr-only
autofocus
>
@@ -117,11 +186,35 @@ export default {
<div>{{ item.title }}</div>
</div>
</template>
+ <template #action="{ item }">
+ <span class="gl-text-blue-500">{{ item.label }}</span>
+ </template>
</gl-form-combobox>
- <gl-button category="secondary" data-testid="add-child-button" @click="addChild">
- {{ s__('WorkItem|Add task') }}
+ <gl-form-group
+ :label="$options.i18n.inputLabel"
+ :description="$options.i18n.fieldValidationMessage"
+ >
+ <gl-form-input
+ ref="wiTitleInput"
+ v-model="search"
+ :placeholder="$options.i18n.placeholder"
+ maxlength="255"
+ class="gl-mb-3"
+ autofocus
+ />
+ </gl-form-group>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ size="small"
+ type="submit"
+ :disabled="search.length === 0"
+ data-testid="add-child-button"
+ class="gl-mr-2"
+ >
+ {{ $options.i18n.createChildOptionLabel }}
</gl-button>
- <gl-button category="tertiary" @click="$emit('cancel')">
+ <gl-button category="secondary" size="small" @click="$emit('cancel')">
{{ s__('WorkItem|Cancel') }}
</gl-button>
</gl-form>
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
index 6deb87c5dca..1aa4a433a58 100644
--- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_menu.vue
@@ -1,10 +1,5 @@
<script>
import { GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { produce } from 'immer';
-import { s__ } from '~/locale';
-import changeWorkItemParentMutation from '../../graphql/change_work_item_parent_link.mutation.graphql';
-import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql';
-import { WIDGET_TYPE_HIERARCHY } from '../../constants';
export default {
components: {
@@ -12,78 +7,6 @@ export default {
GlDropdown,
GlIcon,
},
- props: {
- workItemId: {
- type: String,
- required: true,
- },
- parentWorkItemId: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- activeToast: null,
- };
- },
- methods: {
- toggleChildFromCache(data, store) {
- const sourceData = store.readQuery({
- query: getWorkItemLinksQuery,
- variables: { id: this.parentWorkItemId },
- });
-
- const newData = produce(sourceData, (draftState) => {
- const widgetHierarchy = draftState.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_HIERARCHY,
- );
-
- const index = widgetHierarchy.children.nodes.findIndex(
- (child) => child.id === this.workItemId,
- );
-
- if (index >= 0) {
- widgetHierarchy.children.nodes.splice(index, 1);
- } else {
- widgetHierarchy.children.nodes.push(data.workItemUpdate.workItem);
- }
- });
-
- store.writeQuery({
- query: getWorkItemLinksQuery,
- variables: { id: this.parentWorkItemId },
- data: newData,
- });
- },
- async addChild(data) {
- const { data: resp } = await this.$apollo.mutate({
- mutation: changeWorkItemParentMutation,
- variables: { id: this.workItemId, parentId: this.parentWorkItemId },
- update: this.toggleChildFromCache.bind(this, data),
- });
-
- if (resp.workItemUpdate.errors.length === 0) {
- this.activeToast?.hide();
- }
- },
- async removeChild() {
- const { data } = await this.$apollo.mutate({
- mutation: changeWorkItemParentMutation,
- variables: { id: this.workItemId, parentId: null },
- update: this.toggleChildFromCache.bind(this, null),
- });
-
- if (data.workItemUpdate.errors.length === 0) {
- this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), {
- action: {
- text: s__('WorkItem|Undo'),
- onClick: this.addChild.bind(this, data),
- },
- });
- }
- },
- },
};
</script>
@@ -93,7 +16,7 @@ export default {
<template #button-content>
<gl-icon name="ellipsis_v" :size="14" />
</template>
- <gl-dropdown-item @click="removeChild">
+ <gl-dropdown-item @click="$emit('removeChild')">
{{ s__('WorkItem|Remove') }}
</gl-dropdown-item>
</gl-dropdown>
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 87f4a8822b1..080d4025cc3 100644
--- a/app/assets/javascripts/work_items/components/work_item_state.vue
+++ b/app/assets/javascripts/work_items/components/work_item_state.vue
@@ -27,6 +27,11 @@ export default {
required: false,
default: null,
},
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -102,7 +107,7 @@ export default {
<item-state
v-if="workItem.state"
:state="workItem.state"
- :loading="updateInProgress"
+ :disabled="updateInProgress || !canUpdate"
@changed="updateWorkItemState"
/>
</template>
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 b4c13037038..cd5cc3894f6 100644
--- a/app/assets/javascripts/work_items/components/work_item_title.vue
+++ b/app/assets/javascripts/work_items/components/work_item_title.vue
@@ -31,6 +31,11 @@ export default {
required: false,
default: null,
},
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
tracking() {
@@ -84,5 +89,5 @@ export default {
</script>
<template>
- <item-title :title="workItemTitle" @title-changed="updateTitle" />
+ <item-title :title="workItemTitle" :disabled="!canUpdate" @title-changed="updateTitle" />
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
new file mode 100644
index 00000000000..fd914fa350b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import { WORK_ITEMS_TYPE_MAP } from '../constants';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ workItemType: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showText: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ workItemIconName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ iconName() {
+ return (
+ this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue'
+ );
+ },
+ workItemTypeName() {
+ return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name;
+ },
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-icon :name="iconName" class="gl-mr-2" />
+ <span v-if="workItemTypeName" :class="{ 'gl-sr-only': !showText }">{{ workItemTypeName }}</span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_weight.vue b/app/assets/javascripts/work_items/components/work_item_weight.vue
index 30e2c1e56b8..b0ad7c97bb1 100644
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ b/app/assets/javascripts/work_items/components/work_item_weight.vue
@@ -1,9 +1,10 @@
<script>
import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
import Tracking from '~/tracking';
-import { TRACKING_CATEGORY_SHOW } from '../constants';
-import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
+import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
/* eslint-disable @gitlab/require-i18n-strings */
const allowedKeys = [
@@ -97,17 +98,36 @@ export default {
}
},
updateWeight(event) {
+ if (!this.canUpdate) return;
this.isEditing = false;
+
+ const weight = Number(event.target.value);
+ if (this.weight === weight) {
+ return;
+ }
+
this.track('updated_weight');
- this.$apollo.mutate({
- mutation: localUpdateWorkItemMutation,
- variables: {
- input: {
- id: this.workItemId,
- weight: event.target.value === '' ? null : Number(event.target.value),
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ weightWidget: {
+ weight: event.target.value === '' ? null : weight,
+ },
+ },
},
- },
- });
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors.join('\n'));
+ }
+ })
+ .catch((error) => {
+ this.$emit('error', i18n.updateError);
+ Sentry.captureException(error);
+ });
},
},
};
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index 2140b418e6d..a2aea3cd327 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -8,11 +8,6 @@ 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 TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
@@ -22,13 +17,48 @@ export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
-export const WIDGET_TYPE_TASK_ICON = 'task-done';
+export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT';
+export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE';
+export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK';
+export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE';
+export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
+
+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.'),
+ confidentialTooltip: s__(
+ 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.',
+ ),
+};
export const WIDGET_ICONS = {
- TASK: 'task-done',
+ TASK: 'issue-type-task',
};
export const WORK_ITEM_STATUS_TEXT = {
CLOSED: s__('WorkItem|Closed'),
OPEN: s__('WorkItem|Open'),
};
+
+export const WORK_ITEMS_TYPE_MAP = {
+ [WORK_ITEM_TYPE_ENUM_INCIDENT]: {
+ icon: `issue-type-incident`,
+ name: s__('WorkItem|Incident'),
+ },
+ [WORK_ITEM_TYPE_ENUM_ISSUE]: {
+ icon: `issue-type-issue`,
+ name: s__('WorkItem|Issue'),
+ },
+ [WORK_ITEM_TYPE_ENUM_TASK]: {
+ icon: `issue-type-task`,
+ name: s__('WorkItem|Task'),
+ },
+ [WORK_ITEM_TYPE_ENUM_TEST_CASE]: {
+ icon: `issue-type-test-case`,
+ name: s__('WorkItem|Test case'),
+ },
+ [WORK_ITEM_TYPE_ENUM_REQUIREMENTS]: {
+ icon: `issue-type-requirements`,
+ name: s__('WorkItem|Requirements'),
+ },
+};
diff --git a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql b/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
deleted file mode 100644
index dc5286174d8..00000000000
--- a/app/assets/javascripts/work_items/graphql/change_work_item_parent_link.mutation.graphql
+++ /dev/null
@@ -1,13 +0,0 @@
-mutation changeWorkItemParentLink($id: WorkItemID!, $parentId: WorkItemID) {
- workItemUpdate(input: { id: $id, hierarchyWidget: { parentId: $parentId } }) {
- workItem {
- id
- workItemType {
- id
- }
- title
- state
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
index 7f9aaf43068..4cc23fa0071 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql
@@ -1,9 +1,10 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
workItem {
...WorkItem
}
+ errors
}
}
diff --git a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
index ccfe62cc585..1f98cd4fa2b 100644
--- a/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/create_work_item_from_task.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemCreateFromTask($input: WorkItemCreateFromTaskInput!) {
workItemCreateFromTask(input: $input) {
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
index 43c92cf89ec..790b8e60b6a 100644
--- 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
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
localUpdateWorkItem(input: $input) @client {
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js
index 8788ad21e7b..b70c06fddea 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/work_items/graphql/provider.js
@@ -2,7 +2,7 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants';
+import { WIDGET_TYPE_LABELS } from '../constants';
import typeDefs from './typedefs.graphql';
import workItemQuery from './work_item.query.graphql';
@@ -10,7 +10,7 @@ export const temporaryConfig = {
typeDefs,
cacheConfig: {
possibleTypes: {
- LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'],
+ LocalWorkItemWidget: ['LocalWorkItemLabels'],
},
typePolicies: {
WorkItem: {
@@ -25,15 +25,15 @@ export const temporaryConfig = {
allowScopedLabels: true,
nodes: [],
},
- {
- __typename: 'LocalWorkItemWeight',
- type: 'WEIGHT',
- weight: null,
- },
]
);
},
},
+ widgets: {
+ merge(_, incoming) {
+ return incoming;
+ },
+ },
},
},
},
@@ -49,20 +49,6 @@ export const resolvers = {
});
const data = produce(sourceData, (draftData) => {
- if (input.assignees) {
- const assigneesWidget = draftData.workItem.widgets.find(
- (widget) => widget.type === WIDGET_TYPE_ASSIGNEES,
- );
- assigneesWidget.assignees.nodes = [...input.assignees];
- }
-
- if (input.weight != null) {
- const weightWidget = draftData.workItem.mockWidgets.find(
- (widget) => widget.type === WIDGET_TYPE_WEIGHT,
- );
- weightWidget.weight = input.weight;
- }
-
if (input.labels) {
const labelsWidget = draftData.workItem.mockWidgets.find(
(widget) => widget.type === WIDGET_TYPE_LABELS,
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
index 48228b15a53..36ffba8a540 100644
--- a/app/assets/javascripts/work_items/graphql/typedefs.graphql
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
@@ -1,7 +1,6 @@
enum LocalWidgetType {
ASSIGNEES
LABELS
- WEIGHT
}
interface LocalWorkItemWidget {
@@ -19,20 +18,29 @@ type LocalWorkItemLabels implements LocalWorkItemWidget {
nodes: [Label!]
}
-type LocalWorkItemWeight implements LocalWorkItemWidget {
- type: LocalWidgetType!
- weight: Int
-}
-
extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
+input LocalUserInput {
+ id: ID!
+ name: String
+ username: String
+ webUrl: String
+ avatarUrl: String
+}
+
+input LocalLabelInput {
+ id: ID!
+ title: String!
+ color: String
+ description: String
+}
+
input LocalUpdateWorkItemInput {
id: WorkItemID!
- assignees: [UserCore!]
- labels: [Label]
- weight: Int
+ assignees: [LocalUserInput!]
+ labels: [LocalLabelInput]
}
type LocalWorkItemPayload {
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
index 25eb8099251..0a887fcfc00 100644
--- a/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
+++ b/app/assets/javascripts/work_items/graphql/update_work_item.mutation.graphql
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
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
index ad861a60d15..fad5a9fa5bc 100644
--- 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
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemUpdateTask($input: WorkItemUpdateTaskInput!) {
workItemUpdate: workItemUpdateTask(input: $input) {
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
index 148b340b439..6a94c96b347 100644
--- 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
@@ -1,4 +1,4 @@
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) {
workItemUpdateWidgets(input: $input) {
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 5f64eda96aa..e8ef27ec778 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -5,9 +5,11 @@ fragment WorkItem on WorkItem {
title
state
description
+ confidential
workItemType {
id
name
+ iconName
}
userPermissions {
deleteWorkItem
@@ -22,6 +24,7 @@ fragment WorkItem on WorkItem {
... on WorkItemWidgetAssignees {
type
allowsMultipleAssignees
+ canInviteMembers
assignees {
nodes {
...User
@@ -34,12 +37,11 @@ fragment WorkItem on WorkItem {
id
iid
title
+ confidential
}
children {
- edges {
- node {
- id
- }
+ nodes {
+ id
}
}
}
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 61cb8802187..a9f7b714551 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql
@@ -1,5 +1,5 @@
#import "~/graphql_shared/fragments/label.fragment.graphql"
-#import "./work_item.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
query workItem($id: WorkItemID!) {
workItem(id: $id) {
@@ -12,10 +12,6 @@ query workItem($id: WorkItemID!) {
...Label
}
}
- ... 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
index c2496f53cc8..df62ca1c143 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql
@@ -5,6 +5,11 @@ query workItemQuery($id: WorkItemID!) {
id
}
title
+ userPermissions {
+ deleteWorkItem
+ updateWorkItem
+ }
+ confidential
widgets {
type
... on WorkItemWidgetHierarchy {
@@ -15,6 +20,7 @@ query workItemQuery($id: WorkItemID!) {
children {
nodes {
id
+ confidential
workItemType {
id
}
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index be72ec33465..004dc22c9b8 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -27,7 +27,6 @@
@import './pages/registry';
@import './pages/search';
@import './pages/service_desk';
-@import './pages/settings_ci_cd';
@import './pages/settings';
@import './pages/storage_quota';
@import './pages/tree';
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index ceac5da7f80..6a6febbf7b4 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -37,7 +37,7 @@ $avatar-sizes: (
),
60: (
font-size: 32px,
- line-height: 58px,
+ line-height: 60px,
border-radius: $border-radius-large
),
64: (
@@ -47,7 +47,7 @@ $avatar-sizes: (
),
90: (
font-size: 36px,
- line-height: 88px,
+ line-height: 90px,
border-radius: $border-radius-large
),
96: (
@@ -72,7 +72,6 @@ $avatar-sizes: (
float: left;
margin-right: $gl-padding;
border-radius: $avatar-radius;
- border: 1px solid $t-gray-a-08;
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
@@ -83,13 +82,12 @@ $avatar-sizes: (
.avatar {
transition-property: none;
-
width: 40px;
height: 40px;
padding: 0;
background: $gray-lightest;
overflow: hidden;
- border-color: rgba($black, $gl-avatar-border-opacity);
+ box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity);
&.avatar-inline {
float: none;
@@ -180,6 +178,10 @@ $avatar-sizes: (
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
border-radius: map-get($size-config, 'border-radius');
+
+ .avatar {
+ border-radius: map-get($size-config, 'border-radius');
+ }
}
}
}
diff --git a/app/assets/stylesheets/components/batch_comments/review_bar.scss b/app/assets/stylesheets/components/batch_comments/review_bar.scss
index 6f5c5c5a080..5e1128dc4ce 100644
--- a/app/assets/stylesheets/components/batch_comments/review_bar.scss
+++ b/app/assets/stylesheets/components/batch_comments/review_bar.scss
@@ -11,7 +11,7 @@
padding-right: $gutter_collapsed_width;
background: $white;
border-top: 1px solid $border-color;
- transition: padding $sidebar-transition-duration;
+ transition: padding $gl-transition-duration-medium;
.page-with-icon-sidebar & {
padding-left: $contextual-sidebar-collapsed-width;
diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss
deleted file mode 100644
index 59bd69955d3..00000000000
--- a/app/assets/stylesheets/components/rich_content_editor.scss
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
-* Overrides styles from ToastUI editor
-*/
-
-.tui-editor-defaultUI {
-
- // Toolbar buttons
- .tui-editor-defaultUI-toolbar .toolbar-button {
- color: $gray-500;
- border: 0;
-
- &:hover,
- &:active {
- color: $blue-500;
- border: 0;
- }
- }
-
- // Contextual menu's & popups
- .tui-popup-wrapper {
- @include gl-overflow-hidden;
- @include gl-rounded-base;
- @include gl-border-gray-200;
-
- hr {
- @include gl-m-0;
- @include gl-bg-gray-200;
- }
-
- button {
- @include gl-text-gray-700;
- }
- }
-
- /**
- * Overrides styles from ToastUI's Code Mirror (markdown mode) editor.
- * Toast UI internally overrides some of these using the `.tui-md-` prefix.
- * https://codemirror.net/doc/manual.html#styling
- */
-
- .te-md-container .CodeMirror * {
- @include gl-font-monospace;
- @include gl-font-size-monospace;
- @include gl-line-height-20;
- }
-}
-
-/**
-* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com
-* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/main/source/stylesheets/_base.scss#L977
-*/
-.video_container {
- padding-bottom: 56.25%;
-}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 549289450a4..f947042ba51 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -84,89 +84,6 @@
border-bottom: 1px solid $white-dark;
padding: 11px 0;
margin-bottom: 11px;
-
- &.no-bottom-space {
- border-bottom: 0;
- margin-bottom: 0;
- }
-}
-
-.cover-block {
- text-align: center;
- background: $gray-light;
- padding-top: 44px;
- position: relative;
-
- .avatar-holder {
- .avatar,
- .identicon {
- margin: 0 auto;
- float: none;
- }
-
- .identicon {
- border-radius: 50%;
- }
- }
-
- .cover-title {
- color: $gl-text-color;
- font-size: 23px;
-
- h1 {
- color: $gl-text-color;
- margin-bottom: 6px;
- font-size: 23px;
- }
-
- .visibility-icon {
- display: inline-block;
- margin-left: 5px;
- font-size: 18px;
- color: color('gray');
- }
-
- p {
- padding: 0 $gl-padding;
- color: $gl-text-color;
- }
- }
-
- .cover-controls {
- @include media-breakpoint-up(sm) {
- position: absolute;
- top: 1rem;
- right: 1.25rem;
- }
-
- &.left {
- @include media-breakpoint-up(sm) {
- left: 1.25rem;
- right: auto;
- }
- }
- }
-
- &.user-cover-block {
- padding: 24px 0 0;
-
- .nav-links {
- width: 100%;
- float: none;
-
- &.scrolling-tabs {
- float: none;
- }
- }
-
- li:first-child {
- margin-left: auto;
- }
-
- li:last-child {
- margin-right: auto;
- }
- }
}
.content-block {
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 1fa03d66f32..b1e5ca50a8b 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -1,30 +1,3 @@
-.calendar-block {
- padding-left: 0;
- padding-right: 0;
- border-top: 0;
-
- @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
- overflow-x: auto;
- }
-}
-
-.user-calendar-activities {
- direction: ltr;
-
- .str-truncated {
- max-width: 70%;
- }
-}
-
-.user-calendar {
- text-align: center;
- min-height: 172px;
-
- .calendar {
- display: inline-block;
- }
-}
-
.user-contrib-cell {
&:hover {
cursor: pointer;
@@ -42,18 +15,6 @@
}
}
-.user-contrib-text {
- font-size: 12px;
- fill: $calendar-user-contrib-text;
-}
-
-.calendar-hint {
- font-size: 12px;
- direction: ltr;
- margin-top: -23px;
- float: right;
-}
-
.pika-single.gitlab-theme {
.pika-label {
color: $gl-text-color-secondary;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 036cec15935..ad0036df607 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -134,16 +134,6 @@
.avatar-container {
@include gl-font-weight-normal;
flex: none;
- box-shadow: $avatar-box-shadow;
-
- &.rect-avatar {
- @include gl-border-none;
-
- .avatar.s32 {
- border-radius: $border-radius-default;
- box-shadow: $avatar-box-shadow;
- }
- }
}
}
@@ -214,7 +204,7 @@
//
.page-with-contextual-sidebar {
- transition: padding-left $sidebar-transition-duration;
+ transition: padding-left $gl-transition-duration-medium;
@include media-breakpoint-up(md) {
padding-left: $contextual-sidebar-collapsed-width;
@@ -243,7 +233,7 @@
@include gl-fixed;
@include gl-bottom-0;
@include gl-left-0;
- transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium, left $gl-transition-duration-medium;
z-index: 600;
width: $contextual-sidebar-width;
top: $header-height;
diff --git a/app/assets/stylesheets/framework/contextual_sidebar_header.scss b/app/assets/stylesheets/framework/contextual_sidebar_header.scss
index 7159dadf7cc..a3d752dcc3d 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar_header.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar_header.scss
@@ -5,7 +5,7 @@
> a,
> button {
- transition: padding $sidebar-transition-duration;
+ transition: padding $gl-transition-duration-medium;
font-weight: $gl-font-weight-bold;
display: flex;
width: 100%;
@@ -25,7 +25,7 @@
}
.avatar-container {
- flex: 0 0 40px;
+ flex: 0 0 32px;
background-color: $white;
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 43e14a63f9d..d91524d99e6 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -125,10 +125,6 @@
padding-right: 25px;
}
- .fa {
- color: $gray-darkest;
- }
-
&:hover {
border-color: $gray-darkest;
}
@@ -148,10 +144,6 @@
text-overflow: ellipsis;
width: 160px;
- .fa {
- position: absolute;
- }
-
.gl-spinner {
position: absolute;
top: 9px;
@@ -387,10 +379,6 @@
margin: 0;
text-align: left;
text-overflow: inherit;
-
- &.btn .fa:not(:last-child) {
- margin-left: 5px;
- }
}
> button.dropdown-epic-button {
@@ -477,6 +465,12 @@
height: 2 * $gl-padding;
margin: 0 10px 0 0;
}
+
+ .sidebar-participant {
+ .merge-icon {
+ top: calc(50% + 5px);
+ }
+ }
}
.dropdown-menu-user-full-name {
@@ -645,14 +639,12 @@
border-color: $blue-300;
box-shadow: 0 0 4px $dropdown-input-focus-shadow;
- ~ .fa,
~ .dropdown-input-clear {
color: $gray-700;
}
}
&:hover {
- ~ .fa,
~ .dropdown-input-clear {
color: $gray-700;
}
@@ -710,14 +702,6 @@
z-index: 9;
background-color: $dropdown-loading-bg;
font-size: 28px;
-
- .fa {
- position: absolute;
- top: 50%;
- left: 50%;
- margin-top: -14px;
- margin-left: -14px;
- }
}
.dropdown-label-box {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index f322c6c8929..b980d7fdaa7 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -202,6 +202,10 @@
float: none;
border-left: 1px solid $gray-100;
+ .file-line-num {
+ @include gl-min-w-9;
+ }
+
i {
float: none;
margin-right: 0;
@@ -478,6 +482,11 @@ span.idiff {
background-color: transparent;
border: transparent;
}
+
+ .gl-dark & {
+ background: transparent;
+ filter: invert(1) hue-rotate(180deg);
+ }
}
.code-navigation-line:hover {
@@ -575,3 +584,11 @@ span.idiff {
@include gl-text-center;
}
}
+
+// *:nth-of-type(1n+30) - makes sure we do not render elements 30+ right away when
+// viewing a file. Even though the HTML is injected in the DOM, as long as we do
+// not render those elements, the browser doesn't need to spend resources
+// calculating and repainting what's hidden.
+.file-holder [data-loading] .file-content *:nth-of-type(1n+30) {
+ @include gl-display-none;
+}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 1c43212f501..2b76e70fa17 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -31,7 +31,8 @@
width: 100%;
padding-left: 10px;
padding-right: 10px;
- white-space: pre;
+ white-space: break-spaces;
+ word-break: break-word;
&:empty::before {
content: '\200b';
@@ -48,8 +49,9 @@
a {
font-family: $monospace-font;
- display: block;
white-space: nowrap;
+ @include gl-display-flex;
+ @include gl-justify-content-end;
i,
svg {
@@ -90,3 +92,55 @@ td.line-numbers {
cursor: pointer;
text-decoration: underline wavy $red-500;
}
+
+.blob-viewer {
+ .line-numbers {
+ // for server-side-rendering
+ .line-links {
+ @include gl-display-flex;
+
+
+ &:first-child {
+ margin-top: 10px;
+ }
+
+ &:last-child {
+ margin-bottom: 10px;
+ }
+ }
+
+ // for client
+ &.line-links {
+ min-width: 6rem;
+ border-bottom-left-radius: 0;
+
+ + pre {
+ margin-left: 6rem;
+ }
+ }
+ }
+
+ .line-links {
+ &:hover a::before,
+ &:focus-within a::before {
+ @include gl-visibility-visible;
+ }
+ }
+
+ .file-line-num {
+ min-width: 4.5rem;
+ @include gl-justify-content-end;
+ @include gl-flex-grow-1;
+ @include gl-pr-3;
+ }
+
+ .file-line-blame {
+ @include gl-ml-3;
+ }
+
+ .file-line-num,
+ .file-line-blame {
+ @include gl-align-items-center;
+ @include gl-display-flex;
+ }
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 2cea3b96ff7..47856f1a0d3 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -478,7 +478,7 @@
}
@mixin side-panel-toggle {
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
height: $toggle-sidebar-height;
padding: 0 $gl-padding;
background-color: $gray-light;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 74aed1bd984..92ca8654287 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -135,27 +135,8 @@
}
@include media-breakpoint-down(md) {
- $controls-margin: $btn-margin-5 - 2px;
flex: 0 0 100%;
margin-top: $gl-padding-8;
-
- .controls-item,
- .controls-item-full,
- .controls-item:last-child {
- flex: 1 1 35%;
- display: block;
- width: 100%;
- margin: $controls-margin;
-
- .btn,
- .dropdown {
- margin: 0;
- }
- }
-
- .controls-item-full {
- flex: 1 1 100%;
- }
}
@include media-breakpoint-down(sm) {
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 13201d43fd0..ae0f18753ad 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -13,7 +13,7 @@
}
.page-initialised .content-wrapper {
- transition: padding $sidebar-transition-duration;
+ transition: padding $gl-transition-duration-medium;
}
.right-sidebar-collapsed {
@@ -109,7 +109,7 @@
@include maintain-sidebar-dimensions;
width: 0;
padding: 0;
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
&.right-sidebar-expanded {
@include maintain-sidebar-dimensions;
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
index 953c42219a9..f9e95d16f63 100644
--- a/app/assets/stylesheets/framework/sortable.scss
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -1,6 +1,4 @@
.sortable-container {
- background-color: $gray-light;
-
.flex-list {
padding: 5px;
margin-bottom: 0;
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 086b83b13e0..43effbdd7d7 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -35,6 +35,10 @@
background-color: $white;
}
+ &:not(.note-form).internal-note {
+ background-color: $orange-50;
+ }
+
.timeline-entry-inner {
position: relative;
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index b5e0dcd875a..031f5dc45ca 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -435,6 +435,35 @@
}
}
+ li.inapplicable {
+ // for a single line list item, no paragraph (tight list)
+ > s {
+ color: $gl-text-color-disabled;
+ }
+
+ // additional blocks, other than paragraphs
+ > div {
+ text-decoration: line-through;
+ color: $gl-text-color-disabled;
+ }
+
+ // because of the embedded checkbox, putting line-through on the entire
+ // paragraph causes the space between the checkbox and the text to have the
+ // line-through. Targeting just the `s` fixes this
+ > p:first-of-type > s {
+ color: $gl-text-color-disabled;
+ }
+
+ > p:not(:first-of-type) {
+ text-decoration: line-through;
+ color: $gl-text-color-disabled;
+ }
+
+ .drag-icon {
+ color: $gl-text-color;
+ }
+ }
+
a.with-attachment-icon,
a[href*='/uploads/'],
a[href*='storage.googleapis.com/google-code-attachments/'] {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1e921b4234e..e9ad930ef2b 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -5,7 +5,6 @@ $grid-size: 8px;
$gutter-collapsed-width: 62px;
$gutter-width: 290px;
$gutter-inner-width: 250px;
-$sidebar-transition-duration: 0.3s;
$sidebar-breakpoint: 1024px;
$default-transition-duration: 0.15s;
$contextual-sidebar-width: 256px;
@@ -454,7 +453,6 @@ $default-icon-size: 16px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: $grid-size;
$btn-sm-side-margin: 7px;
-$btn-margin-5: 5px;
$count-arrow-border: #dce0e5;
$general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
@@ -880,8 +878,6 @@ $image-comment-cursor-top-offset: 12;
Security & Compliance Carousel
*/
$security-and-compliance-carousel-image-carousel-width: 1000px;
-$security-and-compliance-carousel-image-discover-button-width: 45%;
-$security-and-compliance-carousel-image-discover-buttons-max-width: 280px;
$security-and-compliance-carousel-image-discover-footer-max-width: 500px;
$security-and-compliance-carousel-image-discover-text-carousel-max-width: 650px;
$security-and-compliance-carousel-image-discover-text-carousel-caption-height: 100%;
diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss
index 1a536b97142..e3ac615234c 100644
--- a/app/assets/stylesheets/framework/vue_transitions.scss
+++ b/app/assets/stylesheets/framework/vue_transitions.scss
@@ -2,7 +2,7 @@
.fade-leave-active,
.fade-in-enter-active,
.fade-out-leave-active {
- transition: opacity $sidebar-transition-duration $general-hover-transition-curve;
+ transition: opacity $gl-transition-duration-medium $general-hover-transition-curve;
}
.fade-enter,
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index fcbd05141b9..96df8487c0e 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -98,32 +98,33 @@
}
}
-@mixin line-number-link($color) {
- min-width: $gl-spacing-scale-9;
+@mixin line-link($color, $icon) {
&::before {
- @include gl-display-none;
+ @include gl-visibility-hidden;
@include gl-align-self-center;
- @include gl-mt-2;
- @include gl-mr-2;
- @include gl-w-4;
- @include gl-h-4;
- @include gl-absolute;
- @include gl-left-3;
- background-color: $color;
- mask-image: asset_url('icons-stacked.svg#link');
+ @include gl-mr-1;
+ @include gl-w-5;
+ @include gl-h-5;
+ background-color: rgba($color, 0.3);
+ mask-image: asset_url('icons-stacked.svg##{$icon}');
mask-repeat: no-repeat;
mask-size: cover;
mask-position: center;
content: '';
}
- &:hover::before {
- @include gl-display-inline-block;
+ &:hover {
+ &::before {
+ background-color: rgba($color, 0.6);
+ }
}
+}
- &:focus::before {
- @include gl-display-inline-block;
+@mixin line-hover-bg($color: $white-normal) {
+ &:hover,
+ &:focus-within {
+ background-color: darken($color, 10);
}
}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 709e7f5ae18..5e6e10e44be 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -127,7 +127,15 @@ $dark-il: #de935f;
.code.dark {
// Line numbers
.file-line-num {
- @include line-number-link($dark-line-num-color);
+ @include line-link($white, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($white, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($dark-main-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 0ed9c209417..19c3d6926e7 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -120,7 +120,15 @@ $monokai-gh: #75715e;
// Line numbers
.file-line-num {
- @include line-number-link($monokai-line-num-color);
+ @include line-link($white, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($white, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($monokai-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index 868e466b1f8..4c716d20ddf 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -25,7 +25,15 @@
// Line numbers
.file-line-num {
- @include line-number-link($black-transparent);
+ @include line-link($black, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($black, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg;
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index 6260339a48d..70086be1606 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -123,7 +123,15 @@ $solarized-dark-il: #2aa198;
// Line numbers
.file-line-num {
- @include line-number-link($solarized-dark-line-color);
+ @include line-link($white, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($white, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($solarized-dark-pre-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index e6f098f4cdf..8d223d1fdb1 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -109,7 +109,15 @@ $solarized-light-il: #2aa198;
@include hljs-override('title.class_.inherited__', $solarized-light-no);
// Line numbers
.file-line-num {
- @include line-number-link($solarized-light-line-color);
+ @include line-link($black, 'link');
+ }
+
+ .file-line-blame {
+ @include line-link($black, 'git');
+ }
+
+ .line-links {
+ @include line-hover-bg($solarized-light-pre-bg);
}
.line-numbers,
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 770a90bbc57..9761e3961dd 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -95,7 +95,15 @@ $white-gc-bg: #eaf2f5;
// Line numbers
.file-line-num {
- @include line-number-link($black-transparent);
+ @include line-link($black, 'link');
+}
+
+.file-line-blame {
+ @include line-link($black, 'git');
+}
+
+.line-links {
+ @include line-hover-bg;
}
.line-numbers,
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 81d35b8bc7b..197073412e8 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -35,7 +35,7 @@
.boards-app {
@include media-breakpoint-up(sm) {
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
width: 100%;
&.is-compact {
@@ -349,7 +349,7 @@
.right-sidebar.right-sidebar-expanded {
&.boards-sidebar-slide-enter-active,
&.boards-sidebar-slide-leave-active {
- transition: width $sidebar-transition-duration, padding $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium, padding $gl-transition-duration-medium;
}
&.boards-sidebar-slide-enter,
diff --git a/app/assets/stylesheets/page_bundles/escalation_policies.scss b/app/assets/stylesheets/page_bundles/escalation_policies.scss
index 6f3873fea0c..84c62ba93dd 100644
--- a/app/assets/stylesheets/page_bundles/escalation_policies.scss
+++ b/app/assets/stylesheets/page_bundles/escalation_policies.scss
@@ -15,17 +15,11 @@
$stroke-size: 1px;
.right-arrow {
- @include gl-relative;
height: $stroke-size;
- background-color: var(--gray-900, $gray-900);
- min-width: $gl-spacing-scale-7;
&-head {
- @include gl-absolute;
- top: -2*$stroke-size;
- left: calc(100% - #{5*$stroke-size});
- @include gl-p-1;
- @include gl-border-solid;
+ top: -2 * $stroke-size;
+ left: calc(100% - #{5 * $stroke-size});
border-width: 0 $stroke-size $stroke-size 0;
border-color: var(--gray-900, $gray-900);
transform: rotate(-45deg);
@@ -41,14 +35,10 @@ $stroke-size: 1px;
.rule-condition {
@media (min-width: $breakpoint-lg) {
flex-basis: 25%;
- flex-shrink: 0;
+ @include gl-flex-shrink-0;
}
@media (max-width: $breakpoint-lg) {
@include gl-w-full;
}
}
-
-.rule-action {
- min-width: 0;
-}
diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss
index 38dd07f617c..71dbb855103 100644
--- a/app/assets/stylesheets/page_bundles/group.scss
+++ b/app/assets/stylesheets/page_bundles/group.scss
@@ -72,36 +72,43 @@
}
}
-.group-nav-container .nav-controls {
- .group-filter-form {
- flex: 1 1 auto;
- margin-right: $gl-padding-8;
- }
-
- .dropdown-menu-right {
- margin-top: 0;
- }
-
- @include media-breakpoint-down(sm) {
- .dropdown,
- .dropdown .dropdown-toggle,
- .btn-success {
- display: block;
+.group-nav-container {
+ .nav-controls {
+ .group-filter-form {
+ flex: 1 1 auto;
+ margin-right: $gl-padding-8;
}
- .group-filter-form,
- .dropdown {
- margin-bottom: 10px;
- margin-right: 0;
+ .dropdown-menu-right {
+ margin-top: 0;
}
- &,
- .group-filter-form,
- .group-filter-form-field,
- .dropdown,
- .dropdown .dropdown-toggle,
- .btn-success {
- width: 100%;
+ @include media-breakpoint-down(sm) {
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-success {
+ display: block;
+ }
+
+ .group-filter-form,
+ .dropdown {
+ margin-bottom: 10px;
+ margin-right: 0;
+ }
+
+ &,
+ .group-filter-form,
+ .group-filter-form-field,
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-success {
+ width: 100%;
+ }
}
}
+
+ // Remove this selector once https://gitlab.com/gitlab-org/gitlab/-/issues/370050 is addressed.
+ .scrolling-tabs-container {
+ width: 100%;
+ }
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 1b27e51e793..b7a75884425 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -416,8 +416,6 @@ $tabs-holder-z-index: 250;
.label-branch {
@include gl-font-monospace;
font-size: 95%;
- color: var(--gl-text-color, $gl-text-color);
- font-weight: normal;
overflow: hidden;
word-break: break-all;
}
@@ -477,8 +475,7 @@ $tabs-holder-z-index: 250;
margin: 0 0 0 10px;
}
- .bold,
- .gl-font-weight-bold {
+ .bold {
font-weight: $gl-font-weight-bold;
color: var(--gray-600, $gray-600);
margin-left: 10px;
@@ -494,8 +491,7 @@ $tabs-holder-z-index: 250;
}
.spacing,
- .bold,
- .gl-font-weight-bold {
+ .bold {
vertical-align: middle;
}
@@ -602,6 +598,12 @@ $tabs-holder-z-index: 250;
padding: $gl-padding;
}
+.mr-widget-body-ready-merge {
+ @include media-breakpoint-down(sm) {
+ @include gl-p-3;
+ }
+}
+
.mr-widget-border-top {
border-top: 1px solid var(--border-color, $border-color);
}
@@ -820,3 +822,21 @@ $tabs-holder-z-index: 250;
height: 180px;
}
}
+
+.mr-widget-merge-details {
+ li:not(:last-child) {
+ @include gl-mb-3;
+ }
+}
+
+.mr-ready-merge-related-links,
+.mr-widget-merge-details {
+ a {
+ @include gl-text-decoration-underline;
+
+ &:hover,
+ &:focus {
+ @include gl-text-decoration-none;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index e6afc70acbb..98e9e2b3c27 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -225,12 +225,20 @@
}
}
-.test-reports-table {
- .build-log {
- @include build-log();
+.progress-bar.bg-primary {
+ background-color: var(--blue-500, $blue-500) !important;
+}
+
+.ci-job-component {
+ .job-failed {
+ background-color: var(--red-50, $red-50);
}
}
-.progress-bar.bg-primary {
- background-color: var(--blue-500, $blue-500) !important;
+.gl-dark {
+ .ci-job-component {
+ .job-failed {
+ background-color: var(--gray-200, $gray-200);
+ }
+ }
}
diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss
new file mode 100644
index 00000000000..59b8823c113
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/profile.scss
@@ -0,0 +1,212 @@
+@import 'mixins_and_variables_and_functions';
+
+.calendar-block {
+ padding-left: 0;
+ padding-right: 0;
+ border-top: 0;
+
+ @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
+ overflow-x: auto;
+ }
+}
+
+.calendar-hint {
+ font-size: 12px;
+ direction: ltr;
+ margin-top: -23px;
+ float: right;
+}
+
+.cover-block {
+ text-align: center;
+ background: var(--gray-50, $gray-light);
+ padding-top: 44px;
+ position: relative;
+
+ .avatar-holder {
+ .avatar,
+ .identicon {
+ margin: 0 auto;
+ float: none;
+ }
+
+ .identicon {
+ border-radius: 50%;
+ }
+ }
+
+ .cover-title {
+ color: var(--gl-text-color, $gl-text-color);
+ font-size: 23px;
+
+ h1 {
+ color: var(--gl-text-color, $gl-text-color);
+ margin-bottom: 6px;
+ font-size: 23px;
+ }
+
+ .visibility-icon {
+ display: inline-block;
+ margin-left: 5px;
+ font-size: 18px;
+ color: color('gray');
+ }
+
+ p {
+ padding: 0 $gl-padding;
+ color: var(--gl-text-color, $gl-text-color);
+ }
+ }
+
+ .cover-controls {
+ @include media-breakpoint-up(sm) {
+ position: absolute;
+ top: 1rem;
+ right: 1.25rem;
+ }
+
+ &.left {
+ @include media-breakpoint-up(sm) {
+ left: 1.25rem;
+ right: auto;
+ }
+ }
+ }
+
+ &.user-cover-block {
+ padding: 24px 0 0;
+
+ .nav-links {
+ width: 100%;
+ float: none;
+
+ &.scrolling-tabs {
+ float: none;
+ }
+ }
+
+ li:first-child {
+ margin-left: auto;
+ }
+
+ li:last-child {
+ margin-right: auto;
+ }
+ }
+}
+
+// Middle dot divider between each element in a list of items.
+.middle-dot-divider {
+ @include middle-dot-divider;
+}
+
+.middle-dot-divider-sm {
+ @include media-breakpoint-up(sm) {
+ @include middle-dot-divider;
+ }
+}
+
+.profile-user-bio {
+ // Limits the width of the user bio for readability.
+ max-width: 600px;
+ margin: 10px auto;
+}
+
+.user-calendar {
+ text-align: center;
+ min-height: 172px;
+
+ .calendar {
+ display: inline-block;
+ }
+}
+
+.user-calendar-activities {
+ direction: ltr;
+
+ .str-truncated {
+ max-width: 70%;
+ }
+}
+
+.user-contrib-text {
+ font-size: 12px;
+ fill: $calendar-user-contrib-text;
+}
+
+.user-profile {
+ .profile-header {
+ margin: 0 $gl-padding;
+
+ &.with-no-profile-tabs {
+ margin-bottom: $gl-padding-24;
+ }
+
+ .avatar-holder {
+ width: 90px;
+ margin: 0 auto 10px;
+ }
+ }
+
+ .user-profile-nav {
+ font-size: 0;
+ }
+
+ .fade-right {
+ right: 0;
+ }
+
+ .fade-left {
+ left: 0;
+ }
+
+ .activities-block {
+ .event-item {
+ padding-left: 40px;
+ }
+
+ .gl-label-scoped {
+ --label-inset-border: inset 0 0 0 1px currentColor;
+ }
+
+ @include media-breakpoint-up(lg) {
+ margin-right: 5px;
+ }
+ }
+
+ .projects-block {
+ @include media-breakpoint-up(lg) {
+ margin-left: 5px;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .cover-block {
+ padding-top: 20px;
+ }
+
+ .user-profile-nav {
+ a {
+ margin-right: 0;
+ }
+ }
+
+ .activities-block {
+ .event-item {
+ padding-left: 0;
+ }
+ }
+ }
+}
+
+.linkedin-icon {
+ color: $linkedin;
+}
+
+.skype-icon {
+ color: $skype;
+}
+
+.twitter-icon {
+ color: $twitter;
+}
diff --git a/app/assets/stylesheets/page_bundles/runner_details.scss b/app/assets/stylesheets/page_bundles/runner_details.scss
new file mode 100644
index 00000000000..6e5580a18e4
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/runner_details.scss
@@ -0,0 +1,3 @@
+.runner-details-grid-template {
+ grid-template-columns: auto 1fr;
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 51f964a4b70..69797c6b303 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -132,7 +132,7 @@
// stylelint-disable-next-line length-zero-no-unit
bottom: var(--review-bar-height, 0px);
right: 0;
- transition: width $sidebar-transition-duration;
+ transition: width $gl-transition-duration-medium;
background-color: $white;
z-index: 200;
overflow: hidden;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index c0a283ec643..a151c28fe93 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -318,7 +318,7 @@ ul.related-merge-requests > li gl-emoji {
.issuable-header-slide-enter-active,
.issuable-header-slide-leave-active {
- @include gl-transition-slow;
+ @include gl-transition-medium;
}
.issuable-header-slide-enter,
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 7f0bdadd2bc..1beb9f05b6c 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -41,6 +41,20 @@
font-size: 13px;
}
+ .borderless {
+ .login-box,
+ .omniauth-container {
+ box-shadow: none;
+ }
+
+ .g-recaptcha {
+ > div {
+ margin-left: auto;
+ margin-right: auto;
+ }
+ }
+ }
+
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 96fe6caeea2..b016d0a1068 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -342,10 +342,10 @@ $comparison-empty-state-height: 62px;
.mr-compare {
.diff-file .file-title-flex-parent {
- top: calc(#{$header-height} + #{$mr-tabs-height} + 36px);
+ top: calc(#{$header-height} + #{$mr-tabs-height});
.with-performance-bar & {
- top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height} + 36px);
+ top: calc(#{$performance-bar-height} + #{$header-height} + #{$mr-tabs-height});
}
}
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 645f145328b..9692becef4f 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -121,15 +121,6 @@
border-radius: $label-border-radius;
padding-top: $gl-vert-padding;
padding-bottom: $gl-vert-padding;
-
- .icon svg {
- position: relative;
- top: 2px;
- margin-right: $btn-margin-5;
- width: $gl-font-size;
- height: $gl-font-size;
- fill: $orange-600;
- }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 4d0cf30a3b2..db07f16dfd0 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -4,7 +4,7 @@ $system-note-svg-size: 16px;
@mixin vertical-line($left) {
&::before {
content: '';
- border-left: 2px solid $gray-50;
+ border-left: 2px solid $gray-10;
position: absolute;
top: 0;
bottom: 0;
@@ -29,7 +29,7 @@ $system-note-svg-size: 16px;
.issuable-discussion {
.main-notes-list {
- @include vertical-line(36px);
+ @include vertical-line(35px);
}
}
@@ -300,17 +300,17 @@ $system-note-svg-size: 16px;
.timeline-icon {
display: flex;
align-items: center;
- background-color: $white;
+ background-color: $gray-10;
width: $system-note-icon-size;
height: $system-note-icon-size;
- border: 1px solid $border-color;
+ border: 1px solid $gray-10;
border-radius: $system-note-icon-size;
margin: -6px 20px 0 0;
svg {
width: $system-note-svg-size;
height: $system-note-svg-size;
- fill: $gray-darkest;
+ fill: $gray-400;
display: block;
margin: 0 auto;
}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 812cc6ab4e6..951e31ef768 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -29,23 +29,6 @@
}
}
-// Middle dot divider between each element in a list of items.
-.middle-dot-divider {
- @include middle-dot-divider;
-}
-
-.middle-dot-divider-sm {
- @include media-breakpoint-up(sm) {
- @include middle-dot-divider;
- }
-}
-
-.profile-user-bio {
- // Limits the width of the user bio for readability.
- max-width: 600px;
- margin: 10px auto;
-}
-
.user-avatar-button {
.file-name {
display: inline-block;
@@ -156,71 +139,6 @@
}
}
-.user-profile {
- .profile-header {
- margin: 0 $gl-padding;
-
- &.with-no-profile-tabs {
- margin-bottom: $gl-padding-24;
- }
-
- .avatar-holder {
- width: 90px;
- margin: 0 auto 10px;
- }
- }
-
- .user-profile-nav {
- font-size: 0;
- }
-
- .fade-right {
- right: 0;
- }
-
- .fade-left {
- left: 0;
- }
-
- .activities-block {
- .event-item {
- padding-left: 40px;
- }
-
- .gl-label-scoped {
- --label-inset-border: inset 0 0 0 1px currentColor;
- }
-
- @include media-breakpoint-up(lg) {
- margin-right: 5px;
- }
- }
-
- .projects-block {
- @include media-breakpoint-up(lg) {
- margin-left: 5px;
- }
- }
-
- @include media-breakpoint-down(xs) {
- .cover-block {
- padding-top: 20px;
- }
-
- .user-profile-nav {
- a {
- margin-right: 0;
- }
- }
-
- .activities-block {
- .event-item {
- padding-left: 0;
- }
- }
- }
-}
-
table.u2f-registrations {
th:not(:last-child),
td:not(:last-child) {
@@ -366,15 +284,3 @@ table.u2f-registrations {
.gitlab-slack-slack-logo {
transform: scale(200%); // Slack logo SVG is scaled down 50% and has empty space around it
}
-
-.skype-icon {
- color: $skype;
-}
-
-.linkedin-icon {
- color: $linkedin;
-}
-
-.twitter-icon {
- color: $twitter;
-}
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index f1865a7dc40..6c909b8d9fa 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -82,19 +82,17 @@ input[type='checkbox']:hover {
min-width: $search-input-field-x-min-width;
}
- &.is-active {
- &.is-searching {
- .in-search-scope-help {
- position: absolute;
- top: $gl-spacing-scale-2;
- right: 2.125rem;
- z-index: 2;
- }
+ &.is-searching {
+ .in-search-scope-help {
+ position: absolute;
+ top: $gl-spacing-scale-2;
+ right: 2.125rem;
+ z-index: 2;
}
}
- &.is-not-searching {
- .in-search-scope-help {
+ &.is-not-focused {
+ .gl-search-box-by-type-clear {
display: none;
}
}
@@ -104,28 +102,11 @@ input[type='checkbox']:hover {
box-shadow: none;
border-color: transparent;
}
-
- &.is-active {
- .keyboard-shortcut-helper {
- display: none;
- }
- }
-
- &.is-not-active {
- .btn.gl-clear-icon-button,
- .in-search-scope-help {
- display: none;
- }
- }
}
.header-search-dropdown-menu {
max-height: $dropdown-max-height;
- top: $header-height;
-}
-
-.header-search-dropdown-content {
- max-height: $dropdown-max-height;
+ top: 100%;
}
.search {
diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss
deleted file mode 100644
index 7d74070b4f2..00000000000
--- a/app/assets/stylesheets/pages/settings_ci_cd.scss
+++ /dev/null
@@ -1,18 +0,0 @@
-.triggers-container {
- .label-container {
- display: inline-block;
- margin-left: 10px;
- }
-}
-
-.trigger-description {
- max-width: 100px;
-}
-
-.trigger-actions {
- white-space: nowrap;
-}
-
-.auto-devops-card {
- margin-bottom: $gl-vert-padding;
-}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index 801c9ea828f..ffe4d5dde9d 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -1043,7 +1043,7 @@ kbd {
text-align: left;
}
.context-header .avatar-container {
- flex: 0 0 40px;
+ flex: 0 0 32px;
background-color: #333;
}
.context-header .sidebar-context-title {
@@ -1376,18 +1376,6 @@ kbd {
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
margin-bottom: 60px;
@@ -1400,18 +1388,6 @@ kbd {
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items
> li.active
@@ -1628,7 +1604,6 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid rgba(0, 0, 0, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -1649,7 +1624,7 @@ svg.s16 {
padding: 0;
background: #222;
overflow: hidden;
- border-color: rgba(255, 255, 255, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.avatar.avatar-tile {
border-radius: 0;
@@ -1676,7 +1651,7 @@ svg.s16 {
background-color: #232150;
}
.identicon.bg3 {
- background-color: #f1f1ff;
+ background-color: #1a1a40;
}
.identicon.bg4 {
background-color: #033464;
@@ -1714,9 +1689,15 @@ svg.s16 {
.rect-avatar.s16 {
border-radius: 2px;
}
+.rect-avatar.s16 .avatar {
+ border-radius: 2px;
+}
.rect-avatar.s32 {
border-radius: 4px;
}
+.rect-avatar.s32 .avatar {
+ border-radius: 4px;
+}
:root {
color-scheme: dark;
}
@@ -1817,6 +1798,10 @@ body.gl-dark {
background-color: #262626;
border-right: 1px solid #303030;
}
+.avatar-container,
+.avatar {
+ background: rgba(255, 255, 255, 0.04);
+}
.nav-sidebar li a {
color: var(--gray-600);
}
@@ -1907,7 +1892,7 @@ body.gl-dark .header-search input::placeholder {
body.gl-dark .header-search input:active::placeholder {
color: #868686;
}
-body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper {
+body.gl-dark .header-search .keyboard-shortcut-helper {
color: #fafafa;
background-color: rgba(250, 250, 250, 0.2);
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 43ca5a512d5..00ca98bfd27 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -1022,7 +1022,7 @@ kbd {
text-align: left;
}
.context-header .avatar-container {
- flex: 0 0 40px;
+ flex: 0 0 32px;
background-color: #fff;
}
.context-header .sidebar-context-title {
@@ -1355,18 +1355,6 @@ kbd {
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.nav-sidebar-inner-scroll
- > div.context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
margin-bottom: 60px;
@@ -1379,18 +1367,6 @@ kbd {
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
-}
-.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
- border-style: none;
-}
-.sidebar-top-level-items
- .context-header
- a
- .avatar-container.rect-avatar
- .avatar.s32 {
- border-radius: 4px;
- box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items
> li.active
@@ -1607,7 +1583,6 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid rgba(0, 0, 0, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -1628,7 +1603,7 @@ svg.s16 {
padding: 0;
background: #fdfdfd;
overflow: hidden;
- border-color: rgba(0, 0, 0, 0.1);
+ box-shadow: inset 0 0 0 1px rgba(31, 31, 31, 0.1);
}
.avatar.avatar-tile {
border-radius: 0;
@@ -1693,9 +1668,15 @@ svg.s16 {
.rect-avatar.s16 {
border-radius: 2px;
}
+.rect-avatar.s16 .avatar {
+ border-radius: 2px;
+}
.rect-avatar.s32 {
border-radius: 4px;
}
+.rect-avatar.s32 .avatar {
+ border-radius: 4px;
+}
.tab-width-8 {
tab-size: 8;
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 3090edfb123..c0e2d8d44d4 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -11,9 +11,6 @@ html {
font-family: sans-serif;
line-height: 1.15;
}
-header {
- display: block;
-}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -31,8 +28,7 @@ hr {
height: 0;
overflow: visible;
}
-h1,
-h3 {
+h1 {
margin-top: 0;
margin-bottom: 0.25rem;
}
@@ -53,10 +49,6 @@ img {
vertical-align: middle;
border-style: none;
}
-svg {
- overflow: hidden;
- vertical-align: middle;
-}
label {
display: inline-block;
margin-bottom: 0.5rem;
@@ -86,8 +78,7 @@ fieldset {
[hidden] {
display: none !important;
}
-h1,
-h3 {
+h1 {
margin-bottom: 0.25rem;
font-weight: 600;
line-height: 1.2;
@@ -96,9 +87,6 @@ h3 {
h1 {
font-size: 2.1875rem;
}
-h3 {
- font-size: 1.53125rem;
-}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -132,13 +120,6 @@ hr {
max-width: 1140px;
}
}
-.row {
- display: flex;
- flex-wrap: wrap;
- margin-right: -15px;
- margin-left: -15px;
-}
-.col-md-6,
.col-sm-12,
.col {
position: relative;
@@ -151,29 +132,11 @@ hr {
flex-grow: 1;
max-width: 100%;
}
-.order-1 {
- order: 1;
-}
-.order-12 {
- order: 12;
-}
@media (min-width: 576px) {
.col-sm-12 {
flex: 0 0 100%;
max-width: 100%;
}
- .order-sm-1 {
- order: 1;
- }
- .order-sm-12 {
- order: 12;
- }
-}
-@media (min-width: 768px) {
- .col-md-6 {
- flex: 0 0 50%;
- max-width: 50%;
- }
}
.form-control {
display: block;
@@ -241,39 +204,18 @@ hr {
fieldset:disabled a.btn {
pointer-events: none;
}
-.navbar {
- position: relative;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- padding: 0.25rem 0.5rem;
-}
-.navbar .container {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
-}
-.fixed-top {
- position: fixed;
- top: 0;
- right: 0;
- left: 0;
- z-index: 1030;
-}
.mt-3 {
margin-top: 1rem !important;
}
.mb-3 {
margin-bottom: 1rem !important;
}
+.text-nowrap {
+ white-space: nowrap !important;
+}
.text-center {
text-align: center !important;
}
-.font-weight-normal {
- font-weight: 400 !important;
-}
.gl-form-input,
.gl-form-input.form-control {
background-color: #fff;
@@ -373,8 +315,7 @@ body {
[type="submit"] {
cursor: pointer;
}
-h1,
-h3 {
+h1 {
margin-top: 20px;
margin-bottom: 10px;
}
@@ -384,9 +325,6 @@ a {
hr {
overflow: hidden;
}
-svg {
- vertical-align: baseline;
-}
.form-control {
font-size: 0.875rem;
}
@@ -442,13 +380,6 @@ body.navless {
border-color: #e3e3e3;
color: #303030;
}
-.btn svg {
- height: 15px;
- width: 15px;
-}
-.btn svg:not(:last-child) {
- margin-right: 5px;
-}
.light {
color: #303030;
}
@@ -504,26 +435,6 @@ label.label-bold {
.gl-show-field-errors .gl-field-hint {
color: #303030;
}
-.navbar-empty {
- justify-content: center;
- height: var(--header-height, 48px);
- background: #fff;
- border-bottom: 1px solid #dbdbdb;
-}
-.navbar-empty .tanuki-logo,
-.navbar-empty .brand-header-logo {
- max-height: 100%;
-}
-.tanuki-logo .tanuki {
- fill: #e24329;
-}
-.tanuki-logo .left-cheek,
-.tanuki-logo .right-cheek {
- fill: #fc6d26;
-}
-.tanuki-logo .chin {
- fill: #fca326;
-}
input::-moz-placeholder {
color: #868686;
opacity: 1;
@@ -534,9 +445,6 @@ input::-ms-input-placeholder {
input:-ms-input-placeholder {
color: #868686;
}
-svg {
- fill: currentColor;
-}
.login-page .container {
max-width: 960px;
}
@@ -569,6 +477,14 @@ svg {
.login-page p {
font-size: 13px;
}
+.login-page .borderless .login-box,
+.login-page .borderless .omniauth-container {
+ box-shadow: none;
+}
+.login-page .borderless .g-recaptcha > div {
+ margin-left: auto;
+ margin-right: auto;
+}
.login-page .login-box,
.login-page .omniauth-container {
box-shadow: 0 0 0 1px #dbdbdb;
@@ -732,61 +648,76 @@ svg {
}
}
-.gl-border-solid {
- border-style: solid;
-}
-.gl-border-gray-100 {
- border-color: #dbdbdb;
-}
-.gl-border-1 {
- border-width: 1px;
-}
-.gl-rounded-base {
- border-radius: 0.25rem;
-}
.gl-text-green-600 {
color: #217645;
}
.gl-text-red-500 {
color: #dd2b0e;
}
-.gl-display-flex {
- display: flex;
-}
.gl-display-block {
display: block;
}
-.gl-align-items-center {
- align-items: center;
+.gl-w-10 {
+ width: 3.5rem;
}
-.gl-flex-wrap {
- flex-wrap: wrap;
+.gl-w-half {
+ width: 50%;
+}
+.gl-w-90p {
+ width: 90%;
}
.gl-w-full {
width: 100%;
}
+@media (max-width: 575.98px) {
+ .gl-xs-w-full {
+ width: 100%;
+ }
+}
.gl-p-4 {
padding: 0.75rem;
}
+.gl-pt-5 {
+ padding-top: 1rem;
+}
.gl-mt-2 {
margin-top: 0.25rem;
}
.gl-mt-5 {
margin-top: 1rem;
}
+.gl-mr-auto {
+ margin-right: auto;
+}
+.gl-mr-2 {
+ margin-right: 0.25rem;
+}
+.gl-mb-1 {
+ margin-bottom: 0.125rem;
+}
+.gl-mb-2 {
+ margin-bottom: 0.25rem;
+}
.gl-mb-3 {
margin-bottom: 0.5rem;
}
.gl-mb-5 {
margin-bottom: 1rem;
}
-@media (min-width: 576px) {
- .gl-sm-mt-0 {
- margin-top: 0;
- }
+.gl-ml-auto {
+ margin-left: auto;
}
-.gl-font-weight-bold {
- font-weight: 600;
+.gl-ml-2 {
+ margin-left: 0.25rem;
+}
+.gl-text-center {
+ text-align: center;
+}
+.gl-font-size-h2 {
+ font-size: 1.1875rem;
+}
+.gl-font-weight-normal {
+ font-weight: 400;
}
@import "startup/cloaking";
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index e6e736ef47c..eeb4604f32a 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -98,6 +98,8 @@ $white-light: #2b2b2b;
$white-normal: #333;
$white-dark: #444;
+$theme-indigo-50: #1a1a40;
+
$border-color: #4f4f4f;
$nav-active-bg: rgba(255, 255, 255, 0.08);
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 34bb4925249..92740aaf89e 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -48,6 +48,17 @@
border-right: 1px solid $gray-50;
}
+.gl-avatar:not(.gl-avatar-identicon),
+.avatar-container,
+.avatar {
+ background: rgba($gray-950, 0.04);
+}
+
+.gl-avatar {
+ @include gl-border-none;
+ box-shadow: inset 0 0 0 1px rgba($gray-950, $gl-avatar-border-opacity);
+}
+
.nav-sidebar {
li {
a {
@@ -149,3 +160,8 @@ body.gl-dark {
background-color: $gray-200;
}
}
+
+.timeline-entry.internal-note:not(.note-form) {
+ // soften on darkmode
+ background-color: mix($gray-50, $orange-50, 75%);
+}
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 2b6221a6c87..042e21cebd6 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -176,11 +176,9 @@
}
}
- &.is-not-active {
- .keyboard-shortcut-helper {
- color: $search-and-nav-links;
- background-color: rgba($search-and-nav-links, 0.2);
- }
+ .keyboard-shortcut-helper {
+ color: $search-and-nav-links;
+ background-color: rgba($search-and-nav-links, 0.2);
}
}