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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/javascripts/access_tokens/components/access_token_table_app.vue1
-rw-r--r--app/assets/javascripts/access_tokens/components/constants.js14
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue29
-rw-r--r--app/assets/javascripts/access_tokens/components/new_access_token_app.vue10
-rw-r--r--app/assets/javascripts/access_tokens/index.js4
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue52
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue123
-rw-r--r--app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js32
-rw-r--r--app/assets/javascripts/admin/topics/components/merge_topics.vue141
-rw-r--r--app/assets/javascripts/admin/topics/components/topic_select.vue106
-rw-r--r--app/assets/javascripts/admin/topics/index.js32
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_form.vue2
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue24
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js3
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js2
-rw-r--r--app/assets/javascripts/analytics/usage_trends/utils.js2
-rw-r--r--app/assets/javascripts/api.js1
-rw-r--r--app/assets/javascripts/api/harbor_registry.js49
-rw-r--r--app/assets/javascripts/api/integrations_api.js21
-rw-r--r--app/assets/javascripts/api/user_api.js6
-rw-r--r--app/assets/javascripts/autosave.js16
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/batch_comments/components/preview_item.vue1
-rw-r--r--app/assets/javascripts/batch_comments/components/submit_dropdown.vue87
-rw-r--r--app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js6
-rw-r--r--app/assets/javascripts/behaviors/copy_code.js1
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js8
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_math.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js30
-rw-r--r--app/assets/javascripts/blob/3d_viewer/index.js7
-rw-r--r--app/assets/javascripts/blob/3d_viewer/mesh_object.js2
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue6
-rw-r--r--app/assets/javascripts/blob/sketch/index.js41
-rw-r--r--app/assets/javascripts/blob/viewer/index.js1
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js3
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column_form.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_blocked_icon.vue27
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue19
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue47
-rw-r--r--app/assets/javascripts/boards/components/board_card_move_to_position.vue128
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue22
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue16
-rw-r--r--app/assets/javascripts/boards/components/board_new_item.vue2
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue8
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/item_count.vue4
-rw-r--r--app/assets/javascripts/boards/constants.js6
-rw-r--r--app/assets/javascripts/boards/filters/due_date_filters.js2
-rw-r--r--app/assets/javascripts/boards/graphql/board.fragment.graphql4
-rw-r--r--app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql17
-rw-r--r--app/assets/javascripts/boards/graphql/group_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/project_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql5
-rw-r--r--app/assets/javascripts/boards/stores/actions.js28
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js41
-rw-r--r--app/assets/javascripts/boards/stores/state.js1
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue120
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue18
-rw-r--r--app/assets/javascripts/ci_variable_list/components/legacy_ci_variable_modal.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js10
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql3
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql30
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql11
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql15
-rw-r--r--app/assets/javascripts/ci_variable_list/graphql/resolvers.js56
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js9
-rw-r--r--app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue66
-rw-r--r--app/assets/javascripts/clusters/agents/components/integration_status.vue98
-rw-r--r--app/assets/javascripts/clusters/agents/components/show.vue19
-rw-r--r--app/assets/javascripts/clusters/agents/components/token_table.vue7
-rw-r--r--app/assets/javascripts/clusters/agents/constants.js22
-rw-r--r--app/assets/javascripts/clusters_list/clusters_util.js23
-rw-r--r--app/assets/javascripts/clusters_list/components/agents.vue34
-rw-r--r--app/assets/javascripts/code_navigation/utils/dom_utils.js1
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue60
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue)13
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue)11
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/link.vue)47
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue (renamed from app/assets/javascripts/content_editor/components/bubble_menus/media.vue)5
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue84
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor_alert.vue17
-rw-r--r--app/assets/javascripts/content_editor/components/editor_state_observer.vue14
-rw-r--r--app/assets/javascripts/content_editor/components/loading_indicator.vue34
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_image_button.vue48
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_link_button.vue55
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue13
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue24
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/top_toolbar.vue2
-rw-r--r--app/assets/javascripts/content_editor/constants/index.js8
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js2
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js14
-rw-r--r--app/assets/javascripts/content_editor/extensions/sourcemap.js8
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js41
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js2
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js16
-rw-r--r--app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js70
-rw-r--r--app/assets/javascripts/crm/constants.js4
-rw-r--r--app/assets/javascripts/crm/organizations/bundle.js18
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations.query.graphql29
-rw-r--r--app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql11
-rw-r--r--app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue2
-rw-r--r--app/assets/javascripts/crm/organizations/components/organizations_root.vue225
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/total_time.vue2
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue5
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue4
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue4
-rw-r--r--app/assets/javascripts/deploy_keys/index.js4
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/render.js2
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue5
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue5
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue40
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue2
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue4
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue1
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue12
-rw-r--r--app/assets/javascripts/diffs/components/app.vue19
-rw-r--r--app/assets/javascripts/diffs/components/compare_dropdown_layout.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_code_quality.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_line.vue35
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue6
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue17
-rw-r--r--app/assets/javascripts/diffs/constants.js3
-rw-r--r--app/assets/javascripts/diffs/i18n.js2
-rw-r--r--app/assets/javascripts/diffs/index.js4
-rw-r--r--app/assets/javascripts/environments/components/deploy_board.vue4
-rw-r--r--app/assets/javascripts/environments/components/deployment.vue171
-rw-r--r--app/assets/javascripts/environments/components/new_environment_item.vue1
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js4
-rw-r--r--app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql13
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue1
-rw-r--r--app/assets/javascripts/filterable_list.js1
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue12
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js1
-rw-r--r--app/assets/javascripts/filtered_search/droplab/drop_down.js4
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_button.js1
-rw-r--r--app/assets/javascripts/filtered_search/droplab/hook_input.js1
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js5
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js2
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list.vue2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue9
-rw-r--r--app/assets/javascripts/google_cloud/databases/index.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/init_index.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/init_new.js11
-rw-r--r--app/assets/javascripts/google_cloud/databases/panel.vue38
-rw-r--r--app/assets/javascripts/google_tag_manager/index.js19
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql1
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql13
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql13
-rw-r--r--app/assets/javascripts/graphql_shared/issuable_client.js (renamed from app/assets/javascripts/work_items/graphql/provider.js)40
-rw-r--r--app/assets/javascripts/graphql_shared/possible_types.json1
-rw-r--r--app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql (renamed from app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql)0
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql14
-rw-r--r--app/assets/javascripts/groups/components/app.vue13
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue22
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue8
-rw-r--r--app/assets/javascripts/groups/components/overview_tabs.vue103
-rw-r--r--app/assets/javascripts/groups/components/visibility_level_dropdown.vue48
-rw-r--r--app/assets/javascripts/groups/constants.js26
-rw-r--r--app/assets/javascripts/groups/index.js17
-rw-r--r--app/assets/javascripts/groups/init_overview_tabs.js78
-rw-r--r--app/assets/javascripts/groups/visibility_level.js24
-rw-r--r--app/assets/javascripts/header_search/constants.js25
-rw-r--r--app/assets/javascripts/header_search/store/actions.js29
-rw-r--r--app/assets/javascripts/header_search/store/getters.js18
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js8
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue6
-rw-r--r--app/assets/javascripts/ide/index.js14
-rw-r--r--app/assets/javascripts/ide/init_gitlab_web_ide.js30
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js1
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js1
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue31
-rw-r--r--app/assets/javascripts/invite_members/components/user_limit_notification.vue23
-rw-r--r--app/assets/javascripts/invite_members/constants.js11
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js9
-rw-r--r--app/assets/javascripts/issuable/components/issue_assignees.vue2
-rw-r--r--app/assets/javascripts/issuable/components/related_issuable_item.vue4
-rw-r--r--app/assets/javascripts/issuable/components/status_box.vue2
-rw-r--r--app/assets/javascripts/issuable/issuable_form.js71
-rw-r--r--app/assets/javascripts/issues/list/components/issues_list_app.vue52
-rw-r--r--app/assets/javascripts/issues/list/constants.js19
-rw-r--r--app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/app.vue27
-rw-r--r--app/assets/javascripts/issues/show/components/delete_issue_modal.vue3
-rw-r--r--app/assets/javascripts/issues/show/components/description.vue10
-rw-r--r--app/assets/javascripts/issues/show/components/edit_actions.vue86
-rw-r--r--app/assets/javascripts/issues/show/components/edited.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue4
-rw-r--r--app/assets/javascripts/issues/show/components/form.vue17
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/constants.js11
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue31
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue47
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql13
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue242
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue94
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue66
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue27
-rw-r--r--app/assets/javascripts/issues/show/components/incidents/utils.js11
-rw-r--r--app/assets/javascripts/issues/show/graphql.js2
-rw-r--r--app/assets/javascripts/issues/show/index.js2
-rw-r--r--app/assets/javascripts/issues/show/utils/update_description.js1
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/api.js64
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/app.vue6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue15
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue111
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js25
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue38
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue12
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/actions.js8
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/store/state.js8
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/utils.js41
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/constants.js13
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue22
-rw-r--r--app/assets/javascripts/jobs/components/filtered_search/utils.js27
-rw-r--r--app/assets/javascripts/jobs/components/job/empty_state.vue (renamed from app/assets/javascripts/jobs/components/empty_state.vue)16
-rw-r--r--app/assets/javascripts/jobs/components/job/environments_block.vue (renamed from app/assets/javascripts/jobs/components/environments_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/erased_block.vue (renamed from app/assets/javascripts/jobs/components/erased_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/job_app.vue (renamed from app/assets/javascripts/jobs/components/job_app.vue)8
-rw-r--r--app/assets/javascripts/jobs/components/job/job_log_controllers.vue (renamed from app/assets/javascripts/jobs/components/job_log_controllers.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue (renamed from app/assets/javascripts/jobs/components/manual_variables_form.vue)23
-rw-r--r--app/assets/javascripts/jobs/components/job/manual_variables_form.vue195
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue (renamed from app/assets/javascripts/jobs/components/artifacts_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue (renamed from app/assets/javascripts/jobs/components/commit_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue (renamed from app/assets/javascripts/jobs/components/job_container_item.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue (renamed from app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue)2
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue (renamed from app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue)4
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue (renamed from app/assets/javascripts/jobs/components/jobs_container.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue99
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue (renamed from app/assets/javascripts/jobs/components/sidebar.vue)96
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue (renamed from app/assets/javascripts/jobs/components/sidebar_detail_row.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue102
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue (renamed from app/assets/javascripts/jobs/components/sidebar_job_details_container.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue (renamed from app/assets/javascripts/jobs/components/stages_dropdown.vue)4
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue (renamed from app/assets/javascripts/jobs/components/trigger_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/stuck_block.vue (renamed from app/assets/javascripts/jobs/components/stuck_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue (renamed from app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue)0
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue24
-rw-r--r--app/assets/javascripts/jobs/constants.js8
-rw-r--r--app/assets/javascripts/jobs/index.js2
-rw-r--r--app/assets/javascripts/labels/labels_select.js1
-rw-r--r--app/assets/javascripts/lib/dateformat.js60
-rw-r--r--app/assets/javascripts/lib/dompurify.js6
-rw-r--r--app/assets/javascripts/lib/gfm/constants.js10
-rw-r--r--app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js85
-rw-r--r--app/assets/javascripts/lib/gfm/index.js11
-rw-r--r--app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js1
-rw-r--r--app/assets/javascripts/lib/mermaid.js1
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js26
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_range.js2
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js10
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js1
-rw-r--r--app/assets/javascripts/linked_resources/index.js2
-rw-r--r--app/assets/javascripts/locale/sprintf.js2
-rw-r--r--app/assets/javascripts/members/components/modals/remove_member_modal.vue3
-rw-r--r--app/assets/javascripts/members/constants.js5
-rw-r--r--app/assets/javascripts/members/utils.js16
-rw-r--r--app/assets/javascripts/merge_request_tabs.js1
-rw-r--r--app/assets/javascripts/merge_requests/components/sticky_header.vue180
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue3
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue4
-rw-r--r--app/assets/javascripts/monitoring/format_date.js2
-rw-r--r--app/assets/javascripts/mr_notes/index.js6
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js4
-rw-r--r--app/assets/javascripts/nav/components/top_nav_app.vue17
-rw-r--r--app/assets/javascripts/nav/components/top_nav_menu_sections.vue29
-rw-r--r--app/assets/javascripts/notebook/cells/code.vue13
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue25
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue6
-rw-r--r--app/assets/javascripts/notebook/index.vue6
-rw-r--r--app/assets/javascripts/notebook/lib/highlight.js5
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue18
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue1
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue93
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue109
-rw-r--r--app/assets/javascripts/notes/components/discussion_navigator.vue11
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue15
-rw-r--r--app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue49
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue18
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue12
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue26
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue25
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue18
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue36
-rw-r--r--app/assets/javascripts/notes/components/sidebar_subscription.vue2
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue76
-rw-r--r--app/assets/javascripts/notes/components/timeline_toggle.vue1
-rw-r--r--app/assets/javascripts/notes/constants.js2
-rw-r--r--app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql8
-rw-r--r--app/assets/javascripts/notes/index.js8
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js107
-rw-r--r--app/assets/javascripts/notes/sort_discussions.js17
-rw-r--r--app/assets/javascripts/notes/stores/actions.js55
-rw-r--r--app/assets/javascripts/notes/stores/getters.js7
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js3
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue95
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue133
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue68
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue12
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue32
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue54
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue82
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue74
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js17
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js46
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/index.js43
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js200
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue156
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue103
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue169
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/router.js13
-rw-r--r--app/assets/javascripts/packages_and_registries/harbor_registry/utils.js84
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue103
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue79
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue129
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue112
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy.vue70
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue32
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue39
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/constants.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js41
-rw-r--r--app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue7
-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/admin/application_settings/ci_cd/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js2
-rw-r--r--app/assets/javascripts/pages/admin/jobs/index/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/runners/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/topics/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/topics/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js6
-rw-r--r--app/assets/javascripts/pages/groups/details/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/runners/edit/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js6
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/show/emoji_menu.js19
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js96
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue112
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue136
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql13
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/index.js69
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js23
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js8
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js30
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js4
-rw-r--r--app/assets/javascripts/pages/projects/project.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/merge_requests/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue189
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js17
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue2
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue81
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js2
-rw-r--r--app/assets/javascripts/pages/users/user_overview_block.js1
-rw-r--r--app/assets/javascripts/pdf/index.vue4
-rw-r--r--app/assets/javascripts/persistent_user_callout.js4
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js3
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue31
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue2
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue16
-rw-r--r--app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue490
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue2
-rw-r--r--app/assets/javascripts/pipeline_new/index.js75
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/editor.vue12
-rw-r--r--app/assets/javascripts/pipeline_wizard/components/wrapper.vue63
-rw-r--r--app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue4
-rw-r--r--app/assets/javascripts/pipeline_wizard/templates/pages.yml1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/performance_insights_modal.vue171
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js14
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue132
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue103
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue)4
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue)7
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue33
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue42
-rw-r--r--app/assets/javascripts/pipelines/constants.js6
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql28
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js4
-rw-r--r--app/assets/javascripts/pipelines/pipeline_tabs.js9
-rw-r--r--app/assets/javascripts/pipelines/utils.js21
-rw-r--r--app/assets/javascripts/profile/account/index.js4
-rw-r--r--app/assets/javascripts/profile/profile.js24
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue36
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue2
-rw-r--r--app/assets/javascripts/projects/project_visibility.js11
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/app.vue52
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue61
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql10
-rw-r--r--app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js17
-rw-r--r--app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue32
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue64
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js2
-rw-r--r--app/assets/javascripts/projects/star.js3
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue4
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_block.vue17
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue2
-rw-r--r--app/assets/javascripts/related_issues/constants.js9
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue2
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/actions.js50
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/getters.js5
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/mutations.js3
-rw-r--r--app/assets/javascripts/releases/stores/modules/edit_new/state.js1
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue28
-rw-r--r--app/assets/javascripts/repository/components/blob_controls.vue2
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue8
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue5
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue4
-rw-r--r--app/assets/javascripts/repository/log_tree.js3
-rw-r--r--app/assets/javascripts/repository/queries/blob_info.query.graphql66
-rw-r--r--app/assets/javascripts/repository/queries/project_info.query.graphql14
-rw-r--r--app/assets/javascripts/repository/utils/commit.js4
-rw-r--r--app/assets/javascripts/rest_api.js1
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue4
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue112
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_status_cell.vue3
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_cell.vue71
-rw-r--r--app/assets/javascripts/runner/components/cells/runner_summary_field.vue33
-rw-r--r--app/assets/javascripts/runner/components/runner_detail.vue9
-rw-r--r--app/assets/javascripts/runner/components/runner_details.vue46
-rw-r--r--app/assets/javascripts/runner/components/runner_header.vue46
-rw-r--r--app/assets/javascripts/runner/components/runner_list.vue49
-rw-r--r--app/assets/javascripts/runner/components/runner_name.vue4
-rw-r--r--app/assets/javascripts/runner/components/runner_paused_badge.vue12
-rw-r--r--app/assets/javascripts/runner/components/runner_projects.vue41
-rw-r--r--app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue58
-rw-r--r--app/assets/javascripts/runner/components/runner_status_badge.vue26
-rw-r--r--app/assets/javascripts/runner/components/runner_tags.vue2
-rw-r--r--app/assets/javascripts/runner/components/runner_type_badge.vue21
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/paused_token_config.js6
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js14
-rw-r--r--app/assets/javascripts/runner/components/stat/runner_stats.vue22
-rw-r--r--app/assets/javascripts/runner/constants.js16
-rw-r--r--app/assets/javascripts/runner/graphql/list/list_item_shared.fragment.graphql1
-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.graphql3
-rw-r--r--app/assets/javascripts/runner/group_runner_show/index.js3
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue8
-rw-r--r--app/assets/javascripts/runner/runner_edit/index.js (renamed from app/assets/javascripts/runner/admin_runner_edit/index.js)6
-rw-r--r--app/assets/javascripts/runner/runner_edit/runner_edit_app.vue (renamed from app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue)2
-rw-r--r--app/assets/javascripts/runner/utils.js11
-rw-r--r--app/assets/javascripts/search/index.js4
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue4
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue4
-rw-r--r--app/assets/javascripts/search/under_topbar/index.js31
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js2
-rw-r--r--app/assets/javascripts/set_status_modal/constants.js14
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_form.vue231
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue219
-rw-r--r--app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue100
-rw-r--r--app/assets/javascripts/set_status_modal/utils.js5
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue5
-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/copy_email_to_clipboard.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/incidents/escalation_status.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/lock/edit_form.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue80
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue18
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js20
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/report.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue33
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue10
-rw-r--r--app/assets/javascripts/sidebar/constants.js20
-rw-r--r--app/assets/javascripts/sidebar/graphql.js29
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js6
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js11
-rw-r--r--app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql6
-rw-r--r--app/assets/javascripts/sidebar/queries/merge_request_time_tracking.query.graphql6
-rw-r--r--app/assets/javascripts/snippets/components/show.vue4
-rw-r--r--app/assets/javascripts/snippets/constants.js20
-rw-r--r--app/assets/javascripts/snippets/index.js8
-rw-r--r--app/assets/javascripts/snippets/utils/blob.js4
-rw-r--r--app/assets/javascripts/surveys/merge_request_experience/app.vue157
-rw-r--r--app/assets/javascripts/token_access/components/token_access.vue38
-rw-r--r--app/assets/javascripts/user_popovers.js1
-rw-r--r--app/assets/javascripts/validators/input_validator.js2
-rw-r--r--app/assets/javascripts/visibility_level/constants.js24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/added_commit_message.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue24
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/state_container.vue71
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue26
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue56
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merging.vue4
-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_not_allowed.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_blocked.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/pipeline_failed.vue8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue115
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue35
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue57
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js7
-rw-r--r--app/assets/javascripts/vue_shared/components/actions_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.stories.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block_highlighted.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar.stories.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image_new.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/constants.js13
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue116
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql1
-rw-r--r--app/assets/javascripts/webpack_non_compiled_placeholder.js1
-rw-r--r--app/assets/javascripts/work_items/components/item_title.vue7
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue30
-rw-r--r--app/assets/javascripts/work_items/components/work_item_assignees.vue84
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue86
-rw-r--r--app/assets/javascripts/work_items/components/work_item_due_date.vue257
-rw-r--r--app/assets/javascripts/work_items/components/work_item_information.vue14
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue9
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/index.js9
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue109
-rw-r--r--app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue121
-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.vue9
-rw-r--r--app/assets/javascripts/work_items/components/work_item_type_icon.vue20
-rw-r--r--app/assets/javascripts/work_items/components/work_item_weight.vue162
-rw-r--r--app/assets/javascripts/work_items/constants.js30
-rw-r--r--app/assets/javascripts/work_items/graphql/create_work_item.mutation.graphql2
-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/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.graphql10
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.fragment.graphql37
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item.query.graphql2
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_links.query.graphql4
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql36
-rw-r--r--app/assets/javascripts/work_items/index.js4
-rw-r--r--app/assets/javascripts/work_items/pages/create_work_item.vue21
-rw-r--r--app/assets/javascripts/work_items/pages/work_item_root.vue17
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss2
-rw-r--r--app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss8
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/diffs.scss31
-rw-r--r--app/assets/stylesheets/framework/files.scss9
-rw-r--r--app/assets/stylesheets/framework/header.scss14
-rw-r--r--app/assets/stylesheets/framework/highlight.scss3
-rw-r--r--app/assets/stylesheets/framework/layout.scss10
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss1
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss10
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss2
-rw-r--r--app/assets/stylesheets/framework/typography.scss7
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss249
-rw-r--r--app/assets/stylesheets/page_bundles/editor.scss (renamed from app/assets/stylesheets/pages/editor.scss)18
-rw-r--r--app/assets/stylesheets/page_bundles/group.scss23
-rw-r--r--app/assets/stylesheets/page_bundles/issues_show.scss69
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss87
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline_schedules.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/profiles/preferences.scss (renamed from app/assets/stylesheets/pages/profiles/preferences.scss)2
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/todos.scss32
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss36
-rw-r--r--app/assets/stylesheets/pages/commits.scss6
-rw-r--r--app/assets/stylesheets/pages/issuable.scss55
-rw-r--r--app/assets/stylesheets/pages/issues.scss18
-rw-r--r--app/assets/stylesheets/pages/login.scss20
-rw-r--r--app/assets/stylesheets/pages/note_form.scss6
-rw-r--r--app/assets/stylesheets/pages/notes.scss15
-rw-r--r--app/assets/stylesheets/pages/search.scss12
-rw-r--r--app/assets/stylesheets/pages/settings.scss6
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss39
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss26
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss458
-rw-r--r--app/assets/stylesheets/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/themes/dark_mode_overrides.scss22
-rw-r--r--app/assets/stylesheets/themes/theme_blue.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_gray.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_green.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss66
-rw-r--r--app/assets/stylesheets/themes/theme_indigo.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_blue.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_gray.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_green.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_indigo.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_light_red.scss1
-rw-r--r--app/assets/stylesheets/themes/theme_red.scss1
-rw-r--r--app/assets/stylesheets/utilities.scss10
729 files changed, 12240 insertions, 5285 deletions
diff --git a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
index 59f0e0dd17d..461b2dad479 100644
--- a/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
+++ b/app/assets/javascripts/access_tokens/components/access_token_table_app.vue
@@ -140,6 +140,7 @@ export default {
<template #cell(action)="{ item: { revokePath } }">
<gl-button
+ v-if="revokePath"
category="tertiary"
:aria-label="$options.i18n.revokeButton"
:data-confirm="modalMessage"
diff --git a/app/assets/javascripts/access_tokens/components/constants.js b/app/assets/javascripts/access_tokens/components/constants.js
index 84e50bc099f..9cd7cb5bb3a 100644
--- a/app/assets/javascripts/access_tokens/components/constants.js
+++ b/app/assets/javascripts/access_tokens/components/constants.js
@@ -12,8 +12,6 @@ export const FIELDS = [
key: 'name',
label: __('Token name'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
formatter(scopes) {
@@ -22,40 +20,30 @@ export const FIELDS = [
key: 'scopes',
label: __('Scopes'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'createdAt',
label: s__('AccessTokens|Created'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'lastUsedAt',
label: __('Last Used'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'expiresAt',
label: __('Expires'),
sortable: true,
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
},
{
key: 'role',
label: __('Role'),
- tdClass: `gl-text-black-normal`,
- thClass: `gl-text-black-normal`,
sortable: true,
},
{
key: 'action',
label: __('Action'),
- thClass: `gl-text-black-normal`,
+ tdClass: 'gl-py-3!',
},
];
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 5516fd0daf6..38501d63d3a 100644
--- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -16,6 +16,16 @@ export default {
import('ee_component/access_tokens/components/max_expiration_date_message.vue'),
},
props: {
+ defaultDateOffset: {
+ type: Number,
+ required: false,
+ default: 30,
+ },
+ description: {
+ type: String,
+ required: false,
+ default: null,
+ },
inputAttrs: {
type: Object,
required: false,
@@ -33,9 +43,15 @@ export default {
},
},
computed: {
- in30Days() {
- const today = new Date();
- return getDateInFuture(today, 30);
+ defaultDate() {
+ const defaultDate = getDateInFuture(new Date(), this.defaultDateOffset);
+ // The maximum date can be set by admins. If the maximum date is sooner
+ // than the default expiration date we use the maximum date as default
+ // expiration date.
+ if (this.maxDate && this.maxDate < defaultDate) {
+ return this.maxDate;
+ }
+ return defaultDate;
},
},
};
@@ -47,7 +63,7 @@ export default {
:target="null"
:min-date="minDate"
:max-date="maxDate"
- :default-date="in30Days"
+ :default-date="defaultDate"
show-clear-button
:input-name="inputAttrs.name"
:input-id="inputAttrs.id"
@@ -55,7 +71,10 @@ export default {
data-qa-selector="expiry_date_field"
/>
<template #description>
- <max-expiration-date-message :max-date="maxDate" />
+ <template v-if="description">
+ {{ description }}
+ </template>
+ <max-expiration-date-message v-else :max-date="maxDate" />
</template>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
index e111ae91e5c..6b52bd84656 100644
--- a/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
+++ b/app/assets/javascripts/access_tokens/components/new_access_token_app.vue
@@ -42,7 +42,6 @@ export default {
formInputGroupProps() {
return {
id: this.$options.tokenInputId,
- class: 'qa-created-access-token',
'data-qa-selector': 'created_access_token_field',
name: this.$options.tokenInputId,
};
@@ -82,7 +81,14 @@ export default {
this.infoAlert = createAlert({ message: this.alertInfoMessage, variant: VARIANT_INFO });
- this.form.reset();
+ // Selectively reset all input fields except for the date picker and submit.
+ // The form token creation is not controlled by Vue.
+ this.form.querySelectorAll('input[type=text]:not([id$=expires_at])').forEach((el) => {
+ el.value = '';
+ });
+ this.form.querySelectorAll('input[type=checkbox]').forEach((el) => {
+ el.checked = false;
+ });
},
},
};
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
index 9801aa08e28..f0c1b415157 100644
--- a/app/assets/javascripts/access_tokens/index.js
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -61,7 +61,7 @@ export const initExpiresAtField = () => {
}
const { expiresAt: inputAttrs } = parseRailsFormFields(el);
- const { minDate, maxDate } = el.dataset;
+ const { minDate, maxDate, defaultDateOffset, description } = el.dataset;
return new Vue({
el,
@@ -71,6 +71,8 @@ export const initExpiresAtField = () => {
inputAttrs,
minDate: minDate ? new Date(minDate) : undefined,
maxDate: maxDate ? new Date(maxDate) : undefined,
+ defaultDateOffset: defaultDateOffset ? Number(defaultDateOffset) : undefined,
+ description,
},
});
},
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue
new file mode 100644
index 00000000000..2f74b44625f
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_interval_description.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: String,
+ required: true,
+ },
+ },
+ i18n: {
+ fieldHelpText: s__(
+ 'AdminSettings|If no unit is written, it defaults to seconds. For example, these are all equivalent: %{oneDayInSeconds}, %{oneDayInHoursHumanReadable}, or %{oneDayHumanReadable}. Minimum value is two hours. %{linkStart}Learn more.%{linkEnd}',
+ ),
+ },
+ computed: {
+ helpUrl() {
+ return helpPagePath('ci/runners/configure_runners', {
+ anchor: 'authentication-token-security',
+ });
+ },
+ },
+};
+</script>
+<template>
+ <p>
+ {{ message }}
+ <gl-sprintf :message="$options.i18n.fieldHelpText">
+ <template #oneDayInSeconds>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>86400</code>
+ </template>
+ <template #oneDayInHoursHumanReadable>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>24 hours</code>
+ </template>
+ <template #oneDayHumanReadable>
+ <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
+ <code>1 day</code>
+ </template>
+ <template #link>
+ <gl-link :href="helpUrl" target="_blank">{{ __('Learn more.') }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+</template>
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue
new file mode 100644
index 00000000000..371a26d2664
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/components/expiration_intervals.vue
@@ -0,0 +1,123 @@
+<script>
+import { GlFormGroup } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ChronicDurationInput from '~/vue_shared/components/chronic_duration_input.vue';
+import ExpirationIntervalDescription from './expiration_interval_description.vue';
+
+export default {
+ components: {
+ ChronicDurationInput,
+ ExpirationIntervalDescription,
+ GlFormGroup,
+ },
+ props: {
+ instanceRunnerExpirationInterval: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ groupRunnerExpirationInterval: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ projectRunnerExpirationInterval: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ perInput: {
+ instance: {
+ value: this.instanceRunnerExpirationInterval,
+ valid: null,
+ feedback: '',
+ },
+ group: {
+ value: this.groupRunnerExpirationInterval,
+ valid: null,
+ feedback: '',
+ },
+ project: {
+ value: this.projectRunnerExpirationInterval,
+ valid: null,
+ feedback: '',
+ },
+ },
+ };
+ },
+ methods: {
+ updateValidity(obj, event) {
+ /* eslint-disable no-param-reassign */
+ obj.valid = event.valid;
+ obj.feedback = event.feedback;
+ /* eslint-enable no-param-reassign */
+ },
+ },
+ i18n: {
+ instanceRunnerTitle: s__('AdminSettings|Instance runners expiration'),
+ instanceRunnerDescription: s__(
+ 'AdminSettings|Set the expiration time of authentication tokens of newly registered instance runners. Authentication tokens are automatically reset at these intervals.',
+ ),
+ groupRunnerTitle: s__('AdminSettings|Group runners expiration'),
+ groupRunnerDescription: s__(
+ 'AdminSettings|Set the expiration time of authentication tokens of newly registered group runners.',
+ ),
+ projectRunnerTitle: s__('AdminSettings|Project runners expiration'),
+ projectRunnerDescription: s__(
+ 'AdminSettings|Set the expiration time of authentication tokens of newly registered project runners.',
+ ),
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-form-group
+ :label="$options.i18n.instanceRunnerTitle"
+ :invalid-feedback="perInput.instance.feedback"
+ :state="perInput.instance.valid"
+ >
+ <template #description>
+ <expiration-interval-description :message="$options.i18n.instanceRunnerDescription" />
+ </template>
+ <chronic-duration-input
+ v-model="perInput.instance.value"
+ name="application_setting[runner_token_expiration_interval]"
+ :state="perInput.instance.valid"
+ @valid="updateValidity(perInput.instance, $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.groupRunnerTitle"
+ :invalid-feedback="perInput.group.feedback"
+ :state="perInput.group.valid"
+ >
+ <template #description>
+ <expiration-interval-description :message="$options.i18n.groupRunnerDescription" />
+ </template>
+ <chronic-duration-input
+ v-model="perInput.group.value"
+ name="application_setting[group_runner_token_expiration_interval]"
+ :state="perInput.group.valid"
+ @valid="updateValidity(perInput.group, $event)"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="$options.i18n.projectRunnerTitle"
+ :invalid-feedback="perInput.project.feedback"
+ :state="perInput.project.valid"
+ >
+ <template #description>
+ <expiration-interval-description :message="$options.i18n.projectRunnerDescription" />
+ </template>
+ <chronic-duration-input
+ v-model="perInput.project.value"
+ name="application_setting[project_runner_token_expiration_interval]"
+ :state="perInput.project.valid"
+ @valid="updateValidity(perInput.project, $event)"
+ />
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
new file mode 100644
index 00000000000..79d7ff0451a
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/runner_token_expiration/index.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import { parseInterval } from '~/runner/utils';
+import ExpirationIntervals from './components/expiration_intervals.vue';
+
+const initRunnerTokenExpirationIntervals = (selector = '#js-runner-token-expiration-intervals') => {
+ const el = document.querySelector(selector);
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ instanceRunnerTokenExpirationInterval,
+ groupRunnerTokenExpirationInterval,
+ projectRunnerTokenExpirationInterval,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(ExpirationIntervals, {
+ props: {
+ instanceRunnerExpirationInterval: parseInterval(instanceRunnerTokenExpirationInterval),
+ groupRunnerExpirationInterval: parseInterval(groupRunnerTokenExpirationInterval),
+ projectRunnerExpirationInterval: parseInterval(projectRunnerTokenExpirationInterval),
+ },
+ });
+ },
+ });
+};
+
+export default initRunnerTokenExpirationIntervals;
diff --git a/app/assets/javascripts/admin/topics/components/merge_topics.vue b/app/assets/javascripts/admin/topics/components/merge_topics.vue
new file mode 100644
index 00000000000..921b762bbef
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/components/merge_topics.vue
@@ -0,0 +1,141 @@
+<script>
+import { GlAlert, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import csrf from '~/lib/utils/csrf';
+import TopicSelect from './topic_select.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ GlModal,
+ GlSprintf,
+ TopicSelect,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ inject: ['path'],
+ data() {
+ return {
+ sourceTopic: {},
+ targetTopic: {},
+ };
+ },
+ computed: {
+ sourceTopicId() {
+ return getIdFromGraphQLId(this.sourceTopic?.id);
+ },
+ targetTopicId() {
+ return getIdFromGraphQLId(this.targetTopic?.id);
+ },
+ validSelectedTopics() {
+ return (
+ Object.keys(this.sourceTopic).length &&
+ Object.keys(this.targetTopic).length &&
+ this.sourceTopic !== this.targetTopic
+ );
+ },
+ actionPrimary() {
+ return {
+ text: __('Merge'),
+ attributes: {
+ variant: 'danger',
+ disabled: !this.validSelectedTopics,
+ },
+ };
+ },
+ },
+ methods: {
+ selectSourceTopic(topic) {
+ this.sourceTopic = topic;
+ },
+ selectTargetTopic(topic) {
+ this.targetTopic = topic;
+ },
+ mergeTopics() {
+ this.$refs.mergeForm.submit();
+ },
+ },
+ i18n: {
+ title: s__('MergeTopics|Merge topics'),
+ body: s__(
+ 'MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic.',
+ ),
+ sourceTopic: s__('MergeTopics|Source topic'),
+ targetTopic: s__('MergeTopics|Target topic'),
+ warningTitle: s__('MergeTopics|Merging topics will cause the following:'),
+ warningBody: s__('MergeTopics|This action cannot be undone.'),
+ warningRemoveTopic: s__('MergeTopics|%{sourceTopic} will be removed'),
+ warningMoveProjects: s__('MergeTopics|All assigned projects will be moved to %{targetTopic}'),
+ },
+ modal: {
+ id: 'merge-topics',
+ actionSecondary: {
+ text: __('Cancel'),
+ attributes: {
+ variant: 'default',
+ },
+ },
+ },
+ csrf,
+};
+</script>
+<template>
+ <div class="gl-mr-3">
+ <gl-button v-gl-modal="$options.modal.id" category="secondary">{{
+ $options.i18n.title
+ }}</gl-button>
+ <gl-modal
+ :title="$options.i18n.title"
+ :action-primary="actionPrimary"
+ :action-secondary="$options.modal.actionSecondary"
+ :modal-id="$options.modal.id"
+ size="sm"
+ @primary="mergeTopics"
+ >
+ <p>{{ $options.i18n.body }}</p>
+ <topic-select
+ :selected-topic="sourceTopic"
+ :label-text="$options.i18n.sourceTopic"
+ @click="selectSourceTopic"
+ />
+ <topic-select
+ :selected-topic="targetTopic"
+ :label-text="$options.i18n.targetTopic"
+ @click="selectTargetTopic"
+ />
+ <gl-alert
+ v-if="validSelectedTopics"
+ :title="$options.i18n.warningTitle"
+ :dismissible="false"
+ variant="danger"
+ >
+ <ul>
+ <li>
+ <gl-sprintf :message="$options.i18n.warningRemoveTopic">
+ <template #sourceTopic>
+ <strong>{{ sourceTopic.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </li>
+ <li>
+ <gl-sprintf :message="$options.i18n.warningMoveProjects">
+ <template #targetTopic>
+ <strong>{{ targetTopic.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </li>
+ </ul>
+ {{ $options.i18n.warningBody }}
+ </gl-alert>
+ <form ref="mergeForm" method="post" :action="path">
+ <input type="hidden" name="_method" value="post" />
+ <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
+ <input type="hidden" name="source_topic_id" :value="sourceTopicId" />
+ <input type="hidden" name="target_topic_id" :value="targetTopicId" />
+ </form>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue
new file mode 100644
index 00000000000..8bf5be1afd1
--- /dev/null
+++ b/app/assets/javascripts/admin/topics/components/topic_select.vue
@@ -0,0 +1,106 @@
+<script>
+import {
+ GlAvatarLabeled,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
+import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
+
+export default {
+ components: {
+ GlAvatarLabeled,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ },
+ props: {
+ selectedTopic: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ labelText: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ apollo: {
+ topics: {
+ query: searchProjectTopics,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ update(data) {
+ return data.topics?.nodes || [];
+ },
+ debounce: 250,
+ },
+ },
+ data() {
+ return {
+ topics: [],
+ search: '',
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.topics.loading;
+ },
+ isResultEmpty() {
+ return this.topics.length === 0;
+ },
+ dropdownText() {
+ if (Object.keys(this.selectedTopic).length) {
+ return this.selectedTopic.name;
+ }
+
+ return this.$options.i18n.dropdownText;
+ },
+ },
+ methods: {
+ selectTopic(topic) {
+ this.$emit('click', topic);
+ },
+ },
+ i18n: {
+ dropdownText: s__('TopicSelect|Select a topic'),
+ searchPlaceholder: s__('TopicSelect|Search topics'),
+ emptySearchResult: s__('TopicSelect|No matching results'),
+ },
+ AVATAR_SHAPE_OPTION_RECT,
+};
+</script>
+
+<template>
+ <div>
+ <label v-if="labelText">{{ labelText }}</label>
+ <gl-dropdown block :text="dropdownText">
+ <gl-search-box-by-type
+ v-model="search"
+ :is-loading="loading"
+ :placeholder="$options.i18n.searchPlaceholder"
+ />
+ <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)">
+ <gl-avatar-labeled
+ :label="topic.title"
+ :sub-label="topic.name"
+ :src="topic.avatarUrl"
+ :entity-name="topic.name"
+ :size="32"
+ :shape="$options.AVATAR_SHAPE_OPTION_RECT"
+ />
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="isResultEmpty && !loading">
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
index 09e9b20f220..d81690e8f4c 100644
--- a/app/assets/javascripts/admin/topics/index.js
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -1,7 +1,20 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import showToast from '~/vue_shared/plugins/global_toast';
import RemoveAvatar from './components/remove_avatar.vue';
+import MergeTopics from './components/merge_topics.vue';
-export default () => {
+const toasts = document.querySelectorAll('.js-toast-message');
+toasts.forEach((toast) => showToast(toast.dataset.message));
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initRemoveAvatar = () => {
const el = document.querySelector('.js-remove-topic-avatar');
if (!el) {
@@ -21,3 +34,20 @@ export default () => {
},
});
};
+
+export const initMergeTopics = () => {
+ const el = document.querySelector('.js-merge-topics');
+
+ if (!el) return false;
+
+ const { path } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: { path },
+ render(createElement) {
+ return createElement(MergeTopics);
+ },
+ });
+};
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
index 696e7f359d1..388d925196b 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue
@@ -109,7 +109,7 @@ export default {
v-for="template in templates"
:key="template.key"
data-qa-selector="incident_templates_item"
- :is-check-item="true"
+ is-check-item
:is-checked="isTemplateSelected(template.key)"
@click="selectIssueTemplate(template.key)"
>
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
index 7df66d1b2be..92ccac59057 100644
--- a/app/assets/javascripts/analytics/shared/components/daterange.vue
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -1,13 +1,10 @@
<script>
-import { GlDaterangePicker, GlSprintf } from '@gitlab/ui';
-import { getDayDifference } from '~/lib/utils/datetime_utility';
-import { __, sprintf } from '~/locale';
-import { OFFSET_DATE_BY_ONE } from '../constants';
+import { GlDaterangePicker } from '@gitlab/ui';
+import { n__, __, sprintf } from '~/locale';
export default {
components: {
GlDaterangePicker,
- GlSprintf,
},
props: {
show: {
@@ -69,9 +66,10 @@ export default {
this.$emit('change', { startDate, endDate });
},
},
- numberOfDays() {
- const dayDifference = getDayDifference(this.startDate, this.endDate);
- return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference;
+ },
+ methods: {
+ numberOfDays(daysSelected) {
+ return n__('1 day selected', '%d days selected', daysSelected);
},
},
};
@@ -83,7 +81,7 @@ export default {
>
<gl-daterange-picker
v-model="dateRange"
- class="d-flex flex-column flex-lg-row"
+ class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row"
:default-start-date="startDate"
:default-end-date="endDate"
:default-min-date="minDate"
@@ -93,12 +91,12 @@ export default {
:tooltip="maxDateRangeTooltip"
theme="animate-picker"
start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
- end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center gl-mb-2 gl-lg-mb-0"
+ end-picker-class="js-daterange-picker-to gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-mb-2 gl-lg-mb-0"
label-class="gl-mb-2 gl-lg-mb-0"
>
- <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
- <template #numberOfDays>{{ numberOfDays }}</template>
- </gl-sprintf>
+ <template #default="{ daysSelected }">
+ {{ numberOfDays(daysSelected) }}
+ </template>
</gl-daterange-picker>
</div>
</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
index e1bc59b36ef..c62736d55a8 100644
--- a/app/assets/javascripts/analytics/shared/constants.js
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -1,8 +1,7 @@
-import { masks } from 'dateformat';
+import { masks } from '~/lib/dateformat';
import { s__ } from '~/locale';
export const DATE_RANGE_LIMIT = 180;
-export const OFFSET_DATE_BY_ONE = 1;
export const PROJECTS_PER_PAGE = 50;
const { isoDate, mediumDate } = masks;
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 1887f2affc3..bc52e38fc81 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import { hideFlash } from '~/flash';
+import dateFormat from '~/lib/dateformat';
import { slugify } from '~/lib/utils/text_utility';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from './constants';
diff --git a/app/assets/javascripts/analytics/usage_trends/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js
index 91907877ed6..9474d264363 100644
--- a/app/assets/javascripts/analytics/usage_trends/utils.js
+++ b/app/assets/javascripts/analytics/usage_trends/utils.js
@@ -1,5 +1,5 @@
-import { masks } from 'dateformat';
import { get } from 'lodash';
+import { masks } from '~/lib/dateformat';
import { formatDate } from '~/lib/utils/datetime_utility';
const { isoDate } = masks;
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 0c870a89760..b02dd9321b3 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -47,6 +47,7 @@ const Api = {
projectSharePath: '/api/:version/projects/:id/share',
projectMilestonesPath: '/api/:version/projects/:id/milestones',
projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid',
+ projectCreateIssuePath: '/api/:version/projects/:id/issues',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/api/:version/groups/:namespace_path/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
diff --git a/app/assets/javascripts/api/harbor_registry.js b/app/assets/javascripts/api/harbor_registry.js
new file mode 100644
index 00000000000..eb241342567
--- /dev/null
+++ b/app/assets/javascripts/api/harbor_registry.js
@@ -0,0 +1,49 @@
+import axios from '~/lib/utils/axios_utils';
+import { buildApiUrl } from '~/api/api_utils';
+
+// the :request_path is loading API-like resources, not part of our REST API.
+// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82784#note_1077703806
+const HARBOR_REPOSITORIES_PATH = '/:request_path.json';
+const HARBOR_ARTIFACTS_PATH = '/:request_path/:repo_name/artifacts.json';
+const HARBOR_TAGS_PATH = '/:request_path/:repo_name/artifacts/:digest/tags.json';
+
+export function getHarborRepositoriesList({ requestPath, limit, page, sort, search = '' }) {
+ const url = buildApiUrl(HARBOR_REPOSITORIES_PATH).replace('/:request_path', requestPath);
+
+ return axios.get(url, {
+ params: {
+ limit,
+ page,
+ search,
+ sort,
+ },
+ });
+}
+
+export function getHarborArtifacts({ requestPath, repoName, limit, page, sort, search = '' }) {
+ const url = buildApiUrl(HARBOR_ARTIFACTS_PATH)
+ .replace('/:request_path', requestPath)
+ .replace(':repo_name', repoName);
+
+ return axios.get(url, {
+ params: {
+ limit,
+ page,
+ search,
+ sort,
+ },
+ });
+}
+
+export function getHarborTags({ requestPath, repoName, digest, page }) {
+ const url = buildApiUrl(HARBOR_TAGS_PATH)
+ .replace('/:request_path', requestPath)
+ .replace(':repo_name', repoName)
+ .replace(':digest', digest);
+
+ return axios.get(url, {
+ params: {
+ page,
+ },
+ });
+}
diff --git a/app/assets/javascripts/api/integrations_api.js b/app/assets/javascripts/api/integrations_api.js
deleted file mode 100644
index 692aae21a4f..00000000000
--- a/app/assets/javascripts/api/integrations_api.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import axios from '../lib/utils/axios_utils';
-import { buildApiUrl } from './api_utils';
-
-const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions';
-
-export function addJiraConnectSubscription(namespacePath, { jwt, accessToken }) {
- const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH);
-
- return axios.post(
- url,
- {
- jwt,
- namespace_path: namespacePath,
- },
- {
- headers: {
- Authorization: `Bearer ${accessToken}`, // eslint-disable-line @gitlab/require-i18n-strings
- },
- },
- );
-}
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index c362253f52e..c743b18d572 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -12,7 +12,6 @@ const USER_PROJECTS_PATH = '/api/:version/users/:id/projects';
const USER_POST_STATUS_PATH = '/api/:version/user/status';
const USER_FOLLOW_PATH = '/api/:version/users/:id/follow';
const USER_UNFOLLOW_PATH = '/api/:version/users/:id/unfollow';
-const CURRENT_USER_PATH = '/api/:version/user';
export function getUsers(query, options) {
const url = buildApiUrl(USERS_PATH);
@@ -82,8 +81,3 @@ export function unfollowUser(userId) {
const url = buildApiUrl(USER_UNFOLLOW_PATH).replace(':id', encodeURIComponent(userId));
return axios.post(url);
}
-
-export function getCurrentUser(options) {
- const url = buildApiUrl(CURRENT_USER_PATH);
- return axios.get(url, { ...options });
-}
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 8381dcec9c3..5ab66acaf80 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -5,7 +5,7 @@ import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
-
+ this.type = this.field.prop('type');
this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
if (key.join != null) {
key = key.join('/');
@@ -22,11 +22,12 @@ export default class Autosave {
restore() {
if (!this.isLocalStorageAvailable) return;
if (!this.field.length) return;
-
const text = window.localStorage.getItem(this.key);
const fallbackText = window.localStorage.getItem(this.fallbackKey);
- if (text) {
+ if (this.type === 'checkbox') {
+ this.field.prop('checked', text || fallbackText);
+ } else if (text) {
this.field.val(text);
} else if (fallbackText) {
this.field.val(fallbackText);
@@ -49,17 +50,16 @@ export default class Autosave {
save() {
if (!this.field.length) return;
+ const value = this.type === 'checkbox' ? this.field.is(':checked') : this.field.val();
- const text = this.field.val();
-
- if (this.isLocalStorageAvailable && text) {
+ if (this.isLocalStorageAvailable && value) {
if (this.fallbackKey) {
- window.localStorage.setItem(this.fallbackKey, text);
+ window.localStorage.setItem(this.fallbackKey, value);
}
if (this.lockVersion !== undefined) {
window.localStorage.setItem(this.lockVersionKey, this.lockVersion);
}
- return window.localStorage.setItem(this.key, text);
+ return window.localStorage.setItem(this.key, value);
}
return this.reset();
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index a030797c698..a3ffb4df7b7 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -165,6 +165,7 @@ export class AwardsHandler {
`;
const targetEl = this.targetContainerEl ? this.targetContainerEl : document.body;
+ // eslint-disable-next-line no-unsanitized/method
targetEl.insertAdjacentHTML('beforeend', emojiMenuMarkup);
this.addRemainingEmojiMenuCategories();
@@ -198,6 +199,7 @@ export class AwardsHandler {
emojisInCategory,
);
requestAnimationFrame(() => {
+ // eslint-disable-next-line no-unsanitized/method
emojiContentElement.insertAdjacentHTML('beforeend', categoryMarkup);
resolve();
});
diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue
index 0eb4e6e7709..71560c7de3a 100644
--- a/app/assets/javascripts/batch_comments/components/preview_item.vue
+++ b/app/assets/javascripts/batch_comments/components/preview_item.vue
@@ -67,6 +67,7 @@ export default {
},
content() {
const el = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
el.innerHTML = this.draft.note_html;
return el.textContent;
diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
index 54b9953270b..acc3cbe10a0 100644
--- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
+++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue
@@ -1,7 +1,16 @@
<script>
import $ from 'jquery';
-import { GlDropdown, GlButton, GlIcon, GlForm, GlFormGroup, GlLink } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlButton,
+ GlIcon,
+ GlForm,
+ GlFormGroup,
+ GlLink,
+ GlFormCheckbox,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
+import { createAlert } from '~/flash';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
import Autosave from '~/autosave';
@@ -15,29 +24,46 @@ export default {
GlForm,
GlFormGroup,
GlLink,
+ GlFormCheckbox,
MarkdownField,
+ ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'),
},
data() {
return {
isSubmitting: false,
- note: '',
+ noteData: {
+ noteable_type: '',
+ noteable_id: '',
+ note: '',
+ approve: false,
+ approval_password: '',
+ },
};
},
computed: {
...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']),
},
+ watch: {
+ 'noteData.approve': function noteDataApproveWatch() {
+ setTimeout(() => {
+ this.repositionDropdown();
+ });
+ },
+ },
mounted() {
this.autosave = new Autosave(
$(this.$refs.textarea),
`submit_review_dropdown/${this.getNoteableData.id}`,
);
+ this.noteData.noteable_type = this.noteableType;
+ this.noteData.noteable_id = 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
// whenever a item in the autocomplete dropdown is clicked
- const originalClickOutHandler = this.$refs.dropdown.$refs.dropdown.clickOutHandler;
- this.$refs.dropdown.$refs.dropdown.clickOutHandler = (e) => {
+ const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler;
+ this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => {
if (!e.target.closest('.atwho-container')) {
originalClickOutHandler(e);
}
@@ -45,26 +71,32 @@ export default {
},
methods: {
...mapActions('batchComments', ['publishReview']),
+ repositionDropdown() {
+ this.$refs.submitDropdown?.$refs.dropdown?.updatePopper();
+ },
async submitReview() {
- const noteData = {
- noteable_type: this.noteableType,
- noteable_id: this.getNoteableData.id,
- note: this.note,
- };
-
this.isSubmitting = true;
- await this.publishReview(noteData);
+ try {
+ await this.publishReview(this.noteData);
+
+ this.autosave.reset();
- this.autosave.reset();
+ if (window.mrTabs && (this.noteData.note || this.noteData.approve)) {
+ if (this.noteData.note) {
+ window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
+ }
- if (window.mrTabs && this.note) {
- window.location.hash = `note_${this.getCurrentUserLastNote.id}`;
- window.mrTabs.tabShown('show');
+ window.mrTabs.tabShown('show');
- setTimeout(() =>
- scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)),
- );
+ setTimeout(() =>
+ scrollToElement(document.getElementById(`note_${this.getCurrentUserLastNote.id}`)),
+ );
+ }
+ } catch (e) {
+ if (e.data?.message) {
+ createAlert({ message: e.data.message, captureError: true });
+ }
}
this.isSubmitting = false;
@@ -79,8 +111,9 @@ export default {
<template>
<gl-dropdown
- ref="dropdown"
+ ref="submitDropdown"
right
+ dropup
class="submit-review-dropdown"
data-qa-selector="submit_review_dropdown"
variant="info"
@@ -110,7 +143,7 @@ export default {
<markdown-field
:is-submitting="isSubmitting"
:add-spacing-classes="false"
- :textarea-value="note"
+ :textarea-value="noteData.note"
:markdown-preview-path="getNoteableData.preview_note_path"
:markdown-docs-path="getNotesData.markdownDocsPath"
:quick-actions-docs-path="getNotesData.quickActionsDocsPath"
@@ -122,7 +155,7 @@ export default {
<textarea
id="review-note-body"
ref="textarea"
- v-model="note"
+ v-model="noteData.note"
dir="auto"
:disabled="isSubmitting"
name="review[note]"
@@ -139,6 +172,18 @@ export default {
</div>
</div>
</gl-form-group>
+ <template v-if="getNoteableData.current_user.can_approve">
+ <gl-form-checkbox v-model="noteData.approve" data-testid="approve_merge_request">
+ {{ __('Approve merge request') }}
+ </gl-form-checkbox>
+ <approval-password
+ v-if="getNoteableData.require_password_to_approve"
+ v-show="noteData.approve"
+ v-model="noteData.approval_password"
+ class="gl-mt-3"
+ data-testid="approve_password"
+ />
+ </template>
<div class="gl-display-flex gl-justify-content-start gl-mt-5">
<gl-button
:loading="isSubmitting"
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..2b0aaa74e83 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
@@ -84,7 +84,11 @@ export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => {
.publish(getters.getNotesData.draftsPublishPath, noteData)
.then(() => dispatch('updateDiscussionsAfterPublish'))
.then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS))
- .catch(() => commit(types.RECEIVE_PUBLISH_REVIEW_ERROR));
+ .catch((e) => {
+ commit(types.RECEIVE_PUBLISH_REVIEW_ERROR);
+
+ throw e.response;
+ });
};
export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => {
diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js
index 6d2a4c245cc..a653769b60f 100644
--- a/app/assets/javascripts/behaviors/copy_code.js
+++ b/app/assets/javascripts/behaviors/copy_code.js
@@ -22,6 +22,7 @@ class CopyCodeButton extends HTMLElement {
'data-clipboard-target': `pre#${this.for}`,
});
+ // eslint-disable-next-line no-unsanitized/property
button.innerHTML = spriteIcon('copy-to-clipboard');
return button;
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 07fd6dae76a..4b337dce8f3 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -102,8 +102,12 @@ export default function initCopyToClipboard() {
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
- // Ensure the button has already been tooltip'd.
- add([btnElement], { show: true });
+ const { clipboardHandleTooltip = true } = btnElement.dataset;
+
+ if (parseBoolean(clipboardHandleTooltip)) {
+ // Ensure the button has already been tooltip'd.
+ add([btnElement], { show: true });
+ }
btnElement.click();
}
diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js
index af7aac4cf36..ac41af4df7a 100644
--- a/app/assets/javascripts/behaviors/markdown/render_math.js
+++ b/app/assets/javascripts/behaviors/markdown/render_math.js
@@ -91,6 +91,7 @@ class SafeMathRenderer {
`;
if (!wrapperElement.classList.contains('lazy-alert-shown')) {
+ // eslint-disable-next-line no-unsanitized/property
wrapperElement.innerHTML = html;
wrapperElement.append(codeElement);
wrapperElement.classList.add('lazy-alert-shown');
@@ -111,6 +112,7 @@ class SafeMathRenderer {
}
try {
+ // eslint-disable-next-line no-unsanitized/property
displayContainer.innerHTML = this.katex.renderToString(text, {
displayMode: el.dataset.mathStyle === 'display',
throwOnError: true,
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 82229b5aa8f..97ba9e15c0f 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -1,9 +1,11 @@
import $ from 'jquery';
+import ClipboardJS from 'clipboard';
import Mousetrap from 'mousetrap';
-import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import toast from '~/vue_shared/plugins/global_toast';
+import { s__ } from '~/locale';
import Sidebar from '~/right_sidebar';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
import {
@@ -21,6 +23,15 @@ export default class ShortcutsIssuable extends Shortcuts {
constructor() {
super();
+ this.inMemoryButton = document.createElement('button');
+ this.clipboardInstance = new ClipboardJS(this.inMemoryButton);
+ this.clipboardInstance.on('success', () => {
+ toast(s__('GlobalShortcuts|Copied source branch name to clipboard.'));
+ });
+ this.clipboardInstance.on('error', () => {
+ toast(s__('GlobalShortcuts|Unable to copy the source branch name at this time.'));
+ });
+
Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () =>
ShortcutsIssuable.openSidebarDropdown('assignee'),
);
@@ -32,7 +43,7 @@ export default class ShortcutsIssuable extends Shortcuts {
);
Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue);
- Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName);
+ Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), () => this.copyBranchName());
/**
* We're attaching a global focus event listener on document for
@@ -153,17 +164,14 @@ export default class ShortcutsIssuable extends Shortcuts {
return false;
}
- static copyBranchName() {
- // There are two buttons - one that is shown when the sidebar
- // is expanded, and one that is shown when it's collapsed.
- const allCopyBtns = Array.from(document.querySelectorAll('.js-source-branch-copy'));
+ async copyBranchName() {
+ const button = document.querySelector('.js-source-branch-copy');
+ const branchName = button?.dataset.clipboardText;
- // Select whichever button is currently visible so that
- // the "Copied" tooltip is shown when a click is simulated.
- const visibleBtn = allCopyBtns.find(isElementVisible);
+ if (branchName) {
+ this.inMemoryButton.dataset.clipboardText = branchName;
- if (visibleBtn) {
- clickCopyToClipboardButton(visibleBtn);
+ this.inMemoryButton.dispatchEvent(new CustomEvent('click'));
}
}
}
diff --git a/app/assets/javascripts/blob/3d_viewer/index.js b/app/assets/javascripts/blob/3d_viewer/index.js
index d4efe409fef..2831c37838b 100644
--- a/app/assets/javascripts/blob/3d_viewer/index.js
+++ b/app/assets/javascripts/blob/3d_viewer/index.js
@@ -1,11 +1,8 @@
-import OrbitControlsClass from 'three-orbit-controls';
-import STLLoaderClass from 'three-stl-loader';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
+import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
import * as THREE from 'three/build/three.module';
import MeshObject from './mesh_object';
-const STLLoader = STLLoaderClass(THREE);
-const OrbitControls = OrbitControlsClass(THREE);
-
export default class Renderer {
constructor(container) {
this.renderWrapper = this.render.bind(this);
diff --git a/app/assets/javascripts/blob/3d_viewer/mesh_object.js b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
index c55a9ca8926..5322dc00e86 100644
--- a/app/assets/javascripts/blob/3d_viewer/mesh_object.js
+++ b/app/assets/javascripts/blob/3d_viewer/mesh_object.js
@@ -22,7 +22,7 @@ export default class MeshObject extends Mesh {
if (this.geometry.boundingSphere.radius > 4) {
const scale = 4 / this.geometry.boundingSphere.radius;
- this.geometry.applyMatrix(new Matrix4().makeScale(scale, scale, scale));
+ this.geometry.applyMatrix4(new Matrix4().makeScale(scale, scale, scale));
this.geometry.computeBoundingSphere();
this.position.x = -this.geometry.boundingSphere.center.x;
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index d2a841c88f1..dc1a9cb865a 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -1,11 +1,11 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
-import notebookLab from '~/notebook/index.vue';
+import NotebookLab from '~/notebook/index.vue';
export default {
components: {
- notebookLab,
+ NotebookLab,
GlLoadingIcon,
},
props: {
@@ -66,7 +66,7 @@ export default {
<div v-if="loading && !error" class="text-center loading">
<gl-loading-icon class="mt-5" size="lg" />
</div>
- <notebook-lab v-if="!loading && !error" :notebook="json" code-css-class="code white" />
+ <notebook-lab v-if="!loading && !error" :notebook="json" />
<p v-if="error" class="text-center">
<span v-if="loadError" ref="loadErrorMessage">{{
__('An error occurred while loading the file. Please try again later.')
diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js
index a92161bbc1b..bb29224cda2 100644
--- a/app/assets/javascripts/blob/sketch/index.js
+++ b/app/assets/javascripts/blob/sketch/index.js
@@ -1,5 +1,5 @@
import JSZip from 'jszip';
-import JSZipUtils from 'jszip-utils';
+import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
export default class SketchLoader {
@@ -7,35 +7,28 @@ export default class SketchLoader {
this.container = container;
this.loadingIcon = this.container.querySelector('.js-loading-icon');
- this.load();
+ this.load().catch(() => {
+ this.error();
+ });
}
- load() {
- return this.getZipFile()
- .then((data) => JSZip.loadAsync(data))
- .then((asyncResult) => asyncResult.files['previews/preview.png'].async('uint8array'))
- .then((content) => {
- const url = window.URL || window.webkitURL;
- const blob = new Blob([new Uint8Array(content)], {
- type: 'image/png',
- });
- const previewUrl = url.createObjectURL(blob);
+ async load() {
+ const zipContents = await this.getZipContents();
+ const previewContents = await zipContents.files['previews/preview.png'].async('uint8array');
+
+ const blob = new Blob([previewContents], {
+ type: 'image/png',
+ });
- this.render(previewUrl);
- })
- .catch(this.error.bind(this));
+ this.render(window.URL.createObjectURL(blob));
}
- getZipFile() {
- return new Promise((resolve, reject) => {
- JSZipUtils.getBinaryContent(this.container.dataset.endpoint, (err, data) => {
- if (err) {
- reject(err);
- } else {
- resolve(data);
- }
- });
+ async getZipContents() {
+ const { data } = await axios.get(this.container.dataset.endpoint, {
+ responseType: 'arraybuffer',
});
+
+ return JSZip.loadAsync(data);
}
render(previewUrl) {
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index a0d4f7ef4f2..5ca3f131d99 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -45,6 +45,7 @@ const loadViewer = (viewerParam) => {
viewer.dataset.loading = 'true';
return axios.get(url).then(({ data }) => {
+ // eslint-disable-next-line no-unsanitized/property
viewer.innerHTML = data.html;
window.requestIdleCallback(() => {
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index 425de914c17..d73e1cc43b0 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -63,6 +63,7 @@ export default () => {
const isMarkdown = editBlobForm.data('is-markdown');
const previewMarkdownPath = editBlobForm.data('previewMarkdownPath');
const commitButton = $('.js-commit-button');
+ const commitButtonLoading = $('.js-commit-button-loading');
const cancelLink = $('#cancel-changes');
import('./edit_blob')
@@ -88,6 +89,8 @@ export default () => {
});
commitButton.on('click', () => {
+ commitButton.addClass('gl-display-none');
+ commitButtonLoading.removeClass('gl-display-none');
window.onbeforeunload = null;
});
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 c4a2f83ab50..1899d42fa4d 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
@@ -102,7 +102,7 @@ export default {
data-qa-selector="board_add_new_list"
>
<div
- class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
>
<h3 class="gl-font-size-h2 gl-px-5 gl-py-5 gl-m-0" data-testid="board-add-column-form-title">
{{ $options.i18n.newList }}
diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue
index b81edb4dfe6..3f8a596abd8 100644
--- a/app/assets/javascripts/boards/components/board_blocked_icon.vue
+++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue
@@ -1,7 +1,7 @@
<script>
import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui';
import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants';
-import { TYPE_ISSUE } from '~/graphql_shared/constants';
+import { TYPE_ISSUE, TYPE_EPIC } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { truncate } from '~/lib/utils/text_utility';
import { __, n__, s__, sprintf } from '~/locale';
@@ -10,10 +10,12 @@ export default {
i18n: {
issuableType: {
[issuableTypes.issue]: __('issue'),
+ [issuableTypes.epic]: __('epic'),
},
},
graphQLIdType: {
[issuableTypes.issue]: TYPE_ISSUE,
+ [issuableTypes.epic]: TYPE_EPIC,
},
referenceFormatter: {
[issuableTypes.issue]: (r) => r.split('/')[1],
@@ -40,7 +42,7 @@ export default {
type: String,
required: true,
validator(value) {
- return [issuableTypes.issue].includes(value);
+ return [issuableTypes.issue, issuableTypes.epic].includes(value);
},
},
},
@@ -53,14 +55,21 @@ export default {
return blockingIssuablesQueries[this.issuableType].query;
},
variables() {
+ if (this.isEpic) {
+ return {
+ fullPath: this.item.group.fullPath,
+ iid: Number(this.item.iid),
+ };
+ }
return {
id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id),
};
},
update(data) {
this.skip = true;
+ const issuable = this.isEpic ? data?.group?.issuable : data?.issuable;
- return data?.issuable?.blockingIssuables?.nodes || [];
+ return issuable?.blockingIssuables?.nodes || [];
},
error(error) {
const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), {
@@ -77,13 +86,16 @@ export default {
};
},
computed: {
+ isEpic() {
+ return this.issuableType === issuableTypes.epic;
+ },
displayedIssuables() {
const { defaultDisplayLimit, referenceFormatter } = this.$options;
return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => {
return {
...i,
title: truncate(i.title, this.$options.textTruncateWidth),
- reference: referenceFormatter[this.issuableType](i.reference),
+ reference: this.isEpic ? i.reference : referenceFormatter[this.issuableType](i.reference),
};
});
},
@@ -106,6 +118,9 @@ export default {
},
);
},
+ blockIcon() {
+ return this.issuableType === issuableTypes.issue ? 'issue-block' : 'entity-blocked';
+ },
glIconId() {
return `blocked-icon-${this.uniqueId}`;
},
@@ -153,8 +168,8 @@ export default {
<gl-icon
:id="glIconId"
ref="icon"
- name="issue-block"
- class="issue-blocked-icon gl-mr-2 gl-cursor-pointer"
+ :name="blockIcon"
+ class="issue-blocked-icon gl-mr-2 gl-cursor-pointer gl-text-red-500"
data-testid="issue-blocked-icon"
@mouseenter="handleMouseEnter"
/>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 3638fdd2ca5..44c16324950 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -30,6 +30,11 @@ export default {
default: 0,
required: false,
},
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
computed: {
...mapState(['selectedBoardItems', 'activeId']),
@@ -81,10 +86,10 @@ export default {
data-qa-selector="board_card"
:class="[
{
- 'multi-select': multiSelectVisible,
+ 'multi-select gl-bg-blue-50 gl-border-blue-200': multiSelectVisible,
'gl-cursor-grab': isDraggable,
'is-disabled': isDisabled,
- 'is-active': isActive,
+ 'is-active gl-bg-blue-50': isActive,
'gl-cursor-not-allowed gl-bg-gray-10': item.isLoading,
},
colorClass,
@@ -95,9 +100,15 @@ export default {
:data-item-path="item.referencePath"
:style="cardStyle"
data-testid="board_card"
- class="board-card gl-p-5 gl-rounded-base"
+ class="board-card gl-p-5 gl-rounded-base gl-line-height-normal gl-relative gl-mb-3"
@click="toggleIssue($event)"
>
- <board-card-inner :list="list" :item="item" :update-filters="true" />
+ <board-card-inner
+ :list="list"
+ :item="item"
+ :update-filters="true"
+ :index="index"
+ :show-work-item-type-icon="showWorkItemTypeIcon"
+ />
</li>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 8dc521317cd..92a623d65d4 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -15,6 +15,8 @@ import { updateHistory } from '~/lib/utils/url_utility';
import { sprintf, __, n__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import BoardCardMoveToPosition from '~/boards/components/board_card_move_to_position.vue';
+import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import { ListType } from '../constants';
import eventHub from '../eventhub';
import BoardBlockedIcon from './board_blocked_icon.vue';
@@ -34,6 +36,10 @@ export default {
IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
BoardBlockedIcon,
GlSprintf,
+ BoardCardMoveToPosition,
+ WorkItemTypeIcon,
+ IssueHealthStatus: () =>
+ import('ee_component/related_items_tree/components/issue_health_status.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -55,6 +61,15 @@ export default {
required: false,
default: false,
},
+ index: {
+ type: Number,
+ required: true,
+ },
+ showWorkItemTypeIcon: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -202,7 +217,7 @@ export default {
<template>
<div>
<div class="gl-display-flex" dir="auto">
- <h4 class="board-card-title gl-mb-0 gl-mt-0">
+ <h4 class="board-card-title gl-mb-0 gl-mt-0 gl-mr-3 gl-font-base gl-overflow-break-word">
<board-blocked-icon
v-if="item.blocked"
:item="item"
@@ -215,7 +230,7 @@ export default {
v-gl-tooltip
name="eye-slash"
:title="__('Confidential')"
- class="confidential-icon gl-mr-2"
+ class="confidential-icon gl-mr-2 gl-text-orange-500 gl-cursor-help"
:aria-label="__('Confidential')"
/>
<gl-icon
@@ -223,24 +238,25 @@ export default {
v-gl-tooltip
name="spam"
:title="__('This issue is hidden because its author has been banned')"
- class="gl-mr-2 hidden-icon"
+ class="gl-mr-2 hidden-icon gl-text-orange-500 gl-cursor-help"
data-testid="hidden-icon"
/>
<a
:href="item.path || item.webUrl || ''"
:title="item.title"
:class="{ 'gl-text-gray-400!': item.isLoading }"
- class="js-no-trigger"
+ class="js-no-trigger gl-text-body gl-hover-text-gray-900"
@mousemove.stop
>{{ item.title }}</a
>
</h4>
+ <board-card-move-to-position :item="item" :list="list" :index="index" />
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
<template v-for="label in orderedLabels">
<gl-label
:key="label.id"
- class="js-no-trigger"
+ class="js-no-trigger gl-mt-2 gl-mr-2"
:background-color="label.color"
:title="label.title"
:description="label.description"
@@ -260,9 +276,14 @@ export default {
<gl-loading-icon v-if="item.isLoading" size="lg" class="gl-mt-5" />
<span
v-if="item.referencePath"
- class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
+ class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3 gl-text-secondary"
:class="{ 'gl-font-base': isEpicBoard }"
>
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="item.type"
+ show-tooltip-on-hover
+ />
<tooltip-on-truncate
v-if="showReferencePath"
:title="itemReferencePath"
@@ -321,7 +342,10 @@ export default {
</p>
</gl-tooltip>
- <span ref="countBadge" class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3">
+ <span
+ ref="countBadge"
+ class="board-card-info gl-mr-0 gl-pr-0 gl-pl-3 gl-text-secondary gl-cursor-help"
+ >
<span v-if="allowSubEpics" class="gl-mr-3">
<gl-icon name="epic" />
{{ totalEpicsCount }}
@@ -339,7 +363,7 @@ export default {
<span
v-if="shouldRenderEpicProgress"
ref="progressBadge"
- class="board-card-info gl-pl-0"
+ class="board-card-info gl-pl-0 gl-text-secondary gl-cursor-help"
>
<span class="gl-mr-3" data-testid="epic-progress">
<gl-icon name="progress" />
@@ -359,10 +383,11 @@ export default {
:weight="item.weight"
@click="filterByWeight(item.weight)"
/>
+ <issue-health-status v-if="item.healthStatus" :health-status="item.healthStatus" />
</span>
</span>
</div>
- <div class="board-card-assignee gl-display-flex gl-gap-3">
+ <div class="board-card-assignee gl-display-flex gl-gap-3 gl-mb-n2">
<user-avatar-link
v-for="assignee in cappedAssignees"
:key="assignee.id"
@@ -370,7 +395,7 @@ export default {
:img-alt="avatarUrlTitle(assignee)"
:img-src="avatarUrl(assignee)"
:img-size="avatarSize"
- class="js-no-trigger"
+ class="js-no-trigger user-avatar-link"
tooltip-placement="bottom"
:enforce-gl-avatar="true"
>
@@ -384,7 +409,7 @@ export default {
v-if="shouldRenderCounter"
v-gl-tooltip
:title="assigneeCounterTooltip"
- class="avatar-counter"
+ class="avatar-counter gl-bg-gray-400 gl-cursor-help gl-font-weight-bold gl-ml-n4 gl-border-0 gl-line-height-24"
data-placement="bottom"
>{{ assigneeCounterLabel }}</span
>
diff --git a/app/assets/javascripts/boards/components/board_card_move_to_position.vue b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
new file mode 100644
index 00000000000..ff938219475
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card_move_to_position.vue
@@ -0,0 +1,128 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { s__ } from '~/locale';
+
+import Tracking from '~/tracking';
+
+export default {
+ i18n: {
+ moveToStartText: s__('Boards|Move to start of list'),
+ moveToEndText: s__('Boards|Move to end of list'),
+ },
+ name: 'BoardCardMoveToPosition',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ validator: (item) => ['id', 'iid', 'referencePath'].every((key) => item[key]),
+ },
+ list: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['pageInfoByListId']),
+ ...mapGetters(['getBoardItemsByList']),
+ tracking() {
+ return {
+ category: 'boards:list',
+ label: 'move_to_position',
+ property: `type_card`,
+ };
+ },
+ listItems() {
+ return this.getBoardItemsByList(this.list.id);
+ },
+ listHasNextPage() {
+ return this.pageInfoByListId[this.list.id]?.hasNextPage;
+ },
+ lengthOfListItemsInBoard() {
+ return this.listItems?.length;
+ },
+ itemIdentifier() {
+ return `${this.item.id}-${this.item.iid}-${this.index}`;
+ },
+ isFirstItemInList() {
+ return this.index === 0;
+ },
+ isLastItemInList() {
+ return this.index === this.lengthOfListItemsInBoard - 1;
+ },
+ },
+ methods: {
+ ...mapActions(['moveItem']),
+ moveToStart() {
+ this.track('click_toggle_button', {
+ label: 'move_to_start',
+ });
+ /** in case it is the first in the list don't call any action/mutation * */
+ if (this.isFirstItemInList) {
+ return;
+ }
+ this.moveToPosition({
+ positionInList: 0,
+ });
+ },
+ moveToEnd() {
+ this.track('click_toggle_button', {
+ label: 'move_to_end',
+ });
+ /** in case it is the last in the list don't call any action/mutation * */
+ if (this.isLastItemInList) {
+ return;
+ }
+ this.moveToPosition({
+ positionInList: -1,
+ });
+ },
+ moveToPosition({ positionInList }) {
+ this.moveItem({
+ itemId: this.item.id,
+ itemIid: this.item.iid,
+ itemPath: this.item.referencePath,
+ fromListId: this.list.id,
+ toListId: this.list.id,
+ positionInList,
+ atIndex: this.index,
+ allItemsLoadedInList: !this.listHasNextPage,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ ref="dropdown"
+ :key="itemIdentifier"
+ icon="ellipsis_v"
+ :text="s__('Boards|Move card')"
+ :text-sr-only="true"
+ class="move-to-position gl-display-block gl-mb-2 gl-ml-2 gl-mt-n3 gl-mr-n3"
+ category="tertiary"
+ :tabindex="index"
+ no-caret
+ @keydown.esc.native="$emit('hide')"
+ >
+ <div>
+ <gl-dropdown-item @click.stop="moveToStart">
+ {{ $options.i18n.moveToStartText }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click.stop="moveToEnd">
+ {{ $options.i18n.moveToEndText }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index bcf5b12b209..8fc76c02e14 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -76,7 +76,7 @@ export default {
<div
:class="{
'is-draggable': isListDraggable,
- 'is-collapsed': list.collapsed,
+ 'is-collapsed gl-w-10': list.collapsed,
'board-type-assignee': list.listType === 'assignee',
}"
:data-list-id="list.id"
@@ -84,7 +84,7 @@ export default {
data-qa-selector="board_list"
>
<div
- class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
+ class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50"
:class="{ 'board-column-highlighted': highlighted }"
>
<board-list-header :list="list" :disabled="disabled" />
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 8868b9b2f3e..d99afa8455d 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -75,7 +75,7 @@ export default {
v-if="!isSwimlanesOn"
ref="list"
v-bind="draggableOptions"
- class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap"
+ class="boards-list gl-w-full gl-py-5 gl-pr-3 gl-white-space-nowrap gl-overflow-x-scroll"
@end="moveList"
>
<board-column
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index d25169b5b9d..00b4e6c96a9 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -57,6 +57,9 @@ export default {
labelsFilterBasePath: {
default: '',
},
+ canUpdate: {
+ default: false,
+ },
},
inheritAttrs: false,
computed: {
@@ -163,6 +166,7 @@ export default {
:full-path="fullPath"
:initial-assignees="activeBoardItem.assignees"
:allow-multiple-assignees="multipleAssigneesFeatureAvailable"
+ :editable="canUpdate"
@assignees-updated="setAssignees"
/>
<sidebar-dropdown-widget
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 66388f4eb43..edf1a5ee7e6 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -66,7 +66,7 @@ export default {
},
},
computed: {
- ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams']),
+ ...mapState(['pageInfoByListId', 'listsFlags', 'filterParams', 'isUpdateIssueOrderInProgress']),
...mapGetters(['isEpicBoard']),
listItemsCount() {
return this.isEpicBoard ? this.list.epicsCount : this.boardList?.issuesCount;
@@ -132,6 +132,9 @@ export default {
return this.canMoveIssue ? options : {};
},
+ disableScrollingWhenMutationInProgress() {
+ return this.hasNextPage && this.isUpdateIssueOrderInProgress;
+ },
},
watch: {
boardItems() {
@@ -265,7 +268,7 @@ export default {
<template>
<div
v-show="!list.collapsed"
- class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
+ class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column gl-min-h-0"
data-qa-selector="board_list_cards_area"
>
<div
@@ -285,9 +288,13 @@ export default {
v-bind="treeRootOptions"
:data-board="list.id"
:data-board-type="list.listType"
- :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }"
+ :class="{
+ 'bg-danger-100': boardItemsSizeExceedsMax,
+ 'gl-overflow-hidden': disableScrollingWhenMutationInProgress,
+ 'gl-overflow-y-auto': !disableScrollingWhenMutationInProgress,
+ }"
draggable=".board-card"
- class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0"
+ class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-3 gl-pt-0 gl-overflow-x-hidden"
data-testid="tree-root-wrapper"
@start="handleDragOnStart"
@end="handleDragOnEnd"
@@ -301,9 +308,14 @@ export default {
:item="item"
:data-draggable-item-type="$options.draggableItemTypes.card"
:disabled="disabled"
+ :show-work-item-type-icon="!isEpicBoard"
/>
<gl-intersection-observer @appear="onReachingListBottom">
- <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
+ <li
+ v-if="showCount"
+ class="board-list-count gl-text-center gl-text-secondary gl-py-4"
+ data-issue-id="-1"
+ >
<gl-loading-icon
v-if="loadingMore"
size="sm"
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index e3012f5b36d..230fa4e1e0f 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -252,7 +252,7 @@ export default {
<header
:class="{
'gl-h-full': list.collapsed,
- 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
+ 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base gl-bg-gray-50': isSwimlanesHeader,
}"
:style="headerStyle"
class="board-header gl-relative"
@@ -267,14 +267,15 @@ export default {
'gl-py-2': list.collapsed && isSwimlanesHeader,
'gl-flex-direction-column': list.collapsed,
}"
- class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3"
+ class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 gl-h-9"
>
<gl-button
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
- class="board-title-caret no-drag gl-cursor-pointer"
+ class="board-title-caret no-drag gl-cursor-pointer gl-hover-bg-gray-50"
+ :class="{ 'gl-mt-1': list.collapsed, 'gl-mr-2': !list.collapsed }"
category="tertiary"
size="small"
data-testid="board-title-caret"
@@ -307,6 +308,7 @@ export default {
'gl-display-none': list.collapsed && isSwimlanesHeader,
'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
'gl-flex-grow-1': !list.collapsed,
+ 'gl-rotate-90': list.collapsed,
}"
>
<!-- EE start -->
@@ -324,7 +326,7 @@ export default {
<span
v-if="listType === 'assignee'"
v-show="!list.collapsed"
- class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
+ class="gl-ml-2 gl-font-weight-normal gl-text-secondary"
>
@{{ listAssignee }}
</span>
@@ -345,7 +347,7 @@ export default {
v-if="isSwimlanesHeader && list.collapsed"
ref="collapsedInfo"
aria-hidden="true"
- class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500"
+ class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-secondary gl-hover-text-gray-900"
>
<gl-icon name="information" />
</span>
@@ -369,14 +371,14 @@ export default {
<!-- EE end -->
<div
- class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-gray-500"
+ class="issue-count-badge gl-display-inline-flex gl-pr-2 no-drag gl-text-secondary"
data-testid="issue-count-badge"
:class="{
'gl-display-none!': list.collapsed && isSwimlanesHeader,
'gl-p-0': list.collapsed,
}"
>
- <span class="gl-display-inline-flex">
+ <span class="gl-display-inline-flex" :class="{ 'gl-rotate-90': list.collapsed }">
<gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" />
<span ref="itemCount" class="gl-display-inline-flex gl-align-items-center">
<gl-icon class="gl-mr-2" :name="countIcon" :size="16" />
diff --git a/app/assets/javascripts/boards/components/board_new_item.vue b/app/assets/javascripts/boards/components/board_new_item.vue
index 600917683cd..084b7519d1f 100644
--- a/app/assets/javascripts/boards/components/board_new_item.vue
+++ b/app/assets/javascripts/boards/components/board_new_item.vue
@@ -69,7 +69,7 @@ export default {
</script>
<template>
- <div class="board-new-issue-form">
+ <div class="board-new-issue-form gl-z-index-3 gl-m-3">
<div class="board-card position-relative gl-p-5 rounded">
<gl-form @submit.prevent="handleFormSubmit" @reset="handleFormCancel">
<label :for="inputFieldId" class="gl-font-weight-bold">{{ __('Title') }}</label>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index 73ec008c2b6..b09b1d48ca5 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltip, GlIcon } from '@gitlab/ui';
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
import {
getDayDifference,
getTimeago,
@@ -85,7 +85,11 @@ export default {
<template>
<span>
- <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
+ <span
+ ref="issueDueDate"
+ :class="cssClass"
+ class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help"
+ >
<gl-icon
:class="{ 'text-danger': isPastDue }"
class="board-card-info-icon gl-mr-2"
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 9312db06efe..bc12717a92d 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -36,7 +36,7 @@ export default {
<template>
<span>
- <span ref="issueTimeEstimate" class="board-card-info card-number">
+ <span ref="issueTimeEstimate" class="board-card-info gl-mr-3 gl-text-secondary gl-cursor-help">
<gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" />
<time class="board-card-info-text">{{ timeEstimate }}</time>
</span>
diff --git a/app/assets/javascripts/boards/components/item_count.vue b/app/assets/javascripts/boards/components/item_count.vue
index a11c23e5625..dab82abb646 100644
--- a/app/assets/javascripts/boards/components/item_count.vue
+++ b/app/assets/javascripts/boards/components/item_count.vue
@@ -30,7 +30,9 @@ export default {
{{ itemsSize }}
</span>
<span v-if="isMaxLimitSet" class="max-issue-size">
- {{ maxIssueCount }}
+ <!-- eslint-disable @gitlab/vue-require-i18n-strings -->
+ {{ `/ ${maxIssueCount}` }}
+ <!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</span>
</div>
</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 0f290f566ba..ed22a375271 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql';
import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql';
import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql';
+import boardBlockingEpicsQuery from './graphql/board_blocking_epics.query.graphql';
import destroyBoardListMutation from './graphql/board_list_destroy.mutation.graphql';
import updateBoardListMutation from './graphql/board_list_update.mutation.graphql';
@@ -70,6 +71,9 @@ export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
},
+ [issuableTypes.epic]: {
+ query: boardBlockingEpicsQuery,
+ },
};
export const updateListQueries = {
@@ -146,3 +150,5 @@ export default {
BoardType,
ListType,
};
+
+export const DEFAULT_BOARD_LIST_ITEMS_SIZE = 10;
diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js
index 1745ab3bab4..a452d32ef15 100644
--- a/app/assets/javascripts/boards/filters/due_date_filters.js
+++ b/app/assets/javascripts/boards/filters/due_date_filters.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import Vue from 'vue';
+import dateFormat from '~/lib/dateformat';
Vue.filter('due-date', (value) => {
const date = new Date(value);
diff --git a/app/assets/javascripts/boards/graphql/board.fragment.graphql b/app/assets/javascripts/boards/graphql/board.fragment.graphql
deleted file mode 100644
index 872a4c4afbc..00000000000
--- a/app/assets/javascripts/boards/graphql/board.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment BoardFragment on Board {
- id
- name
-}
diff --git a/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql
new file mode 100644
index 00000000000..071a6d7410f
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/board_blocking_epics.query.graphql
@@ -0,0 +1,17 @@
+query BoardBlockingEpics($fullPath: ID!, $iid: ID) {
+ group(fullPath: $fullPath) {
+ id
+ issuable: epic(iid: $iid) {
+ id
+ blockingIssuables: blockedByEpics {
+ nodes {
+ id
+ iid
+ title
+ reference(full: true)
+ webUrl
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
index 0823c4f5a83..ce9f7bbfd2a 100644
--- a/app/assets/javascripts/boards/graphql/group_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
boards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
index 827c08486b1..b9fe778d4d4 100644
--- a/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_recent_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query group_recent_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
id
recentIssueBoards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
index b8879bc260c..770c246a95b 100644
--- a/app/assets/javascripts/boards/graphql/project_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
boards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
index 4d38e9b0498..c633107a409 100644
--- a/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_recent_boards.query.graphql
@@ -1,12 +1,11 @@
-#import "ee_else_ce/boards/graphql/board.fragment.graphql"
-
query project_recent_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
id
recentIssueBoards {
edges {
node {
- ...BoardFragment
+ id
+ name
}
}
}
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index 791182af806..c2e346da606 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -15,6 +15,7 @@ import {
FilterFields,
ListTypeTitles,
DraggableItemTypes,
+ DEFAULT_BOARD_LIST_ITEMS_SIZE,
} from 'ee_else_ce/boards/constants';
import {
formatIssueInput,
@@ -429,7 +430,7 @@ export default {
filters: filterParams,
isGroup: boardType === BoardType.group,
isProject: boardType === BoardType.project,
- first: 10,
+ first: DEFAULT_BOARD_LIST_ITEMS_SIZE,
after: fetchNext ? state.pageInfoByListId[listId].endCursor : undefined,
};
@@ -478,16 +479,25 @@ export default {
toListId,
moveBeforeId,
moveAfterId,
+ positionInList,
+ allItemsLoadedInList,
} = moveData;
commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId });
+ if (reordering && !allItemsLoadedInList && positionInList === -1) {
+ return;
+ }
+
if (reordering) {
commit(types.ADD_BOARD_ITEM_TO_LIST, {
itemId,
listId: toListId,
moveBeforeId,
moveAfterId,
+ positionInList,
+ atIndex: originalIndex,
+ allItemsLoadedInList,
});
return;
@@ -499,6 +509,7 @@ export default {
listId: toListId,
moveBeforeId,
moveAfterId,
+ positionInList,
});
}
@@ -552,7 +563,15 @@ export default {
updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => {
try {
- const { itemId, fromListId, toListId, moveBeforeId, moveAfterId, itemNotInToList } = moveData;
+ const {
+ itemId,
+ fromListId,
+ toListId,
+ moveBeforeId,
+ moveAfterId,
+ itemNotInToList,
+ positionInList,
+ } = moveData;
const {
fullBoardId,
filterParams,
@@ -561,6 +580,8 @@ export default {
},
} = state;
+ commit(types.MUTATE_ISSUE_IN_PROGRESS, true);
+
const { data } = await gqlClient.mutate({
mutation: issueMoveListMutation,
variables: {
@@ -571,6 +592,7 @@ export default {
toListId: getIdFromGraphQLId(toListId),
moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined,
moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined,
+ positionInList,
// 'mutationVariables' allows EE code to pass in extra parameters.
...mutationVariables,
},
@@ -642,7 +664,9 @@ export default {
}
commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue });
+ commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
} catch {
+ commit(types.MUTATE_ISSUE_IN_PROGRESS, false);
commit(
types.SET_ERROR,
s__('Boards|An error occurred while moving the issue. Please try again.'),
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 43268f21f96..0e496677b7b 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -44,3 +44,4 @@ export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
export const SET_ERROR = 'SET_ERROR';
+export const MUTATE_ISSUE_IN_PROGRESS = 'MUTATE_ISSUE_IN_PROGRESS';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 26a98a645b3..44abb2030c7 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -20,17 +20,28 @@ export const removeItemFromList = ({ state, listId, itemId }) => {
updateListItemsCount({ state, listId, value: -1 });
};
-export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => {
+export const addItemToList = ({
+ state,
+ listId,
+ itemId,
+ moveBeforeId,
+ moveAfterId,
+ atIndex,
+ positionInList,
+}) => {
const listIssues = state.boardItemsByListId[listId];
let newIndex = atIndex || 0;
+ const moveToStartOrLast = positionInList !== undefined;
if (moveBeforeId) {
newIndex = listIssues.indexOf(moveBeforeId) + 1;
} else if (moveAfterId) {
newIndex = listIssues.indexOf(moveAfterId);
+ } else if (moveToStartOrLast) {
+ newIndex = positionInList === -1 ? listIssues.length : 0;
}
listIssues.splice(newIndex, 0, itemId);
Vue.set(state.boardItemsByListId, listId, listIssues);
- updateListItemsCount({ state, listId, value: 1 });
+ updateListItemsCount({ state, listId, value: moveToStartOrLast ? 0 : 1 });
};
export default {
@@ -205,12 +216,34 @@ export default {
Vue.set(state.boardItems, issue.id, formatIssue(issue));
},
+ [mutationTypes.MUTATE_ISSUE_IN_PROGRESS](state, isLoading) {
+ state.isUpdateIssueOrderInProgress = isLoading;
+ },
+
[mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
state,
- { itemId, listId, moveBeforeId, moveAfterId, atIndex, inProgress = false },
+ {
+ itemId,
+ listId,
+ moveBeforeId,
+ moveAfterId,
+ atIndex,
+ positionInList,
+ allItemsLoadedInList,
+ inProgress = false,
+ },
) => {
Vue.set(state.listsFlags, listId, { ...state.listsFlags, addItemToListInProgress: inProgress });
- addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex });
+ addItemToList({
+ state,
+ listId,
+ itemId,
+ moveBeforeId,
+ moveAfterId,
+ atIndex,
+ positionInList,
+ allItemsLoadedInList,
+ });
},
[mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => {
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index b62c032b921..bf3f777ea7d 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -40,4 +40,5 @@ export default () => ({
},
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,
+ isUpdateIssueOrderInProgress: false,
});
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
index 83bad9eb518..59ddf4b19d8 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_admin_variables.vue
@@ -11,11 +11,11 @@ import {
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';
+import CiVariableSettings from './ci_variable_settings.vue';
export default {
components: {
- ciVariableSettings,
+ CiVariableSettings,
},
inject: ['endpoint'],
data() {
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
index 3af83ffa8ed..3522243e3e7 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue
@@ -14,11 +14,11 @@ import {
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';
+import CiVariableSettings from './ci_variable_settings.vue';
export default {
components: {
- ciVariableSettings,
+ CiVariableSettings,
},
mixins: [glFeatureFlagsMixin()],
inject: ['endpoint', 'groupPath', 'groupId'],
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
new file mode 100644
index 00000000000..29db02a3c59
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue
@@ -0,0 +1,120 @@
+<script>
+import createFlash from '~/flash';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import getProjectEnvironments from '../graphql/queries/project_environments.query.graphql';
+import getProjectVariables from '../graphql/queries/project_variables.query.graphql';
+import { mapEnvironmentNames } from '../utils';
+import {
+ ADD_MUTATION_ACTION,
+ DELETE_MUTATION_ACTION,
+ GRAPHQL_PROJECT_TYPE,
+ UPDATE_MUTATION_ACTION,
+ environmentFetchErrorText,
+ genericMutationErrorText,
+ variableFetchErrorText,
+} from '../constants';
+import addProjectVariable from '../graphql/mutations/project_add_variable.mutation.graphql';
+import deleteProjectVariable from '../graphql/mutations/project_delete_variable.mutation.graphql';
+import updateProjectVariable from '../graphql/mutations/project_update_variable.mutation.graphql';
+import CiVariableSettings from './ci_variable_settings.vue';
+
+export default {
+ components: {
+ CiVariableSettings,
+ },
+ inject: ['endpoint', 'projectFullPath', 'projectId'],
+ data() {
+ return {
+ projectEnvironments: [],
+ projectVariables: [],
+ };
+ },
+ apollo: {
+ projectEnvironments: {
+ query: getProjectEnvironments,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ };
+ },
+ update(data) {
+ return mapEnvironmentNames(data?.project?.environments?.nodes);
+ },
+ error() {
+ createFlash({ message: environmentFetchErrorText });
+ },
+ },
+ projectVariables: {
+ query: getProjectVariables,
+ variables() {
+ return {
+ fullPath: this.projectFullPath,
+ };
+ },
+ update(data) {
+ return data?.project?.ciVariables?.nodes || [];
+ },
+ error() {
+ createFlash({ message: variableFetchErrorText });
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return (
+ this.$apollo.queries.projectVariables.loading ||
+ this.$apollo.queries.projectEnvironments.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.projectFullPath,
+ projectId: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, this.projectId),
+ variable,
+ },
+ });
+
+ const { errors } = data[currentMutation.name];
+ if (errors.length > 0) {
+ createFlash({ message: errors[0] });
+ }
+ } catch (e) {
+ createFlash({ message: genericMutationErrorText });
+ }
+ },
+ },
+ mutationData: {
+ [ADD_MUTATION_ACTION]: { action: addProjectVariable, name: 'addProjectVariable' },
+ [UPDATE_MUTATION_ACTION]: { action: updateProjectVariable, name: 'updateProjectVariable' },
+ [DELETE_MUTATION_ACTION]: { action: deleteProjectVariable, name: 'deleteProjectVariable' },
+ },
+};
+</script>
+
+<template>
+ <ci-variable-settings
+ :are-scoped-variables-available="true"
+ :environments="projectEnvironments"
+ :is-loading="isLoading"
+ :variables="projectVariables"
+ @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 5ba63de8c96..56c1804910a 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
@@ -108,7 +108,6 @@ export default {
return {
newEnvironments: [],
isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true',
- typeOptions: variableOptions,
validationErrorEventProperty: '',
variable: { ...defaultVariableState, ...this.selectedVariable },
};
@@ -259,6 +258,7 @@ export default {
},
},
defaultScope: allEnvironments.text,
+ variableOptions,
};
</script>
@@ -277,6 +277,7 @@ export default {
v-model="variable.key"
:token-list="$options.tokenList"
:label-text="__('Key')"
+ data-testid="pipeline-form-ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
@@ -293,21 +294,26 @@ export default {
:state="variableValidationState"
rows="3"
max-rows="6"
+ data-testid="pipeline-form-ci-variable-value"
data-qa-selector="ci_variable_value_field"
class="gl-font-monospace!"
/>
</gl-form-group>
- <div class="d-flex">
- <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5">
+ <div class="gl-display-flex">
+ <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="gl-w-half gl-mr-5">
<gl-form-select
id="ci-variable-type"
v-model="variable.variableType"
- :options="typeOptions"
+ :options="$options.variableOptions"
/>
</gl-form-group>
- <gl-form-group label-for="ci-variable-env" class="w-50" data-testid="environment-scope">
+ <gl-form-group
+ label-for="ci-variable-env"
+ class="gl-w-half"
+ data-testid="environment-scope"
+ >
<template #label>
{{ __('Environment scope') }}
<gl-link
@@ -380,7 +386,7 @@ export default {
data-testid="aws-guidance-tip"
@dismiss="dismissTip"
>
- <div class="gl-display-flex gl-flex-direction-row">
+ <div class="gl-display-flex gl-flex-direction-row gl-flex-wrap-wrap gl-md-flex-wrap-nowrap">
<div>
<p>
<gl-sprintf :message="$options.awsTipMessage">
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 cebb7eb85ac..1fbe52388c9 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
@@ -255,6 +255,7 @@ export default {
v-model="key"
:token-list="$options.tokenList"
:label-text="__('Key')"
+ data-testid="pipeline-form-ci-variable-key"
data-qa-selector="ci_variable_key_field"
/>
@@ -271,6 +272,7 @@ export default {
:state="variableValidationState"
rows="3"
max-rows="6"
+ data-testid="pipeline-form-ci-variable-value"
data-qa-selector="ci_variable_value_field"
class="gl-font-monospace!"
/>
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index 5d22974ffbb..e2dd28cdaa1 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -10,7 +10,7 @@ export const displayText = {
};
export const variableTypes = {
- variableType: 'ENV_VAR',
+ envType: 'ENV_VAR',
fileType: 'FILE',
};
@@ -29,13 +29,13 @@ export const allEnvironments = {
export const variableText = {
[types.variableType]: __('Variable'),
[types.fileType]: __('File'),
- [variableTypes.variableType]: __('Variable'),
+ [variableTypes.envType]: __('Variable'),
[variableTypes.fileType]: __('File'),
};
export const variableOptions = [
- { value: types.variableType, text: variableText[types.variableType] },
- { value: types.fileType, text: variableText[types.fileType] },
+ { value: variableTypes.envType, text: variableText[variableTypes.envType] },
+ { value: variableTypes.fileType, text: variableText[variableTypes.fileType] },
];
export const defaultVariableState = {
@@ -44,7 +44,7 @@ export const defaultVariableState = {
masked: false,
protected: false,
value: '',
- variableType: types.variableType,
+ variableType: variableTypes.envType,
};
// eslint-disable-next-line @gitlab/require-i18n-strings
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
new file mode 100644
index 00000000000..45109762e80
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/client/add_project_environment.mutation.graphql
@@ -0,0 +1,3 @@
+mutation addProjectEnvironment($environment: CiEnvironment, $fullPath: ID!) {
+ addProjectEnvironment(environment: $environment, fullPath: $fullPath) @client
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
new file mode 100644
index 00000000000..ab3a46da854
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation addProjectVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $projectId: ID!
+) {
+ addProjectVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ projectId: $projectId
+ ) @client {
+ project {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiProjectVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
new file mode 100644
index 00000000000..e83dc9a5e5e
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation deleteProjectVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $projectId: ID!
+) {
+ deleteProjectVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ projectId: $projectId
+ ) @client {
+ project {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiProjectVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
new file mode 100644
index 00000000000..4788911431b
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql
@@ -0,0 +1,30 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+mutation updateProjectVariable(
+ $variable: CiVariable!
+ $endpoint: String!
+ $fullPath: ID!
+ $projectId: ID!
+) {
+ updateProjectVariable(
+ variable: $variable
+ endpoint: $endpoint
+ fullPath: $fullPath
+ projectId: $projectId
+ ) @client {
+ project {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ ... on CiProjectVariable {
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql
new file mode 100644
index 00000000000..921e0ca25b9
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_environments.query.graphql
@@ -0,0 +1,11 @@
+query getProjectEnvironments($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ environments {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql
new file mode 100644
index 00000000000..a60a50e4bc4
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql
@@ -0,0 +1,15 @@
+#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
+
+query getProjectVariables($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ ciVariables {
+ nodes {
+ ...BaseCiVariable
+ environmentScope
+ masked
+ protected
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
index be7e3f88cfd..c041531ae30 100644
--- a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
+++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js
@@ -4,9 +4,16 @@ import {
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 {
+ GRAPHQL_GROUP_TYPE,
+ GRAPHQL_PROJECT_TYPE,
+ groupString,
+ instanceString,
+ projectString,
+} from '../constants';
+import getProjectVariables from './queries/project_variables.query.graphql';
import getGroupVariables from './queries/group_variables.query.graphql';
+import getAdminVariables from './queries/variables.query.graphql';
const prepareVariableForApi = ({ variable, destroy = false }) => {
return {
@@ -28,6 +35,20 @@ const mapVariableTypes = (variables = [], kind) => {
});
};
+const prepareProjectGraphQLResponse = ({ data, projectId, errors = [] }) => {
+ return {
+ errors,
+ project: {
+ __typename: GRAPHQL_PROJECT_TYPE,
+ id: projectId,
+ ciVariables: {
+ __typename: 'CiVariableConnection',
+ nodes: mapVariableTypes(data.variables, projectString),
+ },
+ },
+ };
+};
+
const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
return {
errors,
@@ -52,6 +73,28 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
};
};
+const callProjectEndpoint = async ({
+ endpoint,
+ fullPath,
+ variable,
+ projectId,
+ cache,
+ destroy = false,
+}) => {
+ try {
+ const { data } = await axios.patch(endpoint, {
+ variables_attributes: [prepareVariableForApi({ variable, destroy })],
+ });
+ return prepareProjectGraphQLResponse({ data, projectId });
+ } catch (e) {
+ return prepareProjectGraphQLResponse({
+ data: cache.readQuery({ query: getProjectVariables, variables: { fullPath } }),
+ projectId,
+ errors: [...e.response.data],
+ });
+ }
+};
+
const callGroupEndpoint = async ({
endpoint,
fullPath,
@@ -91,6 +134,15 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false })
export const resolvers = {
Mutation: {
+ addProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ },
+ updateProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache });
+ },
+ deleteProjectVariable: async (_, { endpoint, fullPath, variable, projectId }, { cache }) => {
+ return callProjectEndpoint({ endpoint, fullPath, variable, projectId, cache, destroy: true });
+ },
addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
},
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index a74af8aed12..f5bdd4c7b1e 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import CiAdminVariables from './components/ci_admin_variables.vue';
import CiGroupVariables from './components/ci_group_variables.vue';
+import CiProjectVariables from './components/ci_project_variables.vue';
import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
import { resolvers } from './graphql/resolvers';
import createStore from './store';
@@ -37,6 +38,8 @@ const mountCiVariableListApp = (containerEl) => {
if (parsedIsGroup) {
component = CiGroupVariables;
+ } else if (parsedIsProject) {
+ component = CiProjectVariables;
}
Vue.use(VueApollo);
@@ -77,7 +80,7 @@ const mountLegacyCiVariableListApp = (containerEl) => {
const {
endpoint,
projectId,
- group,
+ isGroup,
maskableRegex,
protectedByDefault,
awsLogoSvgPath,
@@ -89,13 +92,13 @@ const mountLegacyCiVariableListApp = (containerEl) => {
maskedEnvironmentVariablesLink,
environmentScopeLink,
} = containerEl.dataset;
- const isGroup = parseBoolean(group);
+ const parsedIsGroup = parseBoolean(isGroup);
const isProtectedByDefault = parseBoolean(protectedByDefault);
const store = createStore({
endpoint,
projectId,
- isGroup,
+ isGroup: parsedIsGroup,
maskableRegex,
isProtectedByDefault,
awsLogoSvgPath,
diff --git a/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue b/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue
new file mode 100644
index 00000000000..59de6df1e49
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/agent_integration_status_row.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlLink, GlIcon, GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
+export default {
+ components: {
+ GlLink,
+ GlIcon,
+ GlBadge,
+ },
+ mixins: [glFeatureFlagMixin()],
+ i18n: {
+ premiumTitle: s__('ClusterAgents|Premium'),
+ },
+ props: {
+ text: {
+ required: true,
+ type: String,
+ },
+ icon: {
+ required: false,
+ type: String,
+ default: 'information',
+ },
+ iconClass: {
+ required: false,
+ type: String,
+ default: 'text-info',
+ },
+ helpUrl: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ featureName: {
+ required: false,
+ type: String,
+ default: null,
+ },
+ },
+ computed: {
+ showPremiumBadge() {
+ return this.featureName && !this.glFeatures[this.featureName];
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-mb-3">
+ <gl-icon :name="icon" :size="16" :class="iconClass" class="gl-mr-2" />
+
+ <gl-link v-if="helpUrl" :href="helpUrl">{{ text }}</gl-link>
+ <span v-else>{{ text }}</span>
+
+ <gl-badge
+ v-if="showPremiumBadge"
+ size="md"
+ class="gl-ml-2 gl-vertical-align-middle"
+ icon="license"
+ variant="tier"
+ >{{ $options.i18n.premiumTitle }}</gl-badge
+ >
+ </li>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/integration_status.vue b/app/assets/javascripts/clusters/agents/components/integration_status.vue
new file mode 100644
index 00000000000..68a77dfbc8e
--- /dev/null
+++ b/app/assets/javascripts/clusters/agents/components/integration_status.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlCollapse, GlButton, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { AGENT_STATUSES } from '~/clusters_list/constants';
+import { getAgentLastContact, getAgentStatus } from '~/clusters_list/clusters_util';
+import {
+ INTEGRATION_STATUS_VALID_TOKEN,
+ INTEGRATION_STATUS_NO_TOKEN,
+ INTEGRATION_STATUS_RESTRICTED_CI_CD,
+} from '../constants';
+import AgentIntegrationStatusRow from './agent_integration_status_row.vue';
+
+export default {
+ components: {
+ GlCollapse,
+ GlButton,
+ GlIcon,
+ AgentIntegrationStatusRow,
+ },
+ i18n: {
+ title: s__('ClusterAgents|Integration Status'),
+ },
+ AGENT_STATUSES,
+ props: {
+ tokens: {
+ required: true,
+ type: Array,
+ },
+ },
+ data() {
+ return {
+ isVisible: false,
+ };
+ },
+ computed: {
+ chevronIcon() {
+ return this.isVisible ? 'chevron-down' : 'chevron-right';
+ },
+ agentStatus() {
+ const lastContact = getAgentLastContact(this.tokens);
+ return getAgentStatus(lastContact);
+ },
+ integrationStatuses() {
+ const statuses = [];
+
+ if (this.agentStatus === 'active') {
+ statuses.push(INTEGRATION_STATUS_VALID_TOKEN);
+ }
+
+ if (!this.tokens.length) {
+ statuses.push(INTEGRATION_STATUS_NO_TOKEN);
+ }
+
+ statuses.push(INTEGRATION_STATUS_RESTRICTED_CI_CD);
+
+ return statuses;
+ },
+ },
+ methods: {
+ toggleCollapse() {
+ this.isVisible = !this.isVisible;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-button
+ :icon="chevronIcon"
+ variant="link"
+ size="small"
+ class="gl-mr-3"
+ @click="toggleCollapse"
+ >
+ {{ $options.i18n.title }} </gl-button
+ ><span data-testid="agent-status">
+ <gl-icon
+ :name="$options.AGENT_STATUSES[agentStatus].icon"
+ :class="$options.AGENT_STATUSES[agentStatus].class"
+ class="gl-mr-2"
+ />{{ $options.AGENT_STATUSES[agentStatus].name }}
+ </span>
+ <gl-collapse v-model="isVisible" class="gl-ml-5 gl-mt-5">
+ <ul class="gl-list-style-none gl-pl-2 gl-mb-0">
+ <agent-integration-status-row
+ v-for="(status, index) in integrationStatuses"
+ :key="index"
+ :icon="status.icon"
+ :icon-class="status.iconClass"
+ :text="status.text"
+ :help-url="status.helpUrl"
+ :feature-name="status.featureName"
+ />
+ </ul>
+ </gl-collapse>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/agents/components/show.vue b/app/assets/javascripts/clusters/agents/components/show.vue
index e3de8339325..f1bd36b4a63 100644
--- a/app/assets/javascripts/clusters/agents/components/show.vue
+++ b/app/assets/javascripts/clusters/agents/components/show.vue
@@ -14,6 +14,7 @@ import { MAX_LIST_COUNT, TOKEN_STATUS_ACTIVE } from '../constants';
import getClusterAgentQuery from '../graphql/queries/get_cluster_agent.query.graphql';
import TokenTable from './token_table.vue';
import ActivityEvents from './activity_events_list.vue';
+import IntegrationStatus from './integration_status.vue';
export default {
i18n: {
@@ -51,6 +52,7 @@ export default {
TimeAgoTooltip,
TokenTable,
ActivityEvents,
+ IntegrationStatus,
},
inject: ['agentName', 'projectPath'],
data() {
@@ -105,11 +107,11 @@ export default {
<template>
<section>
- <h2>{{ agentName }}</h2>
+ <h1>{{ agentName }}</h1>
<gl-loading-icon v-if="isLoading && clusterAgent == null" size="lg" class="gl-m-3" />
- <div v-else-if="clusterAgent">
+ <template v-else-if="clusterAgent">
<p data-testid="cluster-agent-create-info">
<gl-sprintf :message="$options.i18n.installedInfo">
<template #name>
@@ -122,7 +124,16 @@ export default {
</gl-sprintf>
</p>
- <gl-tabs sync-active-tab-with-query-params lazy>
+ <integration-status
+ :tokens="tokens"
+ class="gl-py-5 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
+ />
+
+ <gl-tabs
+ sync-active-tab-with-query-params
+ lazy
+ class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100"
+ >
<gl-tab :title="$options.i18n.activity" query-param-value="activity">
<activity-events :agent-name="agentName" :project-path="projectPath" />
</gl-tab>
@@ -151,7 +162,7 @@ export default {
</div>
</gl-tab>
</gl-tabs>
- </div>
+ </template>
<gl-alert v-else variant="danger" :dismissible="false">
{{ $options.i18n.loadingError }}
diff --git a/app/assets/javascripts/clusters/agents/components/token_table.vue b/app/assets/javascripts/clusters/agents/components/token_table.vue
index f74d66f6b8f..667d10e1753 100644
--- a/app/assets/javascripts/clusters/agents/components/token_table.vue
+++ b/app/assets/javascripts/clusters/agents/components/token_table.vue
@@ -44,36 +44,43 @@ export default {
},
computed: {
fields() {
+ const tdClass = 'gl-vertical-align-middle!';
return [
{
key: 'name',
label: this.$options.i18n.name,
tdAttr: { 'data-testid': 'agent-token-name' },
+ tdClass,
},
{
key: 'lastUsed',
label: this.$options.i18n.lastUsed,
tdAttr: { 'data-testid': 'agent-token-used' },
+ tdClass,
},
{
key: 'createdAt',
label: this.$options.i18n.dateCreated,
tdAttr: { 'data-testid': 'agent-token-created-time' },
+ tdClass,
},
{
key: 'createdBy',
label: this.$options.i18n.createdBy,
tdAttr: { 'data-testid': 'agent-token-created-user' },
+ tdClass,
},
{
key: 'description',
label: this.$options.i18n.description,
tdAttr: { 'data-testid': 'agent-token-description' },
+ tdClass,
},
{
key: 'actions',
label: '',
tdAttr: { 'data-testid': 'agent-token-revoke' },
+ tdClass,
},
];
},
diff --git a/app/assets/javascripts/clusters/agents/constants.js b/app/assets/javascripts/clusters/agents/constants.js
index 962fa243903..76af552181f 100644
--- a/app/assets/javascripts/clusters/agents/constants.js
+++ b/app/assets/javascripts/clusters/agents/constants.js
@@ -1,4 +1,5 @@
import { s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const MAX_LIST_COUNT = 25;
@@ -46,3 +47,24 @@ export const EVENT_ACTIONS_CLICK = 'click_button';
export const TOKEN_NAME_LIMIT = 255;
export const REVOKE_TOKEN_MODAL_ID = 'revoke-token-%{tokenName}';
+
+export const INTEGRATION_STATUS_VALID_TOKEN = {
+ icon: 'status-success',
+ iconClass: 'text-success-500',
+ text: s__('ClusterAgents|Valid access token'),
+};
+export const INTEGRATION_STATUS_NO_TOKEN = {
+ icon: 'status-alert',
+ iconClass: 'text-danger-500',
+ text: s__('ClusterAgents|No agent access token'),
+};
+
+export const INTEGRATION_STATUS_RESTRICTED_CI_CD = {
+ icon: 'information',
+ iconClass: 'text-info',
+ text: s__('ClusterAgents|CI/CD workflow with restricted access'),
+ helpUrl: helpPagePath('user/clusters/agent/ci_cd_workflow', {
+ anchor: 'restrict-project-and-group-access-by-using-impersonation',
+ }),
+ featureName: 'clusterAgentsCiImpersonation',
+};
diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js
index e2d01723dde..ee36a295513 100644
--- a/app/assets/javascripts/clusters_list/clusters_util.js
+++ b/app/assets/javascripts/clusters_list/clusters_util.js
@@ -1,3 +1,5 @@
+import { ACTIVE_CONNECTION_TIME } from './constants';
+
export function generateAgentRegistrationCommand({ name, token, version, address }) {
return `helm repo add gitlab https://charts.gitlab.io
helm repo update
@@ -12,3 +14,24 @@ helm upgrade --install ${name} gitlab/gitlab-agent \\
export function getAgentConfigPath(clusterAgentName) {
return `.gitlab/agents/${clusterAgentName}`;
}
+
+export function getAgentLastContact(tokens = []) {
+ let lastContact = null;
+ tokens.forEach((token) => {
+ const lastContactToDate = new Date(token.lastUsedAt).getTime();
+ if (lastContactToDate > lastContact) {
+ lastContact = lastContactToDate;
+ }
+ });
+ return lastContact;
+}
+
+export function getAgentStatus(lastContact) {
+ if (lastContact) {
+ const now = new Date().getTime();
+ const diff = now - lastContact;
+
+ return diff >= ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
+ }
+ return 'unused';
+}
diff --git a/app/assets/javascripts/clusters_list/components/agents.vue b/app/assets/javascripts/clusters_list/components/agents.vue
index 8a4a81d3e96..36f0f8e61ba 100644
--- a/app/assets/javascripts/clusters_list/components/agents.vue
+++ b/app/assets/javascripts/clusters_list/components/agents.vue
@@ -3,13 +3,9 @@ import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui
import { s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import {
- MAX_LIST_COUNT,
- ACTIVE_CONNECTION_TIME,
- AGENT_FEEDBACK_ISSUE,
- AGENT_FEEDBACK_KEY,
-} from '../constants';
+import { MAX_LIST_COUNT, AGENT_FEEDBACK_ISSUE, AGENT_FEEDBACK_KEY } from '../constants';
import getAgentsQuery from '../graphql/queries/get_agents.query.graphql';
+import { getAgentLastContact, getAgentStatus } from '../clusters_util';
import AgentEmptyState from './agent_empty_state.vue';
import AgentTable from './agent_table.vue';
@@ -88,8 +84,8 @@ export default {
if (list) {
list = list.map((agent) => {
const configFolder = this.folderList[agent.name];
- const lastContact = this.getLastContact(agent);
- const status = this.getStatus(lastContact);
+ const lastContact = getAgentLastContact(agent?.tokens?.nodes);
+ const status = getAgentStatus(lastContact);
return { ...agent, configFolder, lastContact, status };
});
}
@@ -141,28 +137,6 @@ export default {
});
}
},
- getLastContact(agent) {
- const tokens = agent?.tokens?.nodes;
- let lastContact = null;
- if (tokens?.length) {
- tokens.forEach((token) => {
- const lastContactToDate = new Date(token.lastUsedAt).getTime();
- if (lastContactToDate > lastContact) {
- lastContact = lastContactToDate;
- }
- });
- }
- return lastContact;
- },
- getStatus(lastContact) {
- if (lastContact) {
- const now = new Date().getTime();
- const diff = now - lastContact;
-
- return diff > ACTIVE_CONNECTION_TIME ? 'inactive' : 'active';
- }
- return 'unused';
- },
emitAgentsLoaded() {
const count = this.agents?.project?.clusterAgents?.count;
this.$emit('onAgentsLoad', count);
diff --git a/app/assets/javascripts/code_navigation/utils/dom_utils.js b/app/assets/javascripts/code_navigation/utils/dom_utils.js
index 1a65c1a64a2..90af31b715c 100644
--- a/app/assets/javascripts/code_navigation/utils/dom_utils.js
+++ b/app/assets/javascripts/code_navigation/utils/dom_utils.js
@@ -23,6 +23,7 @@ const wrapTextWithSpan = (el, text) => {
const wrapNodes = (text) => {
const wrapper = createSpan();
+ // eslint-disable-next-line no-unsanitized/property
wrapper.innerHTML = wrapSpacesWithSpans(text);
wrapper.childNodes.forEach((el) => wrapTextWithSpan(el, text));
return wrapper.childNodes;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 95ee3a0d90e..6890d7f6f44 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -293,7 +293,7 @@ export default {
</div>
<gl-modal
- v-if="canRenderPipelineButton"
+ v-if="canRenderPipelineButton || shouldRenderEmptyState"
:id="modalId"
ref="modal"
:modal-id="modalId"
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index 6bb654a434f..9cb7cd9607f 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -40,7 +40,7 @@ export default {
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
- :is-check-item="true"
+ is-check-item
:is-checked="project.id === selectedProject.id"
@click="selectProject(project)"
>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
new file mode 100644
index 00000000000..3891274e35e
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue
@@ -0,0 +1,60 @@
+<script>
+import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu';
+
+export default {
+ name: 'BubbleMenu',
+ inject: ['tiptapEditor'],
+ props: {
+ pluginKey: {
+ type: String,
+ required: true,
+ },
+ shouldShow: {
+ type: Function,
+ required: true,
+ },
+ tippyOptions: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ data() {
+ return {
+ menuVisible: false,
+ };
+ },
+ async mounted() {
+ await this.$nextTick();
+
+ this.tiptapEditor.registerPlugin(
+ BubbleMenuPlugin({
+ pluginKey: this.pluginKey,
+ editor: this.tiptapEditor,
+ element: this.$el,
+ shouldShow: this.shouldShow,
+ tippyOptions: {
+ ...this.tippyOptions,
+ onShow: (...args) => {
+ this.$emit('show', ...args);
+ this.menuVisible = true;
+ },
+ onHidden: (...args) => {
+ this.$emit('hidden', ...args);
+ this.menuVisible = false;
+ },
+ },
+ }),
+ );
+ },
+
+ beforeDestroy() {
+ this.tiptapEditor.unregisterPlugin(this.pluginKey);
+ },
+};
+</script>
+<template>
+ <div>
+ <slot v-if="menuVisible"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
index 6c0ac8e54d2..a9668ebdb69 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue
@@ -10,13 +10,13 @@ import {
GlSearchBoxByType,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import { getParentByTagName } from '~/lib/utils/dom_utils';
import codeBlockLanguageLoader from '../../services/code_block_language_loader';
import CodeBlockHighlight from '../../extensions/code_block_highlight';
import Diagram from '../../extensions/diagram';
import Frontmatter from '../../extensions/frontmatter';
import EditorStateObserver from '../editor_state_observer.vue';
+import BubbleMenu from './bubble_menu.vue';
const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name];
@@ -129,6 +129,10 @@ export default {
deleteCodeBlock() {
this.tiptapEditor.chain().focus().deleteNode(this.codeBlockType).run();
},
+
+ tippyOptions() {
+ return { getReferenceClientRect: this.getReferenceClientRect.bind(this) };
+ },
},
};
</script>
@@ -136,12 +140,9 @@ export default {
<bubble-menu
data-testid="code-block-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
plugin-key="bubbleMenuCodeBlock"
:should-show="shouldShow"
- :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- getReferenceClientRect,
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :tippy-options="tippyOptions()"
>
<editor-state-observer @transaction="updateCodeBlockInfoToState">
<gl-button-group>
@@ -181,7 +182,7 @@ export default {
</template>
<template v-if="!showCustomLanguageInput" #highlighted-items>
- <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true">
+ <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item is-checked>
{{ selectedLanguage.label }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
index 05ca7fd75c3..327b0967229 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/formatting.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/formatting_bubble_menu.vue
@@ -1,6 +1,5 @@
<script>
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 Paragraph from '../../extensions/paragraph';
@@ -9,6 +8,7 @@ import Audio from '../../extensions/audio';
import Video from '../../extensions/video';
import Image from '../../extensions/image';
import ToolbarButton from '../toolbar_button.vue';
+import BubbleMenu from './bubble_menu.vue';
export default {
components: {
@@ -34,14 +34,17 @@ export default {
);
},
},
+ toggleLinkCommandParams: {
+ href: '',
+ },
};
</script>
<template>
<bubble-menu
data-testid="formatting-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
:should-show="shouldShow"
+ :plugin-key="'formatting'"
>
<gl-button-group>
<toolbar-button
@@ -109,9 +112,7 @@ export default {
content-type="link"
icon-name="link"
editor-command="toggleLink"
- :editor-command-params="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- href: '',
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :editor-command-params="$options.toggleLinkCommandParams"
category="tertiary"
size="medium"
:label="__('Insert link')"
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
index dae0bc63b5a..a4713eb3275 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/link.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/link_bubble_menu.vue
@@ -8,9 +8,9 @@ import {
GlButtonGroup,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import Link from '../../extensions/link';
import EditorStateObserver from '../editor_state_observer.vue';
+import BubbleMenu from './bubble_menu.vue';
export default {
components: {
@@ -36,18 +36,9 @@ export default {
isEditing: false,
};
},
- watch: {
- linkCanonicalSrc(value) {
- if (!value) this.isEditing = true;
- },
- },
methods: {
shouldShow() {
- const shouldShow = this.tiptapEditor.isActive(Link.name);
-
- if (!shouldShow) this.isEditing = false;
-
- return shouldShow;
+ return this.tiptapEditor.isActive(Link.name);
},
startEditingLink() {
@@ -92,13 +83,23 @@ export default {
},
updateLinkToState() {
- if (!this.tiptapEditor.isActive(Link.name)) return;
+ const editor = this.tiptapEditor;
+
+ const { href, title, canonicalSrc } = editor.getAttributes(Link.name);
- const { href, title, canonicalSrc } = this.tiptapEditor.getAttributes(Link.name);
+ if (
+ canonicalSrc === this.linkCanonicalSrc &&
+ href === this.linkHref &&
+ title === this.linkTitle
+ ) {
+ return;
+ }
this.linkTitle = title;
this.linkHref = href;
this.linkCanonicalSrc = canonicalSrc || href;
+
+ this.isEditing = !this.linkCanonicalSrc;
},
copyLinkHref() {
@@ -108,6 +109,15 @@ export default {
removeLink() {
this.tiptapEditor.chain().focus().extendMarkRange(Link.name).unsetLink().run();
},
+
+ resetBubbleMenuState() {
+ this.linkTitle = undefined;
+ this.linkHref = undefined;
+ this.linkCanonicalSrc = undefined;
+ },
+ },
+ tippyOptions: {
+ placement: 'bottom',
},
};
</script>
@@ -115,14 +125,13 @@ export default {
<bubble-menu
data-testid="link-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
plugin-key="bubbleMenuLink"
- :should-show="() => shouldShow()"
- :tippy-options="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
- placement: 'bottom',
- } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ :should-show="shouldShow"
+ :tippy-options="$options.tippyOptions"
+ @show="updateLinkToState"
+ @hidden="resetBubbleMenuState"
>
- <editor-state-observer @transaction="updateLinkToState">
+ <editor-state-observer @selectionUpdate="updateLinkToState">
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
<gl-link
v-gl-tooltip
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
index a36a860c440..310bb1be81f 100644
--- a/app/assets/javascripts/content_editor/components/bubble_menus/media.vue
+++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue
@@ -9,13 +9,13 @@ import {
GlButtonGroup,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
-import { BubbleMenu } from '@tiptap/vue-2';
import { __ } from '~/locale';
import Audio from '../../extensions/audio';
import Image from '../../extensions/image';
import Video from '../../extensions/video';
import EditorStateObserver from '../editor_state_observer.vue';
import { acceptedMimes } from '../../services/upload_helpers';
+import BubbleMenu from './bubble_menu.vue';
const MEDIA_TYPES = [Audio.name, Image.name, Video.name];
@@ -189,9 +189,8 @@ export default {
<bubble-menu
data-testid="media-bubble-menu"
class="gl-shadow gl-rounded-base gl-bg-white"
- :editor="tiptapEditor"
plugin-key="bubbleMenuMedia"
- :should-show="() => shouldShow()"
+ :should-show="shouldShow"
>
<editor-state-observer @transaction="updateMediaInfoToState">
<gl-button-group v-if="!isEditing" class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index c3c881d9135..659c447e861 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -1,13 +1,16 @@
<script>
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
+import { __ } from '~/locale';
+import { VARIANT_DANGER } from '~/flash';
import { createContentEditor } from '../services/create_content_editor';
+import { ALERT_EVENT } from '../constants';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
import EditorStateObserver from './editor_state_observer.vue';
-import FormattingBubbleMenu from './bubble_menus/formatting.vue';
-import CodeBlockBubbleMenu from './bubble_menus/code_block.vue';
-import LinkBubbleMenu from './bubble_menus/link.vue';
-import MediaBubbleMenu from './bubble_menus/media.vue';
+import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue';
+import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue';
+import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue';
+import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue';
import TopToolbar from './top_toolbar.vue';
import LoadingIndicator from './loading_indicator.vue';
@@ -43,12 +46,26 @@ export default {
required: false,
default: () => {},
},
+ markdown: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
focused: false,
+ isLoading: false,
+ latestMarkdown: null,
};
},
+ watch: {
+ markdown(markdown) {
+ if (markdown !== this.latestMarkdown) {
+ this.setSerializedContent(markdown);
+ }
+ },
+ },
created() {
const { renderMarkdown, uploadsPath, extensions, serializerConfig } = this;
@@ -61,21 +78,61 @@ export default {
});
},
mounted() {
- this.$emit('initialized', this.contentEditor);
+ this.$emit('initialized');
+ this.setSerializedContent(this.markdown);
},
beforeDestroy() {
this.contentEditor.dispose();
},
methods: {
+ async setSerializedContent(markdown) {
+ this.notifyLoading();
+
+ try {
+ await this.contentEditor.setSerializedContent(markdown);
+ this.contentEditor.setEditable(true);
+ this.notifyLoadingSuccess();
+ this.latestMarkdown = markdown;
+ } catch {
+ this.contentEditor.eventHub.$emit(ALERT_EVENT, {
+ message: __(
+ 'An error occurred while trying to render the content editor. Please try again.',
+ ),
+ variant: VARIANT_DANGER,
+ actionLabel: __('Retry'),
+ action: () => {
+ this.setSerializedContent(markdown);
+ },
+ });
+ this.contentEditor.setEditable(false);
+ this.notifyLoadingError();
+ }
+ },
focus() {
this.focused = true;
},
blur() {
this.focused = false;
},
+ notifyLoading() {
+ this.isLoading = true;
+ this.$emit('loading');
+ },
+ notifyLoadingSuccess() {
+ this.isLoading = false;
+ this.$emit('loadingSuccess');
+ },
+ notifyLoadingError(error) {
+ this.isLoading = false;
+ this.$emit('loadingError', error);
+ },
notifyChange() {
+ this.latestMarkdown = this.contentEditor.getSerializedContent();
+
this.$emit('change', {
empty: this.contentEditor.empty,
+ changed: this.contentEditor.changed,
+ markdown: this.latestMarkdown,
});
},
},
@@ -84,14 +141,7 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
- <editor-state-observer
- @docUpdate="notifyChange"
- @focus="focus"
- @blur="blur"
- @loading="$emit('loading')"
- @loadingSuccess="$emit('loadingSuccess')"
- @loadingError="$emit('loadingError')"
- />
+ <editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
<content-editor-alert />
<div
data-testid="content-editor"
@@ -105,8 +155,12 @@ export default {
<code-block-bubble-menu />
<link-bubble-menu />
<media-bubble-menu />
- <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
- <loading-indicator />
+ <tiptap-editor-content
+ class="md"
+ data-testid="content_editor_editablebox"
+ :editor="contentEditor.tiptapEditor"
+ />
+ <loading-indicator v-if="isLoading" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/content_editor/components/content_editor_alert.vue b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
index c6737da1d77..87eff2451ec 100644
--- a/app/assets/javascripts/content_editor/components/content_editor_alert.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor_alert.vue
@@ -14,19 +14,32 @@ export default {
};
},
methods: {
- displayAlert({ message, variant }) {
+ displayAlert({ message, variant, action, actionLabel }) {
this.message = message;
this.variant = variant;
+ this.action = action;
+ this.actionLabel = actionLabel;
},
dismissAlert() {
this.message = null;
},
+ primaryAction() {
+ this.dismissAlert();
+ this.action?.();
+ },
},
};
</script>
<template>
<editor-state-observer @alert="displayAlert">
- <gl-alert v-if="message" class="gl-mb-6" :variant="variant" @dismiss="dismissAlert">
+ <gl-alert
+ v-if="message"
+ class="gl-mb-6"
+ :variant="variant"
+ :primary-button-text="actionLabel"
+ @dismiss="dismissAlert"
+ @primaryAction="primaryAction"
+ >
{{ message }}
</gl-alert>
</editor-state-observer>
diff --git a/app/assets/javascripts/content_editor/components/editor_state_observer.vue b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
index 252f69f7a5d..41c3771bf41 100644
--- a/app/assets/javascripts/content_editor/components/editor_state_observer.vue
+++ b/app/assets/javascripts/content_editor/components/editor_state_observer.vue
@@ -1,11 +1,6 @@
<script>
import { debounce } from 'lodash';
-import {
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
- ALERT_EVENT,
-} from '../constants';
+import { ALERT_EVENT } from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
@@ -15,12 +10,7 @@ export const tiptapToComponentMap = {
blur: 'blur',
};
-export const eventHubEvents = [
- ALERT_EVENT,
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
-];
+export const eventHubEvents = [ALERT_EVENT];
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue
index 7bc953e0dc3..e2af6cabddb 100644
--- a/app/assets/javascripts/content_editor/components/loading_indicator.vue
+++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue
@@ -1,40 +1,18 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import EditorStateObserver from './editor_state_observer.vue';
export default {
components: {
GlLoadingIcon,
- EditorStateObserver,
- },
- data() {
- return {
- isLoading: false,
- };
- },
- methods: {
- displayLoadingIndicator() {
- this.isLoading = true;
- },
- hideLoadingIndicator() {
- this.isLoading = false;
- },
},
};
</script>
<template>
- <editor-state-observer
- @loading="displayLoadingIndicator"
- @loadingSuccess="hideLoadingIndicator"
- @loadingError="hideLoadingIndicator"
+ <div
+ data-testid="content-editor-loading-indicator"
+ class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
>
- <div
- v-if="isLoading"
- data-testid="content-editor-loading-indicator"
- class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0"
- >
- <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
- <gl-loading-icon size="lg" />
- </div>
- </editor-state-observer>
+ <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div>
+ <gl-loading-icon size="lg" />
+ </div>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
index 649e23c29aa..8ed4dfce6de 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_image_button.vue
@@ -71,27 +71,31 @@ export default {
};
</script>
<template>
- <gl-dropdown
- v-gl-tooltip
- :aria-label="__('Insert image')"
- :title="__('Insert image')"
- size="small"
- category="tertiary"
- icon="media"
- @hidden="resetFields()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
- <template #append>
- <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="openFileUpload">
- {{ __('Upload image') }}
- </gl-dropdown-item>
-
+ <span class="gl-display-inline-flex">
+ <gl-dropdown
+ v-gl-tooltip
+ :text="__('Insert image')"
+ :title="__('Insert image')"
+ size="small"
+ category="tertiary"
+ icon="media"
+ lazy
+ text-sr-only
+ data-testid="insert-image-toolbar-button"
+ @hidden="resetFields()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="imgSrc" :placeholder="__('Image URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="insertImage">{{ __('Insert') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="openFileUpload">
+ {{ __('Upload image') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<input
ref="fileSelector"
type="file"
@@ -101,5 +105,5 @@ export default {
data-qa-selector="file_upload_field"
@change="onFileSelect"
/>
- </gl-dropdown>
+ </span>
</template>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
index ff525e52873..4fb1e8ce16f 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_link_button.vue
@@ -89,31 +89,34 @@ export default {
</script>
<template>
<editor-state-observer @transaction="updateLinkState">
- <gl-dropdown
- v-gl-tooltip
- :aria-label="__('Insert link')"
- :title="__('Insert link')"
- :toggle-class="{ active: isActive }"
- size="small"
- category="tertiary"
- icon="link"
- @show="selectLink()"
- >
- <gl-dropdown-form class="gl-px-3!">
- <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
- <template #append>
- <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
- </template>
- </gl-form-input-group>
- </gl-dropdown-form>
- <gl-dropdown-divider />
- <gl-dropdown-item v-if="isActive" @click="removeLink">
- {{ __('Remove link') }}
- </gl-dropdown-item>
- <gl-dropdown-item v-else @click="openFileUpload">
- {{ __('Upload file') }}
- </gl-dropdown-item>
-
+ <span class="gl-display-inline-flex">
+ <gl-dropdown
+ v-gl-tooltip
+ :title="__('Insert link')"
+ :text="__('Insert link')"
+ :toggle-class="{ active: isActive }"
+ size="small"
+ category="tertiary"
+ icon="link"
+ text-sr-only
+ lazy
+ @show="selectLink()"
+ >
+ <gl-dropdown-form class="gl-px-3!">
+ <gl-form-input-group v-model="linkHref" :placeholder="__('Link URL')">
+ <template #append>
+ <gl-button variant="confirm" @click="updateLink">{{ __('Apply') }}</gl-button>
+ </template>
+ </gl-form-input-group>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ <gl-dropdown-item v-if="isActive" @click="removeLink">
+ {{ __('Remove link') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-else @click="openFileUpload">
+ {{ __('Upload file') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
<input
ref="fileSelector"
type="file"
@@ -121,6 +124,6 @@ export default {
class="gl-display-none"
@change="onFileSelect"
/>
- </gl-dropdown>
+ </span>
</editor-state-observer>
</template>
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 9ad739e7358..6bb122153ef 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -46,7 +46,18 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="plus" class="content-editor-dropdown" right>
+ <gl-dropdown
+ v-gl-tooltip
+ size="small"
+ category="tertiary"
+ icon="plus"
+ :text="__('More')"
+ :title="__('More')"
+ text-sr-only
+ class="content-editor-dropdown"
+ right
+ lazy
+ >
<gl-dropdown-item @click="insert('codeBlock')">
{{ __('Code block') }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
index 18928acef3c..4b1929e1a20 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue
@@ -1,5 +1,11 @@
<script>
-import { GlDropdown, GlDropdownDivider, GlDropdownForm, GlButton } from '@gitlab/ui';
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownForm,
+ GlButton,
+ GlTooltipDirective as GlTooltip,
+} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { clamp } from '../services/utils';
@@ -17,6 +23,9 @@ export default {
GlDropdownDivider,
GlDropdownForm,
},
+ directives: {
+ GlTooltip,
+ },
inject: ['tiptapEditor'],
data() {
return {
@@ -62,7 +71,18 @@ export default {
};
</script>
<template>
- <gl-dropdown size="small" category="tertiary" icon="table" class="content-editor-dropdown" right>
+ <gl-dropdown
+ v-gl-tooltip
+ size="small"
+ category="tertiary"
+ icon="table"
+ :title="__('Insert table')"
+ :text="__('Insert table')"
+ class="content-editor-dropdown"
+ right
+ text-sr-only
+ lazy
+ >
<gl-dropdown-form class="gl-px-3!">
<div v-for="r of list(maxRows)" :key="r" class="gl-display-flex">
<gl-button
diff --git a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
index 13728d4001d..2bf32a70cd1 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_text_style_dropdown.vue
@@ -64,6 +64,7 @@ export default {
data-qa-selector="text_style_dropdown"
:disabled="!activeItem"
:text="activeItemLabel"
+ lazy
>
<gl-dropdown-item
v-for="(item, index) in $options.items"
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue
index 1030ebbf838..460368b6a11 100644
--- a/app/assets/javascripts/content_editor/components/top_toolbar.vue
+++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue
@@ -25,7 +25,7 @@ export default {
</script>
<template>
<div
- class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
+ class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200"
>
<toolbar-text-style-dropdown
data-testid="text-styles"
diff --git a/app/assets/javascripts/content_editor/constants/index.js b/app/assets/javascripts/content_editor/constants/index.js
index a39a243ec6b..564cca23afa 100644
--- a/app/assets/javascripts/content_editor/constants/index.js
+++ b/app/assets/javascripts/content_editor/constants/index.js
@@ -58,3 +58,11 @@ export const EXTENSION_PRIORITY_LOWER = 75;
*/
export const EXTENSION_PRIORITY_DEFAULT = 100;
export const EXTENSION_PRIORITY_HIGHEST = 200;
+
+/**
+ * See lib/gitlab/file_type_detection.rb
+ */
+export const SAFE_VIDEO_EXT = ['mp4', 'm4v', 'mov', 'webm', 'ogv'];
+export const SAFE_AUDIO_EXT = ['mp3', 'oga', 'ogg', 'spx', 'wav'];
+
+export const DIAGRAM_LANGUAGES = ['plantuml', 'mermaid'];
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
index 9329bbcb2c7..2d4226ccd33 100644
--- a/app/assets/javascripts/content_editor/content_editor.stories.js
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -2,7 +2,7 @@ import { ContentEditor } from './index';
export default {
component: ContentEditor,
- title: 'content_editor/components/content_editor',
+ title: 'content_editor/content_editor',
};
const Template = (_, { argTypes }) => ({
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index f87e4d8d1dd..848c4c12a9a 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -3,13 +3,7 @@ import { Plugin, PluginKey } from 'prosemirror-state';
import { __ } from '~/locale';
import { VARIANT_DANGER } from '~/flash';
import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer';
-import {
- ALERT_EVENT,
- LOADING_CONTENT_EVENT,
- LOADING_SUCCESS_EVENT,
- LOADING_ERROR_EVENT,
- EXTENSION_PRIORITY_HIGHEST,
-} from '../constants';
+import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants';
import CodeBlockHighlight from './code_block_highlight';
import Diagram from './diagram';
import Frontmatter from './frontmatter';
@@ -34,10 +28,8 @@ export default Extension.create({
const { renderMarkdown, eventHub } = options;
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- eventHub.$emit(LOADING_CONTENT_EVENT);
-
deserializer
- .deserialize({ schema: editor.schema, content: markdown })
+ .deserialize({ schema: editor.schema, markdown })
.then(({ document }) => {
if (!document) {
return;
@@ -48,14 +40,12 @@ export default Extension.create({
tr.replaceWith(selection.from - 1, selection.to, document.content);
view.dispatch(tr);
- eventHub.$emit(LOADING_SUCCESS_EVENT);
})
.catch(() => {
eventHub.$emit(ALERT_EVENT, {
message: __('An error occurred while pasting text in the editor. Please try again.'),
variant: VARIANT_DANGER,
});
- eventHub.$emit(LOADING_ERROR_EVENT);
});
return true;
diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js
index f9de71f601b..54d69d83188 100644
--- a/app/assets/javascripts/content_editor/extensions/sourcemap.js
+++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js
@@ -1,9 +1,11 @@
import { Extension } from '@tiptap/core';
+import Audio from './audio';
import Blockquote from './blockquote';
import Bold from './bold';
import BulletList from './bullet_list';
import Code from './code';
import CodeBlockHighlight from './code_block_highlight';
+import Diagram from './diagram';
import FootnoteReference from './footnote_reference';
import FootnoteDefinition from './footnote_definition';
import Frontmatter from './frontmatter';
@@ -25,17 +27,21 @@ import Table from './table';
import TableCell from './table_cell';
import TableHeader from './table_header';
import TableRow from './table_row';
+import TableOfContents from './table_of_contents';
+import Video from './video';
export default Extension.create({
addGlobalAttributes() {
return [
{
types: [
+ Audio.name,
Bold.name,
Blockquote.name,
BulletList.name,
Code.name,
CodeBlockHighlight.name,
+ Diagram.name,
FootnoteReference.name,
FootnoteDefinition.name,
Frontmatter.name,
@@ -56,6 +62,8 @@ export default Extension.create({
TableCell.name,
TableHeader.name,
TableRow.name,
+ TableOfContents.name,
+ Video.name,
...HTMLNodes.map((htmlNode) => htmlNode.name),
],
attributes: {
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 75d8581890f..514ab9699bc 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,5 +1,3 @@
-import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
-
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
@@ -20,14 +18,19 @@ export class ContentEditor {
}
get changed() {
- return this._pristineDoc?.eq(this.tiptapEditor.state.doc);
+ if (!this._pristineDoc) {
+ return !this.empty;
+ }
+
+ return !this._pristineDoc.eq(this.tiptapEditor.state.doc);
}
get empty() {
- const doc = this.tiptapEditor?.state.doc;
+ return this.tiptapEditor.isEmpty;
+ }
- // Makes sure the document has more than one empty paragraph
- return doc.childCount === 0 || (doc.childCount === 1 && doc.child(0).childCount === 0);
+ get editable() {
+ return this.tiptapEditor.isEditable;
}
dispose() {
@@ -55,24 +58,22 @@ export class ContentEditor {
return this._assetResolver.renderDiagram(code, language);
}
+ setEditable(editable = true) {
+ this._tiptapEditor.setOptions({
+ editable,
+ });
+ }
+
async setSerializedContent(serializedContent) {
- const { _tiptapEditor: editor, _eventHub: eventHub } = this;
+ const { _tiptapEditor: editor } = this;
const { doc, tr } = editor.state;
- try {
- eventHub.$emit(LOADING_CONTENT_EVENT);
- const { document } = await this.deserialize(serializedContent);
-
- if (document) {
- this._pristineDoc = document;
- tr.replaceWith(0, doc.content.size, document).setMeta('preventUpdate', true);
- editor.view.dispatch(tr);
- }
+ const { document } = await this.deserialize(serializedContent);
- eventHub.$emit(LOADING_SUCCESS_EVENT);
- } catch (e) {
- eventHub.$emit(LOADING_ERROR_EVENT, e);
- throw e;
+ if (document) {
+ this._pristineDoc = document;
+ 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 7a289df94ea..5ed7f3dc23d 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -127,7 +127,7 @@ export const createContentEditor = ({
MathInline,
OrderedList,
Paragraph,
- PasteMarkdown,
+ PasteMarkdown.configure({ eventHub, renderMarkdown }),
Reference,
ReferenceDefinition,
Sourcemap,
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index 472a0a4815b..ba0cad6c91c 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -108,7 +108,10 @@ const defaultSerializerConfig = {
},
nodes: {
- [Audio.name]: renderPlayable,
+ [Audio.name]: preserveUnchanged({
+ render: renderPlayable,
+ inline: true,
+ }),
[Blockquote.name]: preserveUnchanged((state, node) => {
if (node.attrs.multiline) {
state.write('>>>');
@@ -123,7 +126,7 @@ const defaultSerializerConfig = {
}),
[BulletList.name]: preserveUnchanged(renderBulletList),
[CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock),
- [Diagram.name]: renderCodeBlock,
+ [Diagram.name]: preserveUnchanged(renderCodeBlock),
[DescriptionList.name]: renderHTMLNode('dl', true),
[DescriptionItem.name]: (state, node, parent, index) => {
if (index === 1) state.ensureNewLine();
@@ -203,10 +206,10 @@ const defaultSerializerConfig = {
},
overwriteSourcePreservationStrategy: true,
}),
- [TableOfContents.name]: (state, node) => {
+ [TableOfContents.name]: preserveUnchanged((state, node) => {
state.write('[[_TOC_]]');
state.closeBlock(node);
- },
+ }),
[Table.name]: preserveUnchanged(renderTable),
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
@@ -220,7 +223,10 @@ const defaultSerializerConfig = {
else renderBulletList(state, node);
}),
[Text.name]: defaultMarkdownSerializer.nodes.text,
- [Video.name]: renderPlayable,
+ [Video.name]: preserveUnchanged({
+ render: renderPlayable,
+ inline: true,
+ }),
[WordBreak.name]: (state) => state.write('<wbr>'),
...HTMLNodes.reduce((serializers, htmlNode) => {
return {
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 8a15633708f..ca290efca11 100644
--- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js
@@ -1,7 +1,10 @@
import { render } from '~/lib/gfm';
import { isValidAttribute } from '~/lib/dompurify';
+import { SAFE_AUDIO_EXT, SAFE_VIDEO_EXT, DIAGRAM_LANGUAGES } from '../constants';
import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter';
+const ALL_AUDIO_VIDEO_EXT = [...SAFE_AUDIO_EXT, ...SAFE_VIDEO_EXT];
+
const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del'];
const isTaskItem = (hastNode) => {
@@ -17,6 +20,32 @@ const getTableCellAttrs = (hastNode) => ({
rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1,
});
+const getMediaAttrs = (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,
+});
+
+const isMediaTag = (hastNode) => hastNode.tagName === 'img' && Boolean(hastNode.properties);
+
+const extractMediaFileExtension = (url) => {
+ try {
+ const parsedUrl = new URL(url, window.location.origin);
+
+ return /\.(\w+)$/.exec(parsedUrl.pathname)?.[1] ?? null;
+ } catch {
+ return null;
+ }
+};
+
+const isCodeBlock = (hastNode) => hastNode.tagName === 'codeblock';
+
+const isDiagramCodeBlock = (hastNode) => DIAGRAM_LANGUAGES.includes(hastNode.properties?.language);
+
+const getCodeBlockAttrs = (hastNode) => ({ language: hastNode.properties.language });
+
const factorySpecs = {
blockquote: { type: 'block', selector: 'blockquote' },
paragraph: { type: 'block', selector: 'p' },
@@ -45,8 +74,13 @@ const factorySpecs = {
},
codeBlock: {
type: 'block',
- selector: 'codeblock',
- getAttrs: (hastNode) => ({ ...hastNode.properties }),
+ selector: (hastNode) => isCodeBlock(hastNode) && !isDiagramCodeBlock(hastNode),
+ getAttrs: getCodeBlockAttrs,
+ },
+ diagram: {
+ type: 'block',
+ selector: (hastNode) => isCodeBlock(hastNode) && isDiagramCodeBlock(hastNode),
+ getAttrs: getCodeBlockAttrs,
},
horizontalRule: {
type: 'block',
@@ -121,16 +155,26 @@ const factorySpecs = {
selector: 'pre',
wrapInParagraph: true,
},
+ audio: {
+ type: 'inline',
+ selector: (hastNode) =>
+ isMediaTag(hastNode) &&
+ SAFE_AUDIO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)),
+ getAttrs: getMediaAttrs,
+ },
image: {
type: 'inline',
- 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,
- }),
+ selector: (hastNode) =>
+ isMediaTag(hastNode) &&
+ !ALL_AUDIO_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)),
+ getAttrs: getMediaAttrs,
+ },
+ video: {
+ type: 'inline',
+ selector: (hastNode) =>
+ isMediaTag(hastNode) &&
+ SAFE_VIDEO_EXT.includes(extractMediaFileExtension(hastNode.properties.src)),
+ getAttrs: getMediaAttrs,
},
hardBreak: {
type: 'inline',
@@ -193,6 +237,11 @@ const factorySpecs = {
language: hastNode.properties.language,
}),
},
+
+ tableOfContents: {
+ type: 'block',
+ selector: 'tableofcontents',
+ },
};
const SANITIZE_ALLOWLIST = ['level', 'identifier', 'numeric', 'language', 'url', 'isReference'];
@@ -250,6 +299,7 @@ export default () => {
'yaml',
'toml',
'json',
+ 'tableOfContents',
],
});
diff --git a/app/assets/javascripts/crm/constants.js b/app/assets/javascripts/crm/constants.js
index 815289e075e..832efa90956 100644
--- a/app/assets/javascripts/crm/constants.js
+++ b/app/assets/javascripts/crm/constants.js
@@ -5,3 +5,7 @@ export const trackViewsOptions = {
category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */,
action: 'view_contacts_list',
};
+export const organizationTrackViewsOptions = {
+ category: 'Customer Relations' /* eslint-disable-line @gitlab/require-i18n-strings */,
+ action: 'view_organizations_list',
+};
diff --git a/app/assets/javascripts/crm/organizations/bundle.js b/app/assets/javascripts/crm/organizations/bundle.js
index 828d7cd426c..5897810a384 100644
--- a/app/assets/javascripts/crm/organizations/bundle.js
+++ b/app/assets/javascripts/crm/organizations/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 CrmOrganizationsRoot from './components/organizations_root.vue';
import routes from './routes';
@@ -21,7 +22,14 @@ export default () => {
return false;
}
- const { basePath, canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath } = el.dataset;
+ const {
+ basePath,
+ canAdminCrmOrganization,
+ groupFullPath,
+ groupId,
+ groupIssuesPath,
+ textQuery,
+ } = el.dataset;
const router = new VueRouter({
base: basePath,
@@ -33,7 +41,13 @@ export default () => {
el,
router,
apolloProvider,
- provide: { canAdminCrmOrganization, groupFullPath, groupId, groupIssuesPath },
+ provide: {
+ canAdminCrmOrganization: parseBoolean(canAdminCrmOrganization),
+ groupFullPath,
+ groupId,
+ groupIssuesPath,
+ textQuery,
+ },
render(createElement) {
return createElement(CrmOrganizationsRoot);
},
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 97b75091cac..1bdcd9ba352 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
@@ -1,12 +1,37 @@
#import "./crm_organization_fields.fragment.graphql"
-query organizations($groupFullPath: ID!) {
+query organizations(
+ $groupFullPath: ID!
+ $state: CustomerRelationsOrganizationState
+ $searchTerm: String
+ $sort: OrganizationSort
+ $firstPageSize: Int
+ $lastPageSize: Int
+ $prevPageCursor: String = ""
+ $nextPageCursor: String = ""
+ $ids: [CustomerRelationsOrganizationID!]
+) {
group(fullPath: $groupFullPath) {
id
- organizations {
+ organizations(
+ state: $state
+ search: $searchTerm
+ sort: $sort
+ first: $firstPageSize
+ last: $lastPageSize
+ after: $nextPageCursor
+ before: $prevPageCursor
+ ids: $ids
+ ) {
nodes {
...OrganizationFragment
}
+ pageInfo {
+ hasNextPage
+ endCursor
+ hasPreviousPage
+ startCursor
+ }
}
}
}
diff --git a/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql
new file mode 100644
index 00000000000..fb6064e171f
--- /dev/null
+++ b/app/assets/javascripts/crm/organizations/components/graphql/get_group_organizations_count_by_state.query.graphql
@@ -0,0 +1,11 @@
+query organizationsCountByState($groupFullPath: ID!, $searchTerm: String) {
+ group(fullPath: $groupFullPath) {
+ __typename
+ id
+ organizationStateCounts(search: $searchTerm) {
+ all
+ active
+ inactive
+ }
+ }
+}
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 5fd0294b0ea..32900d45f22 100644
--- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
+++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue
@@ -36,7 +36,7 @@ export default {
getQuery() {
return {
query: getGroupOrganizationsQuery,
- variables: { groupFullPath: this.groupFullPath },
+ variables: { groupFullPath: this.groupFullPath, ids: [this.organizationGraphQLId] },
};
},
title() {
diff --git a/app/assets/javascripts/crm/organizations/components/organizations_root.vue b/app/assets/javascripts/crm/organizations/components/organizations_root.vue
index a165dd68603..155c8f00537 100644
--- a/app/assets/javascripts/crm/organizations/components/organizations_root.vue
+++ b/app/assets/javascripts/crm/organizations/components/organizations_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 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, organizationTrackViewsOptions } from '../../constants';
import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql';
+import getGroupOrganizationsCountByStateQuery from './graphql/get_group_organizations_count_by_state.query.graphql';
export default {
components: {
- GlAlert,
GlButton,
GlLoadingIcon,
GlTable,
+ PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath'],
+ inject: ['canAdminCrmOrganization', 'groupFullPath', 'groupIssuesPath', 'textQuery'],
data() {
return {
- organizations: [],
+ organizations: { list: [] },
+ organizationsCount: {},
error: false,
+ filteredByStatus: '',
+ pagination: initialPaginationState,
+ statusFilter: 'all',
+ searchTerm: this.textQuery,
+ sort: 'NAME_ASC',
+ sortDesc: false,
};
},
apollo: {
organizations: {
- query() {
- return getGroupOrganizationsQuery;
- },
+ query: getGroupOrganizationsQuery,
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;
},
},
+ organizationsCount: {
+ query: getGroupOrganizationsCountByStateQuery,
+ variables() {
+ return {
+ groupFullPath: this.groupFullPath,
+ searchTerm: this.searchTerm,
+ };
+ },
+ update(data) {
+ return data?.group?.organizationStateCounts;
+ },
+ error() {
+ this.error = true;
+ },
+ },
},
computed: {
isLoading() {
return this.$apollo.queries.organizations.loading;
},
- canAdmin() {
- return parseBoolean(this.canAdminCrmOrganization);
+ tbodyTrClass() {
+ return {
+ [bodyTrClass]: !this.loading && !this.isEmpty,
+ };
},
},
methods: {
+ errorAlertDismissed() {
+ this.error = false;
+ },
extractOrganizations(data) {
const organizations = data?.group?.organizations?.nodes || [];
- return organizations.slice().sort((a, b) => a.name.localeCompare(b.name));
+ const pageInfo = data?.group?.organizations?.pageInfo || {};
+ return {
+ list: organizations,
+ 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_organization_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: 'name', sortable: true },
@@ -83,60 +141,113 @@ 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',
+ },
+ ],
+ organizationTrackViewsOptions,
+ 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="organizations.list"
+ :page-info="organizations.pageInfo"
+ :items-count="organizationsCount"
+ :status-tabs="$options.statusTabs"
+ :track-views-options="$options.organizationTrackViewsOptions"
+ :filter-search-tokens="$options.emptyArray"
+ filter-search-key="organizations"
+ @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"
- class="gl-display-none gl-md-display-flex gl-align-items-center gl-justify-content-end"
- >
- <router-link :to="{ name: $options.NEW_ROUTE_NAME }">
- <gl-button variant="confirm" data-testid="new-organization-button">
+ <template #header-actions>
+ <router-link v-if="canAdminCrmOrganization" :to="{ name: $options.NEW_ROUTE_NAME }">
+ <gl-button
+ class="gl-my-3 gl-mr-5"
+ variant="confirm"
+ data-testid="new-organization-button"
+ >
{{ $options.i18n.newOrganization }}
</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="organizations"
- :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-organization-button"
- icon="pencil"
- :aria-label="$options.i18n.editButtonLabel"
- />
- </router-link>
</template>
- </gl-table>
+
+ <template #title>
+ {{ $options.i18n.title }}
+ </template>
+
+ <template #table>
+ <gl-table
+ :items="organizations.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="canAdminCrmOrganization"
+ v-gl-tooltip.hover.bottom="$options.i18n.editButtonLabel"
+ data-testid="edit-organization-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/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 85a40b89b77..f1fdffd4b72 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -246,9 +246,7 @@ export default {
</p>
<p class="gl-m-0">
<span data-testid="vsa-stage-event-build-author-and-date">
- <gl-link class="gl-text-black-normal build-date" :href="item.url">{{
- item.date
- }}</gl-link>
+ <gl-link class="gl-text-black-normal" :href="item.url">{{ item.date }}</gl-link>
{{ s__('ByAuthor|by') }}
<gl-link
class="gl-text-black-normal issue-author-link"
diff --git a/app/assets/javascripts/cycle_analytics/components/total_time.vue b/app/assets/javascripts/cycle_analytics/components/total_time.vue
index a5a90a56974..725952c3518 100644
--- a/app/assets/javascripts/cycle_analytics/components/total_time.vue
+++ b/app/assets/javascripts/cycle_analytics/components/total_time.vue
@@ -52,7 +52,7 @@ export default {
};
</script>
<template>
- <span class="total-time">
+ <span>
<template v-if="hasData">
{{ calculatedTime.duration }} <span>{{ calculatedTime.units }}</span>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
index f686cd0db95..17decb6b448 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
@@ -57,6 +57,10 @@ export default {
includeSubgroups: true,
};
},
+ currentDate() {
+ const now = new Date();
+ return new Date(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
+ },
},
multiProjectSelect: true,
maxDateRange: DATE_RANGE_LIMIT,
@@ -93,6 +97,7 @@ export default {
v-if="hasDateRangeFilter"
:start-date="startDate"
:end-date="endDate"
+ :max-date="currentDate"
:max-date-range="$options.maxDateRange"
:include-selected-date="true"
class="js-daterange-picker"
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 6fe353405d4..83068cabf0f 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import { dateFormats } from '~/analytics/shared/constants';
+import dateFormat from '~/lib/dateformat';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { PAGINATION_TYPE } from '../constants';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index d811bb3b0bf..c9097b9384f 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -4,11 +4,11 @@ import { head, tail } from 'lodash';
import { s__, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import actionBtn from './action_btn.vue';
+import ActionBtn from './action_btn.vue';
export default {
components: {
- actionBtn,
+ ActionBtn,
GlButton,
GlIcon,
GlLink,
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index d71f4f5507f..77ec1ef590f 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -1,9 +1,9 @@
<script>
-import deployKey from './key.vue';
+import DeployKey from './key.vue';
export default {
components: {
- deployKey,
+ DeployKey,
},
props: {
keys: {
diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js
index 6e439be42ae..83601d5b2e3 100644
--- a/app/assets/javascripts/deploy_keys/index.js
+++ b/app/assets/javascripts/deploy_keys/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
-import deployKeysApp from './components/app.vue';
+import DeployKeysApp from './components/app.vue';
export default () =>
new Vue({
el: document.getElementById('js-deploy-keys'),
components: {
- deployKeysApp,
+ DeployKeysApp,
},
data() {
return {
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
index f10c2d82b61..0f612989bb4 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
@@ -13,6 +13,7 @@ const renderersByType = {
},
header(element, data) {
element.classList.add('dropdown-header');
+ // eslint-disable-next-line no-unsanitized/property
element.innerHTML = data.content;
return element;
@@ -122,6 +123,7 @@ function assignTextToLink(el, data, options) {
const text = getLinkText(data, options);
if (options.icon || options.highlight) {
+ // eslint-disable-next-line no-unsanitized/property
el.innerHTML = text;
} else {
el.textContent = text;
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 ac00af2ab34..124780df8a5 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
@@ -174,6 +174,7 @@ export default {
this.$emit('open-form', this.discussion.id);
this.isFormRendered = true;
},
+
toggleResolvedStatus() {
this.isResolving = true;
@@ -234,6 +235,7 @@ export default {
:note="firstNote"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
+ :noteable-id="noteableId"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
>
@@ -276,6 +278,7 @@ export default {
:note="note"
:markdown-preview-path="markdownPreviewPath"
:is-resolving="isResolving"
+ :noteable-id="noteableId"
:class="{ 'gl-bg-blue-50': isDiscussionActive }"
@error="$emit('update-note-error', $event)"
/>
@@ -307,6 +310,8 @@ export default {
v-model="discussionComment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
+ :noteable-id="noteableId"
+ :discussion-id="discussion.id"
@submit-form="mutate"
@cancel-form="hideForm"
>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index 5fb5989e11a..e629f74ba02 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -45,6 +45,10 @@ export default {
required: false,
default: '',
},
+ noteableId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -160,6 +164,7 @@ export default {
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
:is-new-comment="false"
+ :noteable-id="noteableId"
class="gl-mt-5"
@submit-form="mutate"
@cancel-form="hideForm"
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 1b6458668f5..4faeba3983b 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
@@ -1,7 +1,11 @@
<script>
import { GlButton, GlModal } from '@gitlab/ui';
+import $ from 'jquery';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import Autosave from '~/autosave';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import { getIdFromGraphQLId, isGid } from '~/graphql_shared/utils';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
export default {
@@ -30,10 +34,20 @@ export default {
required: false,
default: true,
},
+ noteableId: {
+ type: String,
+ required: true,
+ },
+ discussionId: {
+ type: String,
+ required: false,
+ default: 'new',
+ },
},
data() {
return {
formText: this.value,
+ isLoggedIn: isLoggedIn(),
};
},
computed: {
@@ -64,13 +78,19 @@ export default {
markdownDocsPath() {
return helpPagePath('user/markdown');
},
+ shortDiscussionId() {
+ return isGid(this.discussionId) ? getIdFromGraphQLId(this.discussionId) : this.discussionId;
+ },
},
mounted() {
this.focusInput();
},
methods: {
submitForm() {
- if (this.hasValue) this.$emit('submit-form');
+ if (this.hasValue) {
+ this.$emit('submit-form');
+ this.autosaveDiscussion.reset();
+ }
},
cancelComment() {
if (this.hasValue && this.formText !== this.value) {
@@ -79,8 +99,22 @@ export default {
this.$emit('cancel-form');
}
},
+ confirmCancelCommentModal() {
+ this.$emit('cancel-form');
+ this.autosaveDiscussion.reset();
+ },
focusInput() {
this.$refs.textarea.focus();
+ this.initAutosaveComment();
+ },
+ initAutosaveComment() {
+ if (this.isLoggedIn) {
+ this.autosaveDiscussion = new Autosave($(this.$refs.textarea), [
+ s__('DesignManagement|Discussion'),
+ getIdFromGraphQLId(this.noteableId),
+ this.shortDiscussionId,
+ ]);
+ }
},
},
};
@@ -124,7 +158,7 @@ export default {
type="submit"
data-track-action="click_button"
data-qa-selector="save_comment_button"
- @click="$emit('submit-form')"
+ @click="submitForm"
>
{{ buttonText }}
</gl-button>
@@ -144,7 +178,7 @@ export default {
:ok-title="modalSettings.okTitle"
:cancel-title="modalSettings.cancelTitle"
modal-id="cancel-comment-modal"
- @ok="$emit('cancel-form')"
+ @ok="confirmCancelCommentModal"
>{{ modalSettings.content }}
</gl-modal>
</form>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
index 3092b8554ac..1e36aa686a4 100644
--- a/app/assets/javascripts/design_management/components/list/item.vue
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -128,7 +128,7 @@ export default {
params: { id: filename },
query: $route.query,
}"
- class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new"
+ class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new gl-mb-0"
>
<div
class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative"
diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
index 816d7ac7abf..f10545faea6 100644
--- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
+++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
@@ -73,8 +73,8 @@ export default {
<gl-dropdown-item
v-for="(version, index) in allVersions"
:key="version.id"
- :is-check-item="true"
- :is-check-centered="true"
+ is-check-item
+ is-check-centered
:is-checked="findVersionId(version.id) === currentVersionId"
:avatar-url="getAvatarUrl(version)"
@click="routeToVersion(version.id)"
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 1825ce7f092..228ad637b9e 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -418,6 +418,7 @@ export default {
v-model="comment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
+ :noteable-id="design.id"
@submit-form="mutate"
@cancel-form="closeCommentForm"
/> </apollo-mutation
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index 91e35ad3764..07f7a19f7d4 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -135,7 +135,7 @@ export default {
designDropzoneWrapperClass() {
return this.isDesignListEmpty
? 'col-12'
- : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3';
+ : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mt-5';
},
},
mounted() {
@@ -364,15 +364,15 @@ export default {
data-testid="design-toolbar-wrapper"
>
<div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap gl-gap-3"
>
- <div class="gl-display-flex gl-align-items-center gl-my-2">
+ <div class="gl-display-flex gl-align-items-center">
<span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span>
<design-version-dropdown />
</div>
<div
v-show="hasDesigns"
- class="gl-display-flex gl-align-items-center gl-my-2"
+ class="gl-display-flex gl-align-items-center"
data-testid="design-selector-toolbar"
>
<gl-button
@@ -413,7 +413,7 @@ export default {
</div>
</div>
</header>
- <div class="gl-mt-6">
+ <div>
<gl-loading-icon v-if="isLoading" size="lg" />
<gl-alert v-else-if="error" variant="danger" :dismissible="false">
{{ __('An error occurred while loading designs. Please try again.') }}
@@ -449,7 +449,7 @@ export default {
<li
v-for="design in designs"
:key="design.id"
- class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
+ class="col-md-6 col-lg-3 gl-mt-5 gl-bg-transparent gl-shadow-none js-design-tile"
>
<design-dropzone
:display-as-card="hasDesigns"
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 530f3a3a7f7..f5c0776ca35 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -331,6 +331,8 @@ export default {
mrReviews: this.rehydratedMrReviews,
});
+ this.interfaceWithDOM();
+
if (this.endpointCodequality) {
this.setCodequalityEndpoint(this.endpointCodequality);
}
@@ -445,6 +447,16 @@ export default {
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
},
+ interfaceWithDOM() {
+ this.diffsTab = document.querySelector('.js-diffs-tab');
+ },
+ updateChangesTabCount() {
+ const badge = this.diffsTab.querySelector('.gl-badge');
+
+ if (this.diffsTab && badge) {
+ badge.textContent = this.diffFilesLength;
+ }
+ },
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
@@ -461,7 +473,11 @@ export default {
this.fetchDiffFilesMeta()
.then(({ real_size }) => {
this.diffFilesLength = parseInt(real_size, 10);
- if (toggleTree) this.setTreeDisplay();
+ if (toggleTree) {
+ this.setTreeDisplay();
+ }
+
+ this.updateChangesTabCount();
})
.catch(() => {
createFlash({
@@ -641,6 +657,7 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
+ :class="{ 'is-sidebar-moved': glFeatures.movedMrSidebar }"
class="diff-tree-list js-diff-tree-list gl-px-5"
>
<panel-resizer
diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
index fd219a7d00f..4501988ee4f 100644
--- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
+++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
@@ -37,7 +37,7 @@ export default {
:class="{
'is-active': version.selected,
}"
- :is-check-item="true"
+ is-check-item
:is-checked="version.selected"
:href="version.href"
>
diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue
index f339b108a11..8498724740f 100644
--- a/app/assets/javascripts/diffs/components/diff_code_quality.vue
+++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue
@@ -5,10 +5,6 @@ import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/c
export default {
components: { GlButton, GlIcon },
props: {
- line: {
- type: Number,
- required: true,
- },
codeQuality: {
type: Array,
required: true,
@@ -33,7 +29,7 @@ export default {
<li
v-for="finding in codeQuality"
:key="finding.description"
- class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100"
+ class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100 gl-font-regular"
>
<gl-icon
:size="12"
@@ -50,7 +46,7 @@ export default {
size="small"
icon="close"
class="gl-absolute gl-right-2 gl-top-2"
- @click="$emit('hideCodeQualityFindings', line)"
+ @click="$emit('hideCodeQualityFindings')"
/>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 70071a3ff53..d7b63d205dc 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -11,7 +11,7 @@ import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_prev
import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
import NoteForm from '~/notes/components/note_form.vue';
import eventHub from '~/notes/event_hub';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
import { getDiffMode } from '../store/utils';
import DiffDiscussions from './diff_discussions.vue';
@@ -28,7 +28,7 @@ export default {
ImageDiffOverlay,
NotDiffableViewer,
NoPreviewViewer,
- userAvatarLink,
+ UserAvatarLink,
DiffFileDrafts,
},
mixins: [diffLineNoteFormMixin, draftCommentsMixin],
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index b39b50c4cdc..25d3bda147b 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -2,11 +2,11 @@
import { GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
-import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
+import NoteableDiscussion from '~/notes/components/noteable_discussion.vue';
export default {
components: {
- noteableDiscussion,
+ NoteableDiscussion,
GlIcon,
DesignNotePin,
},
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 07316f9433a..705b43a222d 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -19,6 +19,7 @@ import { scrollToElement } from '~/lib/utils/common_utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DIFF_FILE_AUTOMATIC_COLLAPSE } from '../constants';
import { DIFF_FILE_HEADER } from '../i18n';
@@ -45,7 +46,7 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash })],
+ mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash }), glFeatureFlagsMixin()],
i18n: {
...DIFF_FILE_HEADER,
compareButtonLabel: __('Compare submodule commit revisions'),
@@ -276,7 +277,10 @@ export default {
<template>
<div
ref="header"
- :class="{ 'gl-z-dropdown-menu!': idState.moreActionsShown }"
+ :class="{
+ 'gl-z-dropdown-menu!': idState.moreActionsShown,
+ 'is-sidebar-moved': glFeatures.movedMrSidebar,
+ }"
class="js-file-title file-title file-title-flex-parent"
data-qa-selector="file_title_container"
:data-qa-file-name="filePath"
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index a077c8ae3af..8553bdd3020 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -4,6 +4,7 @@ import { truncate } from '~/lib/utils/text_utility';
import { n__ } from '~/locale';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants';
+import { HIDE_COMMENTS } from '../i18n';
export default {
components: {
@@ -55,6 +56,9 @@ export default {
return `${noteData.author.name}: ${note}`;
},
},
+ i18n: {
+ HIDE_COMMENTS,
+ },
};
</script>
@@ -62,8 +66,10 @@ export default {
<div class="diff-comment-avatar-holders">
<button
v-if="discussionsExpanded"
+ v-gl-tooltip
+ :title="$options.i18n.HIDE_COMMENTS"
type="button"
- :aria-label="__('Show comments')"
+ :aria-label="$options.i18n.HIDE_COMMENTS"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
@click="$emit('toggleLineDiscussions')"
>
diff --git a/app/assets/javascripts/diffs/components/diff_line.vue b/app/assets/javascripts/diffs/components/diff_line.vue
new file mode 100644
index 00000000000..448272549d3
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_line.vue
@@ -0,0 +1,35 @@
+<script>
+import DiffCodeQuality from './diff_code_quality.vue';
+
+export default {
+ components: {
+ DiffCodeQuality,
+ },
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ parsedCodeQuality() {
+ return (this.line.left ?? this.line.right)?.codequality;
+ },
+ codeQualityLineNumber() {
+ return this.parsedCodeQuality[0].line;
+ },
+ },
+ methods: {
+ hideCodeQualityFindings() {
+ this.$emit('hideCodeQualityFindings', this.codeQualityLineNumber);
+ },
+ },
+};
+</script>
+
+<template>
+ <diff-code-quality
+ :code-quality="parsedCodeQuality"
+ @hideCodeQualityFindings="hideCodeQualityFindings"
+ />
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 467a0f8d2db..f63ab1bb067 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -7,7 +7,7 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import { commentLineOptions, formatLineRange } from '~/notes/components/multiline_comment_utils';
-import noteForm from '~/notes/components/note_form.vue';
+import NoteForm from '~/notes/components/note_form.vue';
import autosave from '~/notes/mixins/autosave';
import {
DIFF_NOTE_TYPE,
@@ -18,7 +18,7 @@ import {
export default {
components: {
- noteForm,
+ NoteForm,
MultilineCommentForm,
},
mixins: [autosave, diffLineNoteFormMixin, glFeatureFlagsMixin()],
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 63c5aedd7ce..e5695c4390f 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -64,6 +64,11 @@ export default {
type: Function,
required: true,
},
+ codeQualityExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
classNameMap: memoize(
(props) => {
@@ -272,6 +277,7 @@ export default {
<component
:is="$options.CodeQualityGutterIcon"
v-if="$options.showCodequalityLeft(props)"
+ :code-quality-expanded="props.codeQualityExpanded"
:codequality="props.line.left.codequality"
:file-path="props.filePath"
@showCodeQualityFindings="
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index ea94df1ad5b..91bf3283379 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -9,7 +9,7 @@ import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
-import DiffCodeQuality from './diff_code_quality.vue';
+import DiffLine from './diff_line.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
import { isHighlighted } from './diff_row_utils';
@@ -18,8 +18,8 @@ export default {
components: {
DiffExpansionCell,
DiffRow,
+ DiffLine,
DiffCommentCell,
- DiffCodeQuality,
DraftNote,
},
directives: {
@@ -96,10 +96,6 @@ export default {
}
this.idState.dragStart = line;
},
- parseCodeQuality(line) {
- return (line.left ?? line.right)?.codequality;
- },
-
hideCodeQualityFindings(line) {
const index = this.codeQualityExpandedLines.indexOf(line);
if (index > -1) {
@@ -179,7 +175,7 @@ export default {
);
},
getCodeQualityLine(line) {
- return this.parseCodeQuality(line)?.[0]?.line;
+ return (line.left ?? line.right)?.codequality?.[0]?.line;
},
},
userColorScheme: window.gon.user_color_scheme,
@@ -234,6 +230,7 @@ export default {
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
:inline="inline"
:index="index"
+ :code-quality-expanded="codeQualityExpandedLines.includes(getCodeQualityLine(line))"
:is-highlighted="isHighlighted(line)"
:file-line-coverage="fileLineCoverage"
:coverage-loaded="coverageLoaded"
@@ -248,15 +245,13 @@ export default {
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
-
- <diff-code-quality
+ <diff-line
v-if="
glFeatures.refactorCodeQualityInlineFindings &&
codeQualityExpandedLines.includes(getCodeQualityLine(line))
"
:key="line.line_code"
- :line="getCodeQualityLine(line)"
- :code-quality="parseCodeQuality(line)"
+ :line="line"
@hideCodeQualityFindings="hideCodeQualityFindings"
/>
<div
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 1cc96ef3d54..6c0c9c4e1d0 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -71,15 +71,12 @@ 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/i18n.js b/app/assets/javascripts/diffs/i18n.js
index e617890af2e..f7f4aad3ad0 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -47,3 +47,5 @@ export const CONFLICT_TEXT = {
'Conflict: This file was added both in the source and target branches, but with different contents.',
),
};
+
+export const HIDE_COMMENTS = __('Hide comments');
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 1691da34c6d..b4ff5e4f250 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -3,7 +3,7 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils';
import eventHub from '../notes/event_hub';
-import diffsApp from './components/app.vue';
+import DiffsApp from './components/app.vue';
import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants';
import { getReviewsForMergeRequest } from './utils/file_reviews';
@@ -14,7 +14,7 @@ export default function initDiffsApp(store) {
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
components: {
- diffsApp,
+ DiffsApp,
},
store,
data() {
diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue
index 7a2c9a8600e..f22a0705b3d 100644
--- a/app/assets/javascripts/environments/components/deploy_board.vue
+++ b/app/assets/javascripts/environments/components/deploy_board.vue
@@ -20,13 +20,13 @@ import {
} from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { s__, n__ } from '~/locale';
-import instanceComponent from '~/vue_shared/components/deployment_instance.vue';
+import InstanceComponent from '~/vue_shared/components/deployment_instance.vue';
import { STATUS_MAP, CANARY_STATUS } from '../constants';
import CanaryIngress from './canary_ingress.vue';
export default {
components: {
- instanceComponent,
+ InstanceComponent,
CanaryIngress,
GlIcon,
GlLoadingIcon,
diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index 19284b26d51..3475b38c8c9 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -1,17 +1,17 @@
<script>
import {
GlBadge,
- GlButton,
- GlCollapse,
GlIcon,
GlLink,
+ GlLoadingIcon,
GlTooltipDirective as GlTooltip,
GlTruncate,
} from '@gitlab/ui';
-import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { __, s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import createFlash from '~/flash';
+import deploymentDetails from '../graphql/queries/deployment_details.query.graphql';
import DeploymentStatusBadge from './deployment_status_badge.vue';
import Commit from './commit.vue';
@@ -21,16 +21,16 @@ export default {
Commit,
DeploymentStatusBadge,
GlBadge,
- GlButton,
- GlCollapse,
GlIcon,
GlLink,
GlTruncate,
+ GlLoadingIcon,
TimeAgoTooltip,
},
directives: {
GlTooltip,
},
+ inject: ['projectPath'],
props: {
deployment: {
type: Object,
@@ -41,9 +41,11 @@ export default {
default: false,
required: false,
},
- },
- data() {
- return { visible: false };
+ visible: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
},
computed: {
status() {
@@ -52,26 +54,21 @@ export default {
iid() {
return this.deployment?.iid;
},
+ isTag() {
+ return this.deployment?.tag;
+ },
shortSha() {
return this.commit?.shortId;
},
createdAt() {
return this.deployment?.createdAt;
},
- isMobile() {
- return !GlBreakpointInstance.isDesktop();
- },
- detailsButton() {
- return this.visible
- ? { text: this.$options.i18n.hideDetails, icon: 'expand-up' }
- : { text: this.$options.i18n.showDetails, icon: 'expand-down' };
- },
- detailsButtonClasses() {
- return this.isMobile ? 'gl-sr-only' : '';
- },
commit() {
return this.deployment?.commit;
},
+ commitPath() {
+ return this.commit?.commitPath;
+ },
user() {
return this.deployment?.user;
},
@@ -90,9 +87,6 @@ export default {
jobPath() {
return this.deployable?.buildPath;
},
- refLabel() {
- return this.deployment?.tag ? this.$options.i18n.tag : this.$options.i18n.branch;
- },
ref() {
return this.deployment?.ref;
},
@@ -105,10 +99,35 @@ export default {
needsApproval() {
return this.deployment.pendingApprovalCount > 0;
},
+ hasTags() {
+ return this.tags?.length > 0;
+ },
+ displayTags() {
+ return this.tags?.slice(0, 5);
+ },
},
- methods: {
- toggleCollapse() {
- this.visible = !this.visible;
+ apollo: {
+ tags: {
+ query: deploymentDetails,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ iid: this.deployment.iid,
+ };
+ },
+ update(data) {
+ return data?.project?.deployment?.tags;
+ },
+ error(error) {
+ createFlash({
+ message: this.$options.i18n.LOAD_ERROR_MESSAGE,
+ captureError: true,
+ error,
+ });
+ },
+ skip() {
+ return !this.visible;
+ },
},
},
i18n: {
@@ -116,14 +135,12 @@ export default {
deploymentId: s__('Deployment|Deployment ID'),
copyButton: __('Copy commit SHA'),
commitSha: __('Commit SHA'),
- showDetails: __('Show details'),
- hideDetails: __('Hide details'),
triggerer: s__('Deployment|Triggerer'),
needsApproval: s__('Deployment|Needs Approval'),
job: __('Job'),
api: __('API'),
branch: __('Branch'),
- tag: __('Tag'),
+ tags: __('Tags'),
},
headerClasses: [
'gl-display-flex',
@@ -179,7 +196,9 @@ export default {
class="gl-font-monospace gl-display-flex gl-align-items-center"
>
<gl-icon ref="deployment-commit-icon" name="commit" class="gl-mr-2" />
- <span v-gl-tooltip :title="$options.i18n.commitSha">{{ shortSha }}</span>
+ <gl-link v-gl-tooltip :title="$options.i18n.commitSha" :href="commitPath">
+ {{ shortSha }}
+ </gl-link>
<clipboard-button
:text="shortSha"
category="tertiary"
@@ -195,54 +214,66 @@ export default {
</time-ago-tooltip>
</div>
</div>
- <gl-button
- ref="details-toggle"
- category="tertiary"
- :icon="detailsButton.icon"
- :button-text-classes="detailsButtonClasses"
- @click="toggleCollapse"
- >
- {{ detailsButton.text }}
- </gl-button>
</div>
<commit v-if="commit" :commit="commit" class="gl-mt-3" />
<div class="gl-mt-3"><slot name="approval"></slot></div>
- <gl-collapse :visible="visible">
+ <div
+ class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
+ >
+ <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p">
+ <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span>
+ <gl-link :href="userPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="username" with-tooltip />
+ </gl-link>
+ </div>
<div
- class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
>
- <div v-if="user" class="gl-display-flex gl-flex-direction-column gl-md-max-w-15p">
- <span class="gl-text-gray-500">{{ $options.i18n.triggerer }}</span>
- <gl-link :href="userPath" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="username" with-tooltip />
- </gl-link>
- </div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
- >
- <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }">
- {{ $options.i18n.job }}
- </span>
- <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="jobName" with-tooltip position="middle" />
- </gl-link>
- <span v-else-if="jobName" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="jobName" with-tooltip position="middle" />
- </span>
- <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info">
- {{ $options.i18n.api }}
- </gl-badge>
- </div>
- <div
- v-if="ref"
- class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
- >
- <span class="gl-text-gray-500">{{ refLabel }}</span>
- <gl-link :href="refPath" class="gl-font-monospace gl-mt-3">
- <gl-truncate :text="refName" with-tooltip />
+ <span class="gl-text-gray-500" :class="{ 'gl-ml-3': !deployable }">
+ {{ $options.i18n.job }}
+ </span>
+ <gl-link v-if="jobPath" :href="jobPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </gl-link>
+ <span v-else-if="jobName" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="jobName" with-tooltip position="middle" />
+ </span>
+ <gl-badge v-else class="gl-font-monospace gl-mt-3" variant="info">
+ {{ $options.i18n.api }}
+ </gl-badge>
+ </div>
+ <div
+ v-if="ref && !isTag"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500">{{ $options.i18n.branch }}</span>
+ <gl-link :href="refPath" class="gl-font-monospace gl-mt-3">
+ <gl-truncate :text="refName" with-tooltip />
+ </gl-link>
+ </div>
+ <div
+ v-if="hasTags || $apollo.queries.tags.loading"
+ class="gl-display-flex gl-flex-direction-column gl-md-pl-7 gl-md-max-w-15p gl-mt-4 gl-md-mt-0"
+ >
+ <span class="gl-text-gray-500">{{ $options.i18n.tags }}</span>
+ <gl-loading-icon
+ v-if="$apollo.queries.tags.loading"
+ class="gl-font-monospace gl-mt-3"
+ size="sm"
+ inline
+ />
+ <div v-if="hasTags" class="gl-display-flex gl-flex-direction-row">
+ <gl-link
+ v-for="(tag, ndx) in displayTags"
+ :key="tag.name"
+ :href="tag.path"
+ class="gl-font-monospace gl-mt-3 gl-mr-3"
+ >
+ {{ tag.name }}<span v-if="ndx + 1 < tags.length">, </span>
</gl-link>
+ <div v-if="tags.length > 5" class="gl-font-monospace gl-mt-3 gl-mr-3">...</div>
</div>
</div>
- </gl-collapse>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index 75bd473497b..9a100e0199e 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -310,6 +310,7 @@ export default {
<div v-if="lastDeployment" :class="$options.deploymentClasses">
<deployment
:deployment="lastDeployment"
+ :visible="visible"
:class="{ 'gl-ml-7': inFolder }"
latest
class="gl-pl-4"
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 4e5fe511f8a..1a32de30de0 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import Translate from '~/vue_shared/translate';
-import environmentsFolderApp from './environments_folder_view.vue';
+import EnvironmentsFolderApp from './environments_folder_view.vue';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -17,7 +17,7 @@ export default () => {
return new Vue({
el,
components: {
- environmentsFolderApp,
+ EnvironmentsFolderApp,
},
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql
new file mode 100644
index 00000000000..baed777bd07
--- /dev/null
+++ b/app/assets/javascripts/environments/graphql/queries/deployment_details.query.graphql
@@ -0,0 +1,13 @@
+query getDeploymentDetails($projectPath: ID!, $iid: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ deployment(iid: $iid) {
+ id
+ iid
+ tags {
+ name
+ path
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 645c2456c6e..93510870915 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -163,7 +163,6 @@ export default {
v-gl-modal="'configure-feature-flags'"
variant="confirm"
category="secondary"
- data-qa-selector="configure_feature_flags_button"
data-testid="ff-configure-button"
class="gl-mb-3"
>
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index a8670caf5b2..a6781cffaec 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -81,6 +81,7 @@ export default class FilterableList {
onFilterSuccess(response, queryData) {
if (response.data.html) {
+ // eslint-disable-next-line no-unsanitized/property
this.listHolderElement.innerHTML = response.data.html;
}
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 4c2f55fd174..679c8caffdb 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -69,14 +69,18 @@ export default {
</script>
<template>
<div>
- <div v-if="!isLocalStorageAvailable" ref="localStorageNote" class="dropdown-info-note">
+ <div
+ v-if="!isLocalStorageAvailable"
+ data-testid="local-storage-note"
+ class="dropdown-info-note"
+ >
{{ __('This feature requires local storage to be enabled') }}
</div>
<ul v-else-if="hasItems">
<li
v-for="(item, index) in processedItems"
- ref="dropdownItem"
:key="`processed-items-${index}`"
+ data-testid="dropdown-item"
>
<button
type="button"
@@ -100,7 +104,7 @@ export default {
<li class="divider"></li>
<li>
<button
- ref="clearButton"
+ data-testid="clear-button"
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)"
@@ -109,7 +113,7 @@ export default {
</button>
</li>
</ul>
- <div v-else ref="dropdownNote" class="dropdown-info-note">
+ <div v-else data-testid="dropdown-note" class="dropdown-info-note">
{{ __("You don't have any recent searches") }}
</div>
</div>
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index 5adc074b3ce..aeea66bf51c 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -75,6 +75,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
const name = valueElement.innerText;
const emojiTag = this.glEmojiTag(name);
const emojiElement = dropdownItem.querySelector('gl-emoji');
+ // eslint-disable-next-line no-unsanitized/property
emojiElement.outerHTML = emojiTag;
}
});
diff --git a/app/assets/javascripts/filtered_search/droplab/drop_down.js b/app/assets/javascripts/filtered_search/droplab/drop_down.js
index 398a7b26677..e7edc678773 100644
--- a/app/assets/javascripts/filtered_search/droplab/drop_down.js
+++ b/app/assets/javascripts/filtered_search/droplab/drop_down.js
@@ -107,7 +107,7 @@ class DropDown {
}
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
-
+ // eslint-disable-next-line no-unsanitized/property
renderableList.innerHTML = children.join('');
const listEvent = new CustomEvent('render.dl', {
@@ -121,7 +121,7 @@ class DropDown {
renderChildren(data) {
const html = utils.template(this.templateString, data);
const template = document.createElement('div');
-
+ // eslint-disable-next-line no-unsanitized/property
template.innerHTML = html;
DropDown.setImagesSrc(template);
template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block';
diff --git a/app/assets/javascripts/filtered_search/droplab/hook_button.js b/app/assets/javascripts/filtered_search/droplab/hook_button.js
index c51d6167fa3..805905e7750 100644
--- a/app/assets/javascripts/filtered_search/droplab/hook_button.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_button.js
@@ -42,6 +42,7 @@ class HookButton extends Hook {
}
restoreInitialState() {
+ // eslint-disable-next-line no-unsanitized/property
this.list.list.innerHTML = this.list.initialState;
}
diff --git a/app/assets/javascripts/filtered_search/droplab/hook_input.js b/app/assets/javascripts/filtered_search/droplab/hook_input.js
index c523dae347f..32dfe0372bb 100644
--- a/app/assets/javascripts/filtered_search/droplab/hook_input.js
+++ b/app/assets/javascripts/filtered_search/droplab/hook_input.js
@@ -97,6 +97,7 @@ class HookInput extends Hook {
}
restoreInitialState() {
+ // eslint-disable-next-line no-unsanitized/property
this.list.list.innerHTML = this.list.initialState;
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 7143cb50ea6..0c01220a7be 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -122,6 +122,7 @@ export default class FilteredSearchVisualTokens {
const hasOperator = Boolean(operator);
if (value) {
+ // eslint-disable-next-line no-unsanitized/property
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
@@ -138,6 +139,7 @@ export default class FilteredSearchVisualTokens {
operatorHTML = '<div class="operator"></div>';
}
+ // eslint-disable-next-line no-unsanitized/property
li.innerHTML = nameHTML + operatorHTML;
}
@@ -160,6 +162,8 @@ export default class FilteredSearchVisualTokens {
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
const operator = FilteredSearchVisualTokens.getLastTokenOperator();
+
+ // eslint-disable-next-line no-unsanitized/property
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
hasOperator: Boolean(operator),
});
@@ -293,6 +297,7 @@ export default class FilteredSearchVisualTokens {
const button = lastVisualToken.querySelector('.selectable');
const valueContainer = lastVisualToken.querySelector('.value-container');
button.removeChild(valueContainer);
+ // eslint-disable-next-line no-unsanitized/property
lastVisualToken.innerHTML = button.innerHTML;
} else if (operator) {
lastVisualToken.removeChild(operator);
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 707add10009..0d144398531 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -47,6 +47,7 @@ export default class VisualTokenValue {
/* eslint-disable no-param-reassign */
tokenValueContainer.dataset.originalValue = tokenValue;
+ // eslint-disable-next-line no-unsanitized/property
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
${escape(user.name)}
@@ -152,6 +153,7 @@ export default class VisualTokenValue {
}
container.dataset.originalValue = value;
+ // eslint-disable-next-line no-unsanitized/property
element.innerHTML = Emoji.glEmojiTag(value);
});
}
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 5a47e76d597..edf83a33812 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -236,11 +236,13 @@ const createFlash = function createFlash({
if (!flashContainer) return null;
+ // eslint-disable-next-line no-unsanitized/property
flashContainer.innerHTML = createFlashEl(message, type);
const flashEl = flashContainer.querySelector(`.flash-${type}`);
if (actionConfig) {
+ // eslint-disable-next-line no-unsanitized/method
flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig));
if (actionConfig.clickHandler) {
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
index 1da0b88c9e9..c0bfcf9c4a9 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue
@@ -58,7 +58,7 @@ export default {
<template>
<div class="frequent-items-list-container">
- <ul ref="frequentItemsList" class="list-unstyled">
+ <ul data-testid="frequent-items-list" class="list-unstyled">
<li
v-if="isListEmpty"
:class="{ 'section-failure': isFetchFailed }"
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 9fb69a3cae3..33ab1d5cd7f 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
@@ -79,16 +79,19 @@ export default {
:project-name="itemName"
aria-hidden="true"
/>
- <div ref="frequentItemsItemMetadataContainer" class="frequent-items-item-metadata-container">
+ <div
+ data-testid="frequent-items-item-metadata-container"
+ class="frequent-items-item-metadata-container"
+ >
<div
- ref="frequentItemsItemTitle"
v-safe-html="highlightedItemName"
+ data-testid="frequent-items-item-title"
:title="itemName"
class="frequent-items-item-title"
></div>
<div
v-if="namespace"
- ref="frequentItemsItemNamespace"
+ data-testid="frequent-items-item-namespace"
:title="namespace"
class="frequent-items-item-namespace"
>
diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js
deleted file mode 100644
index e240a1116e8..00000000000
--- a/app/assets/javascripts/google_cloud/databases/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Vue from 'vue';
-import Panel from './panel.vue';
-
-export default (containerId = '#js-google-cloud-databases') => {
- const element = document.querySelector(containerId);
- const { ...attrs } = JSON.parse(element.getAttribute('data'));
- return new Vue({
- el: element,
- render: (createElement) => createElement(Panel, { attrs }),
- });
-};
diff --git a/app/assets/javascripts/google_cloud/databases/init_index.js b/app/assets/javascripts/google_cloud/databases/init_index.js
new file mode 100644
index 00000000000..931143833cb
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/init_index.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Panel from './panel.vue';
+
+export default () => {
+ const element = document.querySelector('#js-google-cloud-databases');
+ const attrs = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Panel, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/databases/init_new.js b/app/assets/javascripts/google_cloud/databases/init_new.js
new file mode 100644
index 00000000000..3feb2dc2f98
--- /dev/null
+++ b/app/assets/javascripts/google_cloud/databases/init_new.js
@@ -0,0 +1,11 @@
+import Vue from 'vue';
+import Form from './cloudsql/create_instance_form.vue';
+
+export default () => {
+ const element = document.querySelector('#js-google-cloud-databases-cloudsql-form');
+ const attrs = JSON.parse(element.getAttribute('data'));
+ return new Vue({
+ el: element,
+ render: (createElement) => createElement(Form, { attrs }),
+ });
+};
diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue
index e2f18c286a5..8b91c508871 100644
--- a/app/assets/javascripts/google_cloud/databases/panel.vue
+++ b/app/assets/javascripts/google_cloud/databases/panel.vue
@@ -1,11 +1,15 @@
<script>
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
import IncubationBanner from '../components/incubation_banner.vue';
+import InstanceTable from './cloudsql/instance_table.vue';
+import ServiceTable from './service_table.vue';
export default {
components: {
IncubationBanner,
+ InstanceTable,
GoogleCloudMenu,
+ ServiceTable,
},
props: {
configurationUrl: {
@@ -20,6 +24,26 @@ export default {
type: String,
required: true,
},
+ cloudsqlPostgresUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlMysqlUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlSqlserverUrl: {
+ type: String,
+ required: true,
+ },
+ cloudsqlInstances: {
+ type: Array,
+ required: true,
+ },
+ emptyIllustrationUrl: {
+ type: String,
+ required: true,
+ },
},
};
</script>
@@ -34,5 +58,19 @@ export default {
:deployments-url="deploymentsUrl"
:databases-url="databasesUrl"
/>
+
+ <service-table
+ alloydb-postgres-url="#"
+ :cloudsql-mysql-url="cloudsqlMysqlUrl"
+ :cloudsql-postgres-url="cloudsqlPostgresUrl"
+ :cloudsql-sqlserver-url="cloudsqlSqlserverUrl"
+ firestore-url="#"
+ memorystore-redis-url="#"
+ />
+
+ <instance-table
+ :cloudsql-instances="cloudsqlInstances"
+ :empty-illustration-url="emptyIllustrationUrl"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/google_tag_manager/index.js b/app/assets/javascripts/google_tag_manager/index.js
index c8204f397ff..5b0bcfa963b 100644
--- a/app/assets/javascripts/google_tag_manager/index.js
+++ b/app/assets/javascripts/google_tag_manager/index.js
@@ -140,17 +140,6 @@ export const trackSaasTrialGroup = () => {
});
};
-export const trackSaasTrialProject = () => {
- if (!isSupported()) {
- return;
- }
-
- const form = document.getElementById('new_project');
- form.addEventListener('submit', () => {
- pushEvent('saasTrialProject');
- });
-};
-
export const trackProjectImport = () => {
if (!isSupported()) {
return;
@@ -290,3 +279,11 @@ export const trackCombinedGroupProjectForm = () => {
pushEvent('combinedGroupProjectFormSubmit');
});
};
+
+export const trackCompanyForm = (aboutYourCompanyType) => {
+ if (!isSupported()) {
+ return;
+ }
+
+ pushEvent('aboutYourCompanyFormSubmit', { aboutYourCompanyType });
+};
diff --git a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
index fb771d7ec8a..45dbfb30704 100644
--- a/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
+++ b/app/assets/javascripts/graphql_shared/fragments/issuable_timelogs.fragment.graphql
@@ -1,4 +1,5 @@
fragment TimelogFragment on Timelog {
+ __typename
id
timeSpent
user {
diff --git a/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql
new file mode 100644
index 00000000000..dbe6ad9f059
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/issue_time_tracking.fragment.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+fragment IssueTimeTrackingFragment on Issue {
+ __typename
+ id
+ humanTotalTimeSpent
+ totalTimeSpent
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql
new file mode 100644
index 00000000000..68d3c02cf2e
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql
@@ -0,0 +1,13 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+fragment MergeRequestTimeTrackingFragment on MergeRequest {
+ __typename
+ id
+ humanTotalTimeSpent
+ totalTimeSpent
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/graphql_shared/issuable_client.js
index b70c06fddea..e86103c332b 100644
--- a/app/assets/javascripts/work_items/graphql/provider.js
+++ b/app/assets/javascripts/graphql_shared/issuable_client.js
@@ -1,10 +1,11 @@
import produce from 'immer';
-import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { concatPagination } from '@apollo/client/utilities';
+import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
-import { WIDGET_TYPE_LABELS } from '../constants';
-import typeDefs from './typedefs.graphql';
-import workItemQuery from './work_item.query.graphql';
+import typeDefs from '~/work_items/graphql/typedefs.graphql';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import { WIDGET_TYPE_LABELS } from '~/work_items/constants';
export const temporaryConfig = {
typeDefs,
@@ -13,6 +14,13 @@ export const temporaryConfig = {
LocalWorkItemWidget: ['LocalWorkItemLabels'],
},
typePolicies: {
+ Project: {
+ fields: {
+ projectMembers: {
+ keyArgs: ['fullPath', 'search', 'relations', 'first'],
+ },
+ },
+ },
WorkItem: {
fields: {
mockWidgets: {
@@ -36,12 +44,24 @@ export const temporaryConfig = {
},
},
},
+ MemberInterfaceConnection: {
+ fields: {
+ nodes: concatPagination(),
+ },
+ },
},
},
};
export const resolvers = {
Mutation: {
+ updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
+ const sourceData = cache.readQuery({ query: getIssueStateQuery });
+ const data = produce(sourceData, (draftData) => {
+ draftData.issueState = { issueType, isDirty };
+ });
+ cache.writeQuery({ query: getIssueStateQuery, data });
+ },
localUpdateWorkItem(_, { input }, { cache }) {
const sourceData = cache.readQuery({
query: workItemQuery,
@@ -66,12 +86,8 @@ export const resolvers = {
},
};
-export function createApolloProvider() {
- Vue.use(VueApollo);
-
- const defaultClient = createDefaultClient(resolvers, temporaryConfig);
+export const defaultClient = createDefaultClient(resolvers, temporaryConfig);
- return new VueApollo({
- defaultClient,
- });
-}
+export const apolloProvider = new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json
index eac325f184f..72dbf9e7b7b 100644
--- a/app/assets/javascripts/graphql_shared/possible_types.json
+++ b/app/assets/javascripts/graphql_shared/possible_types.json
@@ -140,6 +140,7 @@
"WorkItemWidgetAssignees",
"WorkItemWidgetDescription",
"WorkItemWidgetHierarchy",
+ "WorkItemWidgetIteration",
"WorkItemWidgetLabels",
"WorkItemWidgetStartAndDueDate",
"WorkItemWidgetVerificationStatus",
diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql
index 0c0a874d950..0c0a874d950 100644
--- a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
index bb34e4032f4..f64c4276deb 100644
--- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -1,10 +1,20 @@
#import "../fragments/user.fragment.graphql"
#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
-query projectUsersSearch($search: String!, $fullPath: ID!) {
+query projectUsersSearch($search: String!, $fullPath: ID!, $after: String, $first: Int) {
workspace: project(fullPath: $fullPath) {
id
- users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) {
+ users: projectMembers(
+ search: $search
+ relations: [DIRECT, INHERITED, INVITED_GROUPS]
+ first: $first
+ after: $after
+ ) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ startCursor
+ }
nodes {
id
user {
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index cd5521c599e..0bd7371d39b 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -17,11 +17,6 @@ export default {
GlLoadingIcon,
EmptyState,
},
- inject: {
- renderEmptyState: {
- default: false,
- },
- },
props: {
action: {
type: String,
@@ -45,6 +40,11 @@ export default {
type: Boolean,
required: true,
},
+ renderEmptyState: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -224,6 +224,9 @@ export default {
},
showLegacyEmptyState() {
const { containerEl } = this;
+
+ if (!containerEl) return;
+
const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS);
const emptyStateEl = containerEl.querySelector('.empty-state');
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 2f182b86d2c..961af800971 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,15 +16,15 @@ 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_LEVELS_ENUM } from '~/visibility_level/constants';
+import { VISIBILITY_LEVELS_STRING_TO_INTEGER } 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';
-import itemCaret from './item_caret.vue';
-import itemStats from './item_stats.vue';
-import itemTypeIcon from './item_type_icon.vue';
+import ItemActions from './item_actions.vue';
+import ItemCaret from './item_caret.vue';
+import ItemStats from './item_stats.vue';
+import ItemTypeIcon from './item_type_icon.vue';
export default {
directives: {
@@ -41,10 +41,10 @@ export default {
GlPopover,
GlLink,
UserAccessRoleBadge,
- itemCaret,
- itemTypeIcon,
- itemActions,
- itemStats,
+ ItemCaret,
+ ItemTypeIcon,
+ ItemActions,
+ ItemStats,
},
inject: ['currentGroupVisibility'],
props: {
@@ -111,8 +111,8 @@ export default {
shouldShowVisibilityWarning() {
return (
this.action === 'shared' &&
- VISIBILITY_LEVELS_ENUM[this.group.visibility] >
- VISIBILITY_LEVELS_ENUM[this.currentGroupVisibility]
+ VISIBILITY_LEVELS_STRING_TO_INTEGER[this.group.visibility] >
+ VISIBILITY_LEVELS_STRING_TO_INTEGER[this.currentGroupVisibility]
);
},
},
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 2aa812250a0..a4c163b0a81 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -1,19 +1,19 @@
<script>
import { GlBadge } from '@gitlab/ui';
import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
ITEM_TYPE,
VISIBILITY_TYPE_ICON,
GROUP_VISIBILITY_TYPE,
PROJECT_VISIBILITY_TYPE,
} from '../constants';
-import itemStatsValue from './item_stats_value.vue';
+import ItemStatsValue from './item_stats_value.vue';
export default {
components: {
- timeAgoTooltip,
- itemStatsValue,
+ TimeAgoTooltip,
+ ItemStatsValue,
GlBadge,
},
mixins: [isProjectPendingRemoval],
diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue
new file mode 100644
index 00000000000..325e42af0f8
--- /dev/null
+++ b/app/assets/javascripts/groups/components/overview_tabs.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlTabs, GlTab } from '@gitlab/ui';
+import { isString } from 'lodash';
+import { __ } from '~/locale';
+import GroupsStore from '../store/groups_store';
+import GroupsService from '../service/groups_service';
+import {
+ ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ ACTIVE_TAB_SHARED,
+ ACTIVE_TAB_ARCHIVED,
+} from '../constants';
+import GroupsApp from './app.vue';
+
+export default {
+ components: { GlTabs, GlTab, GroupsApp },
+ inject: ['endpoints'],
+ data() {
+ return {
+ tabs: [
+ {
+ title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS],
+ key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ renderEmptyState: true,
+ lazy: false,
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]),
+ store: new GroupsStore({ showSchemaMarkup: true }),
+ },
+ {
+ title: this.$options.i18n[ACTIVE_TAB_SHARED],
+ key: ACTIVE_TAB_SHARED,
+ renderEmptyState: false,
+ lazy: true,
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]),
+ store: new GroupsStore(),
+ },
+ {
+ title: this.$options.i18n[ACTIVE_TAB_ARCHIVED],
+ key: ACTIVE_TAB_ARCHIVED,
+ renderEmptyState: false,
+ lazy: true,
+ service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]),
+ store: new GroupsStore(),
+ },
+ ],
+ activeTabIndex: 0,
+ };
+ },
+ mounted() {
+ const activeTabIndex = this.tabs.findIndex((tab) => tab.key === this.$route.name);
+
+ if (activeTabIndex === -1) {
+ return;
+ }
+
+ this.activeTabIndex = activeTabIndex;
+ },
+ methods: {
+ handleTabInput(tabIndex) {
+ if (tabIndex === this.activeTabIndex) {
+ return;
+ }
+
+ this.activeTabIndex = tabIndex;
+
+ const tab = this.tabs[tabIndex];
+ tab.lazy = false;
+
+ // Vue router will convert `/` to `%2F` if you pass a string as a param
+ // If you pass an array as a param it will concatenate them with a `/`
+ // This makes sure we are always passing an array for the group param
+ const groupParam = isString(this.$route.params.group)
+ ? this.$route.params.group.split('/')
+ : this.$route.params.group;
+
+ this.$router.push({ name: tab.key, params: { group: groupParam } });
+ },
+ },
+ i18n: {
+ [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: __('Subgroups and projects'),
+ [ACTIVE_TAB_SHARED]: __('Shared projects'),
+ [ACTIVE_TAB_ARCHIVED]: __('Archived projects'),
+ },
+};
+</script>
+
+<template>
+ <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput">
+ <gl-tab
+ v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs"
+ :key="key"
+ :title="title"
+ :lazy="lazy"
+ >
+ <groups-app
+ :action="key"
+ :service="service"
+ :store="store"
+ :hide-projects="false"
+ :render-empty-state="renderEmptyState"
+ />
+ </gl-tab>
+ </gl-tabs>
+</template>
diff --git a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue
deleted file mode 100644
index 0933045fc38..00000000000
--- a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-
-export default {
- components: {
- GlDropdown,
- GlDropdownItem,
- },
- props: {
- visibilityLevelOptions: {
- type: Array,
- required: true,
- },
- defaultLevel: {
- type: Number,
- required: true,
- },
- },
- data() {
- return {
- selectedOption: this.getDefaultOption(),
- };
- },
- methods: {
- getDefaultOption() {
- return this.visibilityLevelOptions.find((option) => option.level === this.defaultLevel);
- },
- onClick(option) {
- this.selectedOption = option;
- },
- },
-};
-</script>
-<template>
- <div>
- <input type="hidden" name="group[visibility_level]" :value="selectedOption.level" />
- <gl-dropdown :text="selectedOption.label" class="gl-w-full" menu-class="gl-w-full! gl-mb-0">
- <gl-dropdown-item
- v-for="option in visibilityLevelOptions"
- :key="option.level"
- :secondary-text="option.description"
- @click="onClick(option)"
- >
- <div class="gl-font-weight-bold gl-mb-1">{{ option.label }}</div>
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 0d09ad9442b..223c2975c11 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,8 +1,8 @@
import { __, s__ } from '~/locale';
import {
- VISIBILITY_LEVEL_PRIVATE,
- VISIBILITY_LEVEL_INTERNAL,
- VISIBILITY_LEVEL_PUBLIC,
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -34,29 +34,31 @@ export const ITEM_TYPE = {
};
export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC]: __(
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
'Public - The group and any public projects can be viewed without any authentication.',
),
- [VISIBILITY_LEVEL_INTERNAL]: __(
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
),
- [VISIBILITY_LEVEL_PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
'Private - The group and its projects can only be viewed by members.',
),
};
export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC]: __('Public - The project can be accessed without any authentication.'),
- [VISIBILITY_LEVEL_INTERNAL]: __(
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The project can be accessed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
'Internal - The project can be accessed by any logged in user except external users.',
),
- [VISIBILITY_LEVEL_PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
'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_LEVEL_PUBLIC]: 'earth',
- [VISIBILITY_LEVEL_INTERNAL]: 'shield',
- [VISIBILITY_LEVEL_PRIVATE]: 'lock',
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
};
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index a502fcd31ad..c3bf3f28509 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -4,9 +4,9 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
import Translate from '../vue_shared/translate';
-import groupsApp from './components/app.vue';
-import groupFolderComponent from './components/group_folder.vue';
-import groupItemComponent from './components/group_item.vue';
+import GroupsApp from './components/app.vue';
+import GroupFolderComponent from './components/group_folder.vue';
+import GroupItemComponent from './components/group_item.vue';
import { GROUPS_LIST_HOLDER_CLASS, CONTENT_LIST_CLASS } from './constants';
import GroupFilterableList from './groups_filterable_list';
import GroupsService from './service/groups_service';
@@ -33,8 +33,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
dataEl = containerEl.querySelector(CONTENT_LIST_CLASS);
}
- Vue.component('GroupFolder', groupFolderComponent);
- Vue.component('GroupItem', groupItemComponent);
+ Vue.component('GroupFolder', GroupFolderComponent);
+ Vue.component('GroupItem', GroupItemComponent);
Vue.use(GlToast);
@@ -42,7 +42,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
new Vue({
el,
components: {
- groupsApp,
+ GroupsApp,
},
provide() {
const {
@@ -52,7 +52,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newSubgroupIllustration,
newProjectIllustration,
emptySubgroupIllustration,
- renderEmptyState,
canCreateSubgroups,
canCreateProjects,
currentGroupVisibility,
@@ -65,7 +64,6 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
newSubgroupIllustration,
newProjectIllustration,
emptySubgroupIllustration,
- renderEmptyState: parseBoolean(renderEmptyState),
canCreateSubgroups: parseBoolean(canCreateSubgroups),
canCreateProjects: parseBoolean(canCreateProjects),
currentGroupVisibility,
@@ -75,6 +73,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects);
const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
+ const renderEmptyState = parseBoolean(dataset.renderEmptyState);
const service = new GroupsService(endpoint || dataset.endpoint);
const store = new GroupsStore({ hideProjects, showSchemaMarkup });
@@ -83,6 +82,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
store,
service,
hideProjects,
+ renderEmptyState,
loading: true,
containerId,
};
@@ -119,6 +119,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
store: this.store,
service: this.service,
hideProjects: this.hideProjects,
+ renderEmptyState: this.renderEmptyState,
containerId: this.containerId,
},
});
diff --git a/app/assets/javascripts/groups/init_overview_tabs.js b/app/assets/javascripts/groups/init_overview_tabs.js
new file mode 100644
index 00000000000..4fa3682c729
--- /dev/null
+++ b/app/assets/javascripts/groups/init_overview_tabs.js
@@ -0,0 +1,78 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { GlToast } from '@gitlab/ui';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import GroupFolder from './components/group_folder.vue';
+import GroupItem from './components/group_item.vue';
+import {
+ ACTIVE_TAB_SUBGROUPS_AND_PROJECTS,
+ ACTIVE_TAB_SHARED,
+ ACTIVE_TAB_ARCHIVED,
+} from './constants';
+import OverviewTabs from './components/overview_tabs.vue';
+
+export const createRouter = () => {
+ const routes = [
+ { name: ACTIVE_TAB_SHARED, path: '/groups/:group*/-/shared' },
+ { name: ACTIVE_TAB_ARCHIVED, path: '/groups/:group*/-/archived' },
+ { name: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, path: '/:group*' },
+ ];
+
+ const router = new VueRouter({
+ routes,
+ mode: 'history',
+ base: '/',
+ });
+
+ return router;
+};
+
+export const initGroupOverviewTabs = () => {
+ const el = document.getElementById('js-group-overview-tabs');
+
+ if (!el) return false;
+
+ Vue.component('GroupFolder', GroupFolder);
+ Vue.component('GroupItem', GroupItem);
+ Vue.use(GlToast);
+ Vue.use(VueRouter);
+
+ const router = createRouter();
+
+ const {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ canCreateSubgroups,
+ canCreateProjects,
+ currentGroupVisibility,
+ subgroupsAndProjectsEndpoint,
+ sharedProjectsEndpoint,
+ archivedProjectsEndpoint,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ router,
+ provide: {
+ newSubgroupPath,
+ newProjectPath,
+ newSubgroupIllustration,
+ newProjectIllustration,
+ emptySubgroupIllustration,
+ canCreateSubgroups: parseBoolean(canCreateSubgroups),
+ canCreateProjects: parseBoolean(canCreateProjects),
+ currentGroupVisibility,
+ endpoints: {
+ [ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: subgroupsAndProjectsEndpoint,
+ [ACTIVE_TAB_SHARED]: sharedProjectsEndpoint,
+ [ACTIVE_TAB_ARCHIVED]: archivedProjectsEndpoint,
+ },
+ },
+ render(createElement) {
+ return createElement(OverviewTabs);
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups/visibility_level.js b/app/assets/javascripts/groups/visibility_level.js
deleted file mode 100644
index d570b5e65ac..00000000000
--- a/app/assets/javascripts/groups/visibility_level.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import Vue from 'vue';
-import VisibilityLevelDropdown from './components/visibility_level_dropdown.vue';
-
-export default () => {
- const el = document.querySelector('.js-visibility-level-dropdown');
-
- if (!el) {
- return null;
- }
-
- const { visibilityLevelOptions, defaultLevel } = el.dataset;
-
- return new Vue({
- el,
- render(createElement) {
- return createElement(VisibilityLevelDropdown, {
- props: {
- visibilityLevelOptions: JSON.parse(visibilityLevelOptions),
- defaultLevel: Number(defaultLevel),
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 3a20fb0216d..332ccee510f 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -26,11 +26,17 @@ export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
-export const ISSUES_CATEGORY = 'Recent issues';
+export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
-export const MERGE_REQUEST_CATEGORY = 'Recent merge requests';
+export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
-export const RECENT_EPICS_CATEGORY = 'Recent epics';
+export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
+
+export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
+
+export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
+
+export const HELP_CATEGORY = s__('GlobalSearch|Help');
export const LARGE_AVATAR_PX = 32;
@@ -55,3 +61,16 @@ 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';
+
+export const DROPDOWN_ORDER = [
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ IN_THIS_PROJECT_CATEGORY,
+ SETTINGS_CATEGORY,
+ HELP_CATEGORY,
+];
+
+export const FETCH_TYPES = ['generic', 'search'];
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
index 3a86dcca409..a0f9e594506 100644
--- a/app/assets/javascripts/header_search/store/actions.js
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -1,10 +1,26 @@
+import { omitBy, isNil } from 'lodash';
+import { objectToQuery } from '~/lib/utils/url_utility';
import axios from '~/lib/utils/axios_utils';
+import { FETCH_TYPES } from '../constants';
import * as types from './mutation_types';
-export const fetchAutocompleteOptions = ({ commit, getters }) => {
- commit(types.REQUEST_AUTOCOMPLETE);
+export const autocompleteQuery = ({ state, fetchType }) => {
+ const query = omitBy(
+ {
+ term: state.search,
+ project_id: state.searchContext?.project?.id,
+ project_ref: state.searchContext?.ref,
+ filter: fetchType,
+ },
+ isNil,
+ );
+
+ return `${state.autocompletePath}?${objectToQuery(query)}`;
+};
+
+const doFetch = ({ commit, state, fetchType }) => {
return axios
- .get(getters.autocompleteQuery)
+ .get(autocompleteQuery({ state, fetchType }))
.then(({ data }) => {
commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data);
})
@@ -13,6 +29,13 @@ export const fetchAutocompleteOptions = ({ commit, getters }) => {
});
};
+export const fetchAutocompleteOptions = ({ commit, state }) => {
+ commit(types.REQUEST_AUTOCOMPLETE);
+ const promises = FETCH_TYPES.map((fetchType) => doFetch({ commit, state, fetchType }));
+
+ return Promise.all(promises);
+};
+
export const clearAutocomplete = ({ commit }) => {
commit(types.CLEAR_AUTOCOMPLETE);
};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index da7bccd35c0..3da9d2cd961 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -14,6 +14,7 @@ import {
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
+ DROPDOWN_ORDER,
} from '../constants';
export const searchQuery = (state) => {
@@ -34,19 +35,6 @@ export const searchQuery = (state) => {
return `${state.searchPath}?${objectToQuery(query)}`;
};
-export const autocompleteQuery = (state) => {
- const query = omitBy(
- {
- term: state.search,
- project_id: state.searchContext?.project?.id,
- project_ref: state.searchContext?.ref,
- },
- isNil,
- );
-
- return `${state.autocompletePath}?${objectToQuery(query)}`;
-};
-
export const scopedIssuesPath = (state) => {
return (
state.searchContext?.project_metadata?.issues_path ||
@@ -197,7 +185,9 @@ export const autocompleteGroupedSearchOptions = (state) => {
}
});
- return results;
+ return results.sort(
+ (a, b) => DROPDOWN_ORDER.indexOf(a.category) - DROPDOWN_ORDER.indexOf(b.category),
+ );
};
export const searchOptions = (state, getters) => {
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
index 92948bec515..19b4d4ec330 100644
--- a/app/assets/javascripts/header_search/store/mutations.js
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -8,9 +8,11 @@ export default {
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
- state.autocompleteOptions = data.map((d, i) => {
- return { html_id: `autocomplete-${d.category}-${i}`, ...d };
- });
+ state.autocompleteOptions = [...state.autocompleteOptions].concat(
+ data.map((d, i) => {
+ return { html_id: `autocomplete-${d.category}-${i}`, ...d };
+ }),
+ );
state.autocompleteError = false;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index 52593aabfea..d40aab8ee4f 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -50,7 +50,7 @@ export default {
<gl-dropdown-item
v-for="mode in modeDropdownItems"
:key="mode.viewerType"
- :is-check-item="true"
+ is-check-item
:is-checked="viewer === mode.viewerType"
@click="changeMode(mode.viewerType)"
>
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index e0b7ac9b1e1..8962bb76926 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -4,7 +4,7 @@ import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { rightSidebarViews } from '../constants';
import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
@@ -12,7 +12,7 @@ import IdeStatusMr from './ide_status_mr.vue';
export default {
components: {
GlIcon,
- userAvatarImage,
+ UserAvatarImage,
CiIcon,
IdeStatusList,
IdeStatusMr,
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 87b60eca73c..9a529bdcee1 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -4,12 +4,12 @@ import { mapActions } from 'vuex';
import { modalTypes } from '../../constants';
import ItemButton from './button.vue';
import NewModal from './modal.vue';
-import upload from './upload.vue';
+import Upload from './upload.vue';
export default {
components: {
GlIcon,
- upload,
+ Upload,
ItemButton,
NewModal,
},
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index d6207d4a557..9684bf8f18c 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -176,7 +176,11 @@ export default {
:placeholder="placeholder"
/>
</form>
- <ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list">
+ <ul
+ v-if="isCreatingNewFile"
+ class="file-templates gl-mt-3 list-inline"
+ data-qa-selector="template_list_content"
+ >
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<gl-button
variant="dashed"
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index df643675357..10e9f6a9488 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -8,6 +8,7 @@ import { parseBoolean } from '../lib/utils/common_utils';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import ide from './components/ide.vue';
import { createRouter } from './ide_router';
+import { initGitlabWebIDE } from './init_gitlab_web_ide';
import { DEFAULT_THEME } from './lib/themes';
import { createStore } from './stores';
@@ -34,7 +35,7 @@ Vue.use(PerformancePlugin, {
* @param {extendStoreCallback} options.extendStore -
* Function that receives the default store and returns an extended one.
*/
-export const initIde = (el, options = {}) => {
+export const initLegacyWebIDE = (el, options = {}) => {
if (!el) return null;
const { rootComponent = ide, extendStore = identity } = options;
@@ -93,8 +94,15 @@ export const initIde = (el, options = {}) => {
*/
export function startIde(options) {
const ideElement = document.getElementById('ide');
- if (ideElement) {
+
+ if (!ideElement) {
+ return;
+ }
+
+ if (gon.features?.vscodeWebIde) {
+ initGitlabWebIDE(ideElement);
+ } else {
resetServiceWorkersPublicPath();
- initIde(ideElement, options);
+ initLegacyWebIDE(ideElement, options);
}
}
diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js
new file mode 100644
index 00000000000..a061da38d4f
--- /dev/null
+++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js
@@ -0,0 +1,30 @@
+import { cleanTrailingSlash } from './stores/utils';
+
+export const initGitlabWebIDE = async (el) => {
+ const { start } = await import('@gitlab/web-ide');
+
+ const { gitlab_url: gitlabUrl } = window.gon;
+ const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin);
+
+ // what: Pull what we need from the element. We will replace it soon.
+ const { path_with_namespace: projectPath } = JSON.parse(el.dataset.project);
+ const { cspNonce: nonce, branchName: ref } = el.dataset;
+
+ // what: Clean up the element, but preserve id.
+ // why: This way we don't inherit any `ide-loading` side-effects. This
+ // mirrors the behavior of Vue when it mounts to an element.
+ const newEl = document.createElement(el.tagName);
+ newEl.id = el.id;
+ newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full');
+
+ el.replaceWith(newEl);
+
+ // what: Trigger start on our new mounting element
+ await start(newEl, {
+ baseUrl: cleanTrailingSlash(baseUrl.href),
+ projectPath,
+ gitlabUrl,
+ ref,
+ nonce,
+ });
+};
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 5ff00394e3b..35d8ec32bdf 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -30,6 +30,7 @@ export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['image-comment-badge']);
+ // eslint-disable-next-line no-unsanitized/property
buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index deaef686f59..2b5cb70737f 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -8,6 +8,7 @@ export function addCommentIndicator(containerEl, { x, y }) {
buttonEl.style.left = `${x}px`;
buttonEl.style.top = `${y}px`;
+ // eslint-disable-next-line no-unsanitized/property
buttonEl.innerHTML = spriteIcon('image-comment-dark');
containerEl.appendChild(buttonEl);
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 87f1ed31a7f..a334f5e4bf7 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -118,6 +118,7 @@ export default {
selectedAccessLevel: undefined,
errorsLimit: 2,
isErrorsSectionExpanded: false,
+ emptyInvitesError: false,
};
},
computed: {
@@ -133,8 +134,8 @@ export default {
labelIntroText() {
return this.$options.labels[this.inviteTo][this.mode].introText;
},
- inviteDisabled() {
- return this.newUsersToInvite.length === 0;
+ isEmptyInvites() {
+ return Boolean(this.newUsersToInvite.length);
},
hasInvalidMembers() {
return !isEmpty(this.invalidMembers);
@@ -219,6 +220,18 @@ export default {
});
},
},
+ watch: {
+ isEmptyInvites: {
+ handler(updatedValue) {
+ // nothing to do if the invites are **still** empty and the emptyInvites were never set from submit
+ if (!updatedValue && !this.emptyInvitesError) {
+ return;
+ }
+
+ this.clearEmptyInviteError();
+ },
+ },
+ },
mounted() {
eventHub.$on('openModal', (options) => {
this.openModal(options);
@@ -260,10 +273,19 @@ export default {
const tracking = new ExperimentTracking(experimentName);
tracking.event(eventName);
},
+ showEmptyInvitesError() {
+ this.invalidFeedbackMessage = this.$options.labels.emptyInvitesErrorText;
+ this.emptyInvitesError = true;
+ },
sendInvite({ accessLevel, expiresAt }) {
this.isLoading = true;
this.clearValidation();
+ if (!this.isEmptyInvites) {
+ this.showEmptyInvitesError();
+ return;
+ }
+
const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite();
const apiAddByInvite = this.isProject
@@ -338,6 +360,10 @@ export default {
this.invalidFeedbackMessage = '';
this.invalidMembers = {};
},
+ clearEmptyInviteError() {
+ this.invalidFeedbackMessage = '';
+ this.emptyInvitesError = false;
+ },
removeToken(token) {
delete this.invalidMembers[memberName(token)];
this.invalidMembers = { ...this.invalidMembers };
@@ -360,7 +386,6 @@ export default {
:label-intro-text="labelIntroText"
:label-search-field="$options.labels.searchField"
:form-group-description="formGroupDescription"
- :submit-disabled="inviteDisabled"
:invalid-feedback-message="invalidFeedbackMessage"
:is-loading="isLoading"
:new-users-to-invite="newUsersToInvite"
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 6c9b1f8e6d0..c3d9d959ef6 100644
--- a/app/assets/javascripts/invite_members/components/user_limit_notification.vue
+++ b/app/assets/javascripts/invite_members/components/user_limit_notification.vue
@@ -8,8 +8,6 @@ import {
REACHED_LIMIT_MESSAGE,
REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
CLOSE_TO_LIMIT_MESSAGE,
- CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
- DANGER_ALERT_TITLE_PERSONAL_NAMESPACE,
} from '../constants';
export default {
@@ -52,13 +50,6 @@ export default {
});
},
dangerAlertTitle() {
- if (this.usersLimitDataset.userNamespace) {
- return sprintf(DANGER_ALERT_TITLE_PERSONAL_NAMESPACE, {
- count: this.freeUsersLimit,
- members: this.pluralMembers(this.freeUsersLimit),
- });
- }
-
return sprintf(DANGER_ALERT_TITLE, {
count: this.freeUsersLimit,
members: this.pluralMembers(this.freeUsersLimit),
@@ -71,20 +62,9 @@ export default {
title() {
return this.reachedLimit ? this.dangerAlertTitle : this.warningAlertTitle;
},
- reachedLimitMessage() {
- if (this.usersLimitDataset.userNamespace) {
- return this.$options.i18n.reachedLimitMessage;
- }
-
- return this.$options.i18n.reachedLimitUpgradeSuggestionMessage;
- },
message() {
if (this.reachedLimit) {
- return this.reachedLimitMessage;
- }
-
- if (this.usersLimitDataset.userNamespace) {
- return this.$options.i18n.closeToLimitMessagePersonalNamespace;
+ return this.$options.i18n.reachedLimitUpgradeSuggestionMessage;
}
return this.$options.i18n.closeToLimitMessage;
@@ -99,7 +79,6 @@ export default {
reachedLimitMessage: REACHED_LIMIT_MESSAGE,
reachedLimitUpgradeSuggestionMessage: REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE,
closeToLimitMessage: CLOSE_TO_LIMIT_MESSAGE,
- closeToLimitMessagePersonalNamespace: CLOSE_TO_LIMIT_MESSAGE_PERSONAL_NAMESPACE,
},
};
</script>
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 1ceb63e2146..f502e1ea369 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -81,6 +81,9 @@ export const MEMBER_ERROR_LIST_TEXT = s__(
);
export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})');
export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less');
+export const EMPTY_INVITES_ERROR_TEXT = s__(
+ 'InviteMembersModal|Please select members or type email addresses to invite',
+);
export const MEMBER_MODAL_LABELS = {
modal: {
@@ -119,6 +122,7 @@ export const MEMBER_MODAL_LABELS = {
memberErrorListText: MEMBER_ERROR_LIST_TEXT,
collapsedErrors: COLLAPSED_ERRORS,
expandedErrors: EXPANDED_ERRORS,
+ emptyInvitesErrorText: EMPTY_INVITES_ERROR_TEXT,
};
export const GROUP_MODAL_LABELS = {
@@ -146,10 +150,6 @@ export const DANGER_ALERT_TITLE = s__(
"InviteMembersModal|You've reached your %{count} %{members} limit for %{name}",
);
-export const DANGER_ALERT_TITLE_PERSONAL_NAMESPACE = s__(
- "InviteMembersModal|You've reached your %{count} %{members} limit for your personal projects",
-);
-
export const REACHED_LIMIT_MESSAGE = s__(
'InviteMembersModal|You cannot add more members, but you can remove members who no longer need access.',
);
@@ -163,6 +163,3 @@ export const REACHED_LIMIT_UPGRADE_SUGGESTION_MESSAGE = REACHED_LIMIT_MESSAGE.co
export const CLOSE_TO_LIMIT_MESSAGE = s__(
'InviteMembersModal|To get more members an owner of 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 6e2c0ecb5bb..a4be3f205a3 100644
--- a/app/assets/javascripts/invite_members/init_invite_members_modal.js
+++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js
@@ -20,8 +20,6 @@ export default (function initInviteMembersModal() {
return false;
}
- const usersLimitDataset = JSON.parse(el.dataset.usersLimitDataset || '{}');
-
inviteMembersModal = new Vue({
el,
name: 'InviteMembersModalRoot',
@@ -40,10 +38,9 @@ export default (function initInviteMembersModal() {
projects: JSON.parse(el.dataset.projects || '[]'),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
- usersLimitDataset: convertObjectPropsToCamelCase({
- ...usersLimitDataset,
- user_namespace: parseBoolean(usersLimitDataset.user_namespace),
- }),
+ usersLimitDataset: convertObjectPropsToCamelCase(
+ JSON.parse(el.dataset.usersLimitDataset || '{}'),
+ ),
},
}),
});
diff --git a/app/assets/javascripts/issuable/components/issue_assignees.vue b/app/assets/javascripts/issuable/components/issue_assignees.vue
index 5955f31fc70..21f35690f6d 100644
--- a/app/assets/javascripts/issuable/components/issue_assignees.vue
+++ b/app/assets/javascripts/issuable/components/issue_assignees.vue
@@ -91,7 +91,7 @@ export default {
data-qa-selector="assignee_link"
>
<span class="js-assignee-tooltip">
- <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
+ <span class="bold d-block">{{ s__('Label|Assignee') }}</span> {{ assignee.name }}
<span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue
index 667c712d3be..8894e8f63b8 100644
--- a/app/assets/javascripts/issuable/components/related_issuable_item.vue
+++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue
@@ -11,6 +11,7 @@ import {
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { isMetaKey } from '~/lib/utils/common_utils';
import { setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
@@ -80,6 +81,9 @@ export default {
methods: {
handleTitleClick(event) {
if (this.workItemType === 'TASK') {
+ if (isMetaKey(event)) {
+ return;
+ }
event.preventDefault();
this.$refs.modal.show();
this.updateWorkItemIdUrlQuery(this.idKey);
diff --git a/app/assets/javascripts/issuable/components/status_box.vue b/app/assets/javascripts/issuable/components/status_box.vue
index d72ee5c6757..6c4ffc44444 100644
--- a/app/assets/javascripts/issuable/components/status_box.vue
+++ b/app/assets/javascripts/issuable/components/status_box.vue
@@ -65,7 +65,7 @@ export default {
data() {
if (!this.iid) return { state: this.initialState };
- if (this.initialState) {
+ if (this.initialState && !badgeState.state) {
badgeState.state = this.initialState;
}
diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js
index cc2608b5c62..81bf7ca6ccc 100644
--- a/app/assets/javascripts/issuable/issuable_form.js
+++ b/app/assets/javascripts/issuable/issuable_form.js
@@ -39,12 +39,26 @@ function format(searchTerm, isFallbackKey = false) {
return formattedQuery;
}
+function getSearchTerm(newIssuePath) {
+ const { search, pathname } = document.location;
+ return newIssuePath === pathname ? '' : format(search);
+}
+
function getFallbackKey() {
const searchTerm = format(document.location.search, true);
return ['autosave', document.location.pathname, searchTerm].join('/');
}
export default class IssuableForm {
+ static addAutosave(map, id, $input, searchTerm, fallbackKey) {
+ if ($input.length) {
+ map.set(
+ id,
+ new Autosave($input, [document.location.pathname, searchTerm, id], `${fallbackKey}=${id}`),
+ );
+ }
+ }
+
constructor(form) {
if (form.length === 0) {
return;
@@ -72,14 +86,15 @@ export default class IssuableForm {
this.reviewersSelect = new UsersSelect(undefined, '.js-reviewer-search');
this.zenMode = new ZenMode();
- this.newIssuePath = form[0].getAttribute(DATA_ISSUES_NEW_PATH);
+ this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH));
+ this.fallbackKey = getFallbackKey();
this.titleField = this.form.find('input[name*="[title]"]');
this.descriptionField = this.form.find('textarea[name*="[description]"]');
if (!(this.titleField.length && this.descriptionField.length)) {
return;
}
- this.initAutosave();
+ this.autosaves = this.initAutosave();
this.form.on('submit', this.handleSubmit);
this.form.on('click', '.btn-cancel, .js-reset-autosave', this.resetAutosave);
this.form.find('.js-unwrap-on-load').unwrap();
@@ -95,7 +110,10 @@ export default class IssuableForm {
container: $issuableDueDate.parent().get(0),
parse: (dateString) => parsePikadayDate(dateString),
toString: (date) => pikadayToString(date),
- onSelect: (dateText) => $issuableDueDate.val(calendar.toString(dateText)),
+ onSelect: (dateText) => {
+ $issuableDueDate.val(calendar.toString(dateText));
+ if (this.autosaves.has('due_date')) this.autosaves.get('due_date').save();
+ },
firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($issuableDueDate.val()));
@@ -109,21 +127,37 @@ export default class IssuableForm {
}
initAutosave() {
- const { search, pathname } = document.location;
- const searchTerm = this.newIssuePath === pathname ? '' : format(search);
- const fallbackKey = getFallbackKey();
-
- this.autosave = new Autosave(
- this.titleField,
- [document.location.pathname, searchTerm, 'title'],
- `${fallbackKey}=title`,
+ const autosaveMap = new Map();
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'title',
+ this.form.find('input[name*="[title]"]'),
+ this.searchTerm,
+ this.fallbackKey,
);
-
- return new Autosave(
- this.descriptionField,
- [document.location.pathname, searchTerm, 'description'],
- `${fallbackKey}=description`,
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'description',
+ this.form.find('textarea[name*="[description]"]'),
+ this.searchTerm,
+ this.fallbackKey,
+ );
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'confidential',
+ this.form.find('input:checkbox[name*="[confidential]"]'),
+ this.searchTerm,
+ this.fallbackKey,
);
+ IssuableForm.addAutosave(
+ autosaveMap,
+ 'due_date',
+ this.form.find('input[name*="[due_date]"]'),
+ this.searchTerm,
+ this.fallbackKey,
+ );
+
+ return autosaveMap;
}
handleSubmit() {
@@ -131,8 +165,9 @@ export default class IssuableForm {
}
resetAutosave() {
- this.titleField.data('autosave').reset();
- return this.descriptionField.data('autosave').reset();
+ this.autosaves.forEach((autosaveItem) => {
+ autosaveItem?.reset();
+ });
}
initWip() {
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 11911adb401..0b424d105b9 100644
--- a/app/assets/javascripts/issues/list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue
@@ -24,6 +24,7 @@ import axios from '~/lib/utils/axios_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, joinPaths } from '~/lib/utils/url_utility';
+import { helpPagePath } from '~/helpers/help_page_helper';
import {
DEFAULT_NONE_ANY,
OPERATOR_IS_ONLY,
@@ -37,6 +38,7 @@ import {
TOKEN_TITLE_ORGANIZATION,
TOKEN_TITLE_RELEASE,
TOKEN_TITLE_TYPE,
+ FILTERED_SEARCH_TERM,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
@@ -462,6 +464,12 @@ export default {
page_before: this.pageParams.beforeCursor ?? undefined,
};
},
+ issuesHelpPagePath() {
+ return helpPagePath('user/project/issues/index');
+ },
+ shouldDisableSomeFilters() {
+ return this.isAnonymousSearchDisabled && !this.isSignedIn;
+ },
},
watch: {
$route(newValue, oldValue) {
@@ -578,13 +586,9 @@ export default {
this.issuesError = null;
},
handleFilter(filter) {
- if (this.isAnonymousSearchDisabled && !this.isSignedIn) {
- this.showAnonymousSearchingMessage();
- return;
- }
+ this.setFilterTokens(filter);
this.pageParams = getInitialPageParams(this.pageSize);
- this.filterTokens = filter;
this.$router.push({ query: this.urlParams });
},
@@ -674,6 +678,28 @@ export default {
Sentry.captureException(error);
});
},
+ setFilterTokens(filtersArg) {
+ const filters = this.removeDisabledSearchTerms(filtersArg);
+
+ this.filterTokens = filters;
+
+ // If we filtered something out, let's show a warning message
+ if (filters.length < filtersArg.length) {
+ this.showAnonymousSearchingMessage();
+ }
+ },
+ removeDisabledSearchTerms(filters) {
+ // If we shouldn't disable anything, let's return the same thing
+ if (!this.shouldDisableSomeFilters) {
+ return filters;
+ }
+
+ const filtersWithoutSearchTerms = filters.filter(
+ (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data),
+ );
+
+ return filtersWithoutSearchTerms;
+ },
showAnonymousSearchingMessage() {
createFlash({
message: this.$options.i18n.anonymousSearchingMessage,
@@ -720,17 +746,9 @@ export default {
sortKey = defaultSortKey;
}
- const isSearchDisabled =
- this.isAnonymousSearchDisabled &&
- !this.isSignedIn &&
- window.location.search.includes('search=');
-
- if (isSearchDisabled) {
- this.showAnonymousSearchingMessage();
- }
-
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
- this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
+ this.setFilterTokens(getFilterTokens(window.location.search));
+
this.pageParams = getInitialPageParams(
this.pageSize,
isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
@@ -899,7 +917,9 @@ export default {
<template v-else-if="isSignedIn">
<gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath">
<template #description>
- <p>{{ $options.i18n.noIssuesSignedInDescription }}</p>
+ <gl-link :href="issuesHelpPagePath" target="_blank">{{
+ $options.i18n.noIssuesSignedInDescription
+ }}</gl-link>
<p v-if="canCreateProjects">
<strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong>
</p>
diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js
index 38fe4c33792..27738d7a3e6 100644
--- a/app/assets/javascripts/issues/list/constants.js
+++ b/app/assets/javascripts/issues/list/constants.js
@@ -41,12 +41,8 @@ export const i18n = {
),
noOpenIssuesDescription: __('To keep this project going, create a new issue'),
noOpenIssuesTitle: __('There are no open issues'),
- noIssuesSignedInDescription: __(
- 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.',
- ),
- noIssuesSignedInTitle: __(
- 'The Issue Tracker is the place to add things that need to be improved or solved in a project',
- ),
+ noIssuesSignedInDescription: __('Learn more about issues.'),
+ noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'),
noIssuesSignedOutButtonText: __('Register / Sign In'),
noIssuesSignedOutDescription: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
@@ -151,6 +147,7 @@ export const TOKEN_TYPE_EPIC = 'epic_id';
export const TOKEN_TYPE_WEIGHT = 'weight';
export const TOKEN_TYPE_CONTACT = 'crm_contact';
export const TOKEN_TYPE_ORGANIZATION = 'crm_organization';
+export const TOKEN_TYPE_HEALTH = 'health_status';
export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' };
@@ -327,6 +324,16 @@ export const filters = {
},
},
},
+ [TOKEN_TYPE_HEALTH]: {
+ [API_PARAM]: {
+ [NORMAL_FILTER]: 'healthStatus',
+ },
+ [URL_PARAM]: {
+ [OPERATOR_IS]: {
+ [NORMAL_FILTER]: 'health_status',
+ },
+ },
+ },
[TOKEN_TYPE_CONTACT]: {
[API_PARAM]: {
[NORMAL_FILTER]: 'crmContactId',
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 a5cba3daafa..149049247fb 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
@@ -65,7 +65,7 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
- <div class="card card-slim gl-mt-5">
+ <div class="card card-slim gl-mt-5 gl-mb-0">
<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"
@@ -112,7 +112,7 @@ export default {
</div>
<div
v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
- class="issue-closed-by-widget second-block"
+ class="issue-closed-by-widget second-block gl-mt-3"
>
{{ closingMergeRequestsText }}
</div>
diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue
index c664135f30e..0daf77e03dc 100644
--- a/app/assets/javascripts/issues/show/components/app.vue
+++ b/app/assets/javascripts/issues/show/components/app.vue
@@ -17,11 +17,11 @@ import eventHub from '../event_hub';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
import Service from '../services/index';
import Store from '../stores';
-import descriptionComponent from './description.vue';
-import editedComponent from './edited.vue';
-import formComponent from './form.vue';
+import DescriptionComponent from './description.vue';
+import EditedComponent from './edited.vue';
+import FormComponent from './form.vue';
import PinnedLinks from './pinned_links.vue';
-import titleComponent from './title.vue';
+import TitleComponent from './title.vue';
export default {
WorkspaceType,
@@ -29,9 +29,9 @@ export default {
GlIcon,
GlBadge,
GlIntersectionObserver,
- titleComponent,
- editedComponent,
- formComponent,
+ TitleComponent,
+ EditedComponent,
+ FormComponent,
PinnedLinks,
ConfidentialityBadge,
},
@@ -51,20 +51,11 @@ export default {
required: true,
type: Boolean,
},
- canDestroy: {
- required: true,
- type: Boolean,
- },
showInlineEditButton: {
type: Boolean,
required: false,
default: true,
},
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
enableAutocomplete: {
type: Boolean,
required: false,
@@ -181,7 +172,7 @@ export default {
type: Object,
required: false,
default: () => {
- return descriptionComponent;
+ return DescriptionComponent;
},
},
showTitleBorder: {
@@ -494,14 +485,12 @@ export default {
:endpoint="endpoint"
:form-state="formState"
:initial-description-text="initialDescriptionText"
- :can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-id="projectId"
:project-namespace="projectNamespace"
- :show-delete-button="showDeleteButton"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
:issuable-type="issuableType"
diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
index 47b09bd6aa0..f86ee11e64b 100644
--- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
+++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue
@@ -13,7 +13,8 @@ export default {
props: {
issuePath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
issueType: {
type: String,
diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue
index a6747d67611..5c2a154362f 100644
--- a/app/assets/javascripts/issues/show/components/description.vue
+++ b/app/assets/javascripts/issues/show/components/description.vue
@@ -7,6 +7,7 @@ import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import createFlash from '~/flash';
import { IssuableType } from '~/issues/constants';
+import { isMetaKey } from '~/lib/utils/common_utils';
import { isPositiveInteger } from '~/lib/utils/number_utils';
import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility';
import { __, s__, sprintf } from '~/locale';
@@ -20,6 +21,8 @@ import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_ite
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_CREATING,
TRACKING_CATEGORY_SHOW,
TASK_TYPE_NAME,
WIDGET_TYPE_DESCRIPTION,
@@ -226,6 +229,7 @@ export default {
},
createDragIconElement() {
const container = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true">
<use href="${gon.sprite_icons}#drag-vertical"></use>
</svg>`;
@@ -330,6 +334,9 @@ export default {
this.addHoverListeners(taskLink, workItemId);
taskLink.classList.add('gl-link');
taskLink.addEventListener('click', (e) => {
+ if (isMetaKey(e)) {
+ return;
+ }
e.preventDefault();
this.openWorkItemDetailModal(taskLink);
this.workItemId = workItemId;
@@ -358,6 +365,7 @@ export default {
);
button.id = `js-task-button-${index}`;
this.taskButtons.push(button.id);
+ // eslint-disable-next-line no-unsanitized/property
button.innerHTML = `
<svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14">
<use href="${gon.sprite_icons}#doc-new"></use>
@@ -460,7 +468,7 @@ export default {
this.openWorkItemDetailModal(el);
} catch (error) {
createFlash({
- message: s__('WorkItem|Something went wrong when creating a work item. Please try again'),
+ message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK),
error,
captureError: true,
});
diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue
index 358b53bd131..120034b8d67 100644
--- a/app/assets/javascripts/issues/show/components/edit_actions.vue
+++ b/app/assets/javascripts/issues/show/components/edit_actions.vue
@@ -1,12 +1,10 @@
<script>
-import { GlButton, GlModalDirective } from '@gitlab/ui';
-import { uniqueId } from 'lodash';
-import { __, sprintf } from '~/locale';
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import Tracking from '~/tracking';
import eventHub from '../event_hub';
import updateMixin from '../mixins/update';
import getIssueStateQuery from '../queries/get_issue_state.query.graphql';
-import DeleteIssueModal from './delete_issue_modal.vue';
const issuableTypes = {
issue: __('Issue'),
@@ -18,18 +16,10 @@ const trackingMixin = Tracking.mixin({ label: 'delete_issue' });
export default {
components: {
- DeleteIssueModal,
GlButton,
},
- directives: {
- GlModal: GlModalDirective,
- },
mixins: [trackingMixin, updateMixin],
props: {
- canDestroy: {
- type: Boolean,
- required: true,
- },
endpoint: {
required: true,
type: String,
@@ -38,11 +28,6 @@ export default {
type: Object,
required: true,
},
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
issuableType: {
type: String,
required: true,
@@ -53,7 +38,6 @@ export default {
deleteLoading: false,
skipApollo: false,
issueState: {},
- modalId: uniqueId('delete-issuable-modal-'),
};
},
apollo: {
@@ -68,17 +52,9 @@ export default {
},
},
computed: {
- deleteIssuableButtonText() {
- return sprintf(__('Delete %{issuableType}'), {
- issuableType: this.typeToShow.toLowerCase(),
- });
- },
isSubmitEnabled() {
return this.formState.title.trim() !== '';
},
- shouldShowDeleteButton() {
- return this.canDestroy && this.showDeleteButton && this.typeToShow;
- },
typeToShow() {
const { issueState, issuableType } = this;
const type = issueState.issueType ?? issuableType;
@@ -89,52 +65,26 @@ export default {
closeForm() {
eventHub.$emit('close.form');
},
- deleteIssuable() {
- this.deleteLoading = true;
- eventHub.$emit('delete.issuable');
- },
},
};
</script>
<template>
- <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between">
- <div>
- <gl-button
- :loading="formState.updateLoading"
- :disabled="formState.updateLoading || !isSubmitEnabled"
- category="primary"
- variant="confirm"
- class="gl-mr-3"
- data-testid="issuable-save-button"
- type="submit"
- @click.prevent="updateIssuable"
- >
- {{ __('Save changes') }}
- </gl-button>
- <gl-button data-testid="issuable-cancel-button" @click="closeForm">
- {{ __('Cancel') }}
- </gl-button>
- </div>
- <div v-if="shouldShowDeleteButton">
- <gl-button
- v-gl-modal="modalId"
- :loading="deleteLoading"
- :disabled="deleteLoading"
- category="secondary"
- variant="danger"
- data-testid="issuable-delete-button"
- @click="track('click_button')"
- >
- {{ deleteIssuableButtonText }}
- </gl-button>
- <delete-issue-modal
- :issue-path="endpoint"
- :issue-type="typeToShow"
- :modal-id="modalId"
- :title="deleteIssuableButtonText"
- @delete="deleteIssuable"
- />
- </div>
+ <div class="gl-mt-3 gl-mb-3 gl-display-flex">
+ <gl-button
+ :loading="formState.updateLoading"
+ :disabled="formState.updateLoading || !isSubmitEnabled"
+ category="primary"
+ variant="confirm"
+ class="gl-mr-3"
+ data-testid="issuable-save-button"
+ type="submit"
+ @click.prevent="updateIssuable"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ <gl-button data-testid="issuable-cancel-button" @click="closeForm">
+ {{ __('Cancel') }}
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue
index 41cc3964055..4c5f783cd66 100644
--- a/app/assets/javascripts/issues/show/components/edited.vue
+++ b/app/assets/javascripts/issues/show/components/edited.vue
@@ -1,10 +1,10 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
- timeAgoTooltip,
+ TimeAgoTooltip,
},
props: {
updatedAt: {
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index f45af47374a..c2ab7c4f298 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -1,11 +1,11 @@
<script>
-import markdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import updateMixin from '../../mixins/update';
export default {
components: {
- markdownField,
+ MarkdownField,
},
mixins: [updateMixin],
props: {
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index e2c12edf46d..f479c8ae78d 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -22,10 +22,6 @@ export default {
LockedWarning,
},
props: {
- canDestroy: {
- type: Boolean,
- required: true,
- },
endpoint: {
type: String,
required: true,
@@ -63,11 +59,6 @@ export default {
type: String,
required: true,
},
- showDeleteButton: {
- type: Boolean,
- required: false,
- default: true,
- },
canAttachFile: {
type: Boolean,
required: false,
@@ -231,12 +222,6 @@ export default {
:enable-autocomplete="enableAutocomplete"
/>
- <edit-actions
- :endpoint="endpoint"
- :form-state="formState"
- :can-destroy="canDestroy"
- :show-delete-button="showDeleteButton"
- :issuable-type="issuableType"
- />
+ <edit-actions :endpoint="endpoint" :form-state="formState" :issuable-type="issuableType" />
</form>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js
index 77d13fe085a..aa7b9805b5f 100644
--- a/app/assets/javascripts/issues/show/components/incidents/constants.js
+++ b/app/assets/javascripts/issues/show/components/incidents/constants.js
@@ -26,4 +26,15 @@ export const timelineListI18n = Object.freeze({
'Incident|Something went wrong while deleting the incident timeline event.',
),
deleteModal: s__('Incident|Are you sure you want to delete this event?'),
+ editError: s__('Incident|Error updating incident timeline event: %{error}'),
+ editErrorGeneric: s__(
+ 'Incident|Something went wrong while updating the incident timeline event.',
+ ),
+});
+
+export const timelineItemI18n = Object.freeze({
+ delete: __('Delete'),
+ edit: __('Edit'),
+ moreActions: __('More actions'),
+ timeUTC: __('%{time} UTC'),
});
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
index c902895702e..6bb72e82778 100644
--- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue
@@ -1,6 +1,7 @@
<script>
import { produce } from 'immer';
import { sortBy } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
import { sprintf } from '~/locale';
import { createAlert } from '~/flash';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -16,6 +17,7 @@ export default {
i18n: timelineFormI18n,
components: {
TimelineEventsForm,
+ GlIcon,
},
inject: ['fullPath', 'issuableId'],
props: {
@@ -31,9 +33,6 @@ export default {
clearForm() {
this.$refs.eventForm.clear();
},
- focusDate() {
- this.$refs.eventForm.focusDate();
- },
updateCache(store, { data }) {
const { timelineEvent: event, errors } = data?.timelineEventCreate || {};
@@ -107,11 +106,23 @@ export default {
</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')"
- />
+ <div
+ class="create-timeline-event gl-relative gl-display-flex gl-align-items-start"
+ :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
+ >
+ <div
+ v-if="hasTimelineEvents"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ >
+ <gl-icon name="comment" class="note-icon" />
+ </div>
+ <timeline-events-form
+ ref="eventForm"
+ :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }"
+ :is-event-processed="createTimelineEventActive"
+ show-save-and-add
+ @save-event="createIncidentTimelineEvent"
+ @cancel="$emit('hide-new-timeline-events-form')"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
new file mode 100644
index 00000000000..60fa8cb949b
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import TimelineEventsForm from './timeline_events_form.vue';
+
+export default {
+ name: 'EditTimelineEvent',
+ components: {
+ TimelineEventsForm,
+ GlIcon,
+ },
+ props: {
+ event: {
+ type: Object,
+ required: true,
+ validator: (item) => ['occurredAt', 'note'].every((key) => item[key]),
+ },
+ editTimelineEventActive: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ saveEvent(eventDetails) {
+ this.$emit('handle-save-edit', { ...eventDetails, id: this.event.id }, false);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-relative gl-display-flex gl-align-items-center">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
+ >
+ <gl-icon name="comment" class="note-icon" />
+ </div>
+ <timeline-events-form
+ ref="eventForm"
+ class="timeline-event-border"
+ :is-event-processed="editTimelineEventActive"
+ :previous-occurred-at="event.occurredAt"
+ :previous-note="event.note"
+ @save-event="saveEvent"
+ @cancel="$emit('hide-edit')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..54f036268cc
--- /dev/null
+++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql
@@ -0,0 +1,13 @@
+mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) {
+ timelineEventUpdate(input: $input) {
+ timelineEvent {
+ id
+ note
+ noteHtml
+ action
+ occurredAt
+ createdAt
+ }
+ errors
+ }
+}
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 0d84fabb1be..b7ae18372ab 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,9 +1,9 @@
<script>
-import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui';
+import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
import { timelineFormI18n } from './constants';
-import { getUtcShiftedDateNow } from './utils';
+import { getUtcShiftedDate } from './utils';
export default {
name: 'TimelineEventsForm',
@@ -15,6 +15,7 @@ export default {
'task-list',
'collapsible-section',
'table',
+ 'attach-file',
'full-screen',
],
components: {
@@ -23,175 +24,168 @@ export default {
GlFormInput,
GlFormGroup,
GlButton,
- GlIcon,
},
i18n: timelineFormI18n,
directives: {
autofocusonshow,
},
props: {
- hasTimelineEvents: {
+ showSaveAndAdd: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
isEventProcessed: {
type: Boolean,
required: true,
},
+ previousOccurredAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ previousNote: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
- // if occurredAt is undefined, returns "now" in UTC
- const placeholderDate = getUtcShiftedDateNow();
+ // if occurredAt is null, returns "now" in UTC
+ const placeholderDate = getUtcShiftedDate(this.previousOccurredAt);
return {
- timelineText: '',
+ timelineText: this.previousNote,
placeholderDate,
hourPickerInput: placeholderDate.getHours(),
minutePickerInput: placeholderDate.getMinutes(),
- datepickerTextInput: null,
+ datePickerInput: placeholderDate,
};
},
computed: {
- occurredAt() {
- const [years, months, days] = this.datepickerTextInput.split('-');
+ occurredAtString() {
+ const year = this.datePickerInput.getFullYear();
+ const month = this.datePickerInput.getMonth();
+ const day = this.datePickerInput.getDate();
+
const utcDate = new Date(
- Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput),
+ Date.UTC(year, month, day, this.hourPickerInput, this.minutePickerInput),
);
return utcDate.toISOString();
},
},
+ mounted() {
+ this.focusDate();
+ },
methods: {
clear() {
- const utcShiftedDateNow = getUtcShiftedDateNow();
- this.placeholderDate = utcShiftedDateNow;
- this.hourPickerInput = utcShiftedDateNow.getHours();
- this.minutePickerInput = utcShiftedDateNow.getMinutes();
+ const newPlaceholderDate = getUtcShiftedDate();
+ this.datePickerInput = newPlaceholderDate;
+ this.hourPickerInput = newPlaceholderDate.getHours();
+ this.minutePickerInput = newPlaceholderDate.getMinutes();
this.timelineText = '';
},
focusDate() {
- this.$refs.datepicker.$el.focus();
+ this.$refs.datepicker.$el.querySelector('input').focus();
},
handleSave(addAnotherEvent) {
- const eventDetails = {
+ const event = {
note: this.timelineText,
- occurredAt: this.occurredAt,
+ occurredAt: this.occurredAtString,
};
- this.$emit('save-event', eventDetails, addAnotherEvent);
+ this.$emit('save-event', event, addAnotherEvent);
},
},
};
</script>
<template>
- <div
- class="gl-relative gl-display-flex gl-align-items-center"
- :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }"
- >
- <div
- v-if="hasTimelineEvents"
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1"
- >
- <gl-icon name="comment" class="note-icon" />
- </div>
- <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }">
- <div
- 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="placeholderDate">
+ <form class="gl-flex-grow-1 gl-border-gray-50">
+ <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row">
+ <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5">
+ <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" />
+ </gl-form-group>
+ <div class="gl-display-flex gl-mt-5">
+ <gl-form-group :label="__('Time')">
+ <div class="gl-display-flex">
+ <label label-for="timeline-input-hours" class="sr-only"></label>
<gl-form-input
- id="incident-date"
- ref="datepicker"
- v-model="datepickerTextInput"
- data-testid="input-datepicker"
- class="gl-datepicker-input gl-pr-7!"
- :value="formattedDate"
- :placeholder="__('YYYY-MM-DD')"
- @keydown.enter="onKeydown"
+ id="timeline-input-hours"
+ v-model="hourPickerInput"
+ data-testid="input-hours"
+ size="xs"
+ type="number"
+ min="00"
+ max="23"
/>
- </gl-datepicker>
- </gl-form-group>
- <div class="gl-display-flex gl-mt-5">
- <gl-form-group :label="__('Time')">
- <div class="gl-display-flex">
- <label label-for="timeline-input-hours" class="sr-only"></label>
- <gl-form-input
- id="timeline-input-hours"
- v-model="hourPickerInput"
- data-testid="input-hours"
- size="xs"
- type="number"
- min="00"
- max="23"
- />
- <label label-for="timeline-input-minutes" class="sr-only"></label>
- <gl-form-input
- id="timeline-input-minutes"
- v-model="minutePickerInput"
- class="gl-ml-3"
- data-testid="input-minutes"
- size="xs"
- type="number"
- min="00"
- max="59"
- />
- </div>
- </gl-form-group>
- <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
- </div>
- </div>
- <div class="common-note-form">
- <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
- <markdown-field
- :can-attach-file="false"
- :add-spacing-classes="false"
- :show-comment-tool-bar="false"
- :textarea-value="timelineText"
- :restricted-tool-bar-items="$options.restrictedToolBarItems"
- markdown-docs-path=""
- :enable-preview="false"
- class="bordered-box gl-mt-0"
- >
- <template #textarea>
- <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="$options.i18n.description"
- :placeholder="$options.i18n.areaPlaceholder"
- >
- </textarea>
- </template>
- </markdown-field>
+ <label label-for="timeline-input-minutes" class="sr-only"></label>
+ <gl-form-input
+ id="timeline-input-minutes"
+ v-model="minutePickerInput"
+ class="gl-ml-3"
+ data-testid="input-minutes"
+ size="xs"
+ type="number"
+ min="00"
+ max="59"
+ />
+ </div>
</gl-form-group>
+ <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p>
</div>
- <gl-form-group class="gl-mb-0">
- <gl-button
- variant="confirm"
- category="primary"
- class="gl-mr-3"
- :loading="isEventProcessed"
- @click="handleSave(false)"
- >
- {{ $options.i18n.save }}
- </gl-button>
- <gl-button
- variant="confirm"
- category="secondary"
- class="gl-mr-3 gl-ml-n2"
- :loading="isEventProcessed"
- @click="handleSave(true)"
+ </div>
+ <div class="common-note-form">
+ <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel">
+ <markdown-field
+ :can-attach-file="false"
+ :add-spacing-classes="false"
+ :show-comment-tool-bar="false"
+ :textarea-value="timelineText"
+ :restricted-tool-bar-items="$options.restrictedToolBarItems"
+ markdown-docs-path=""
+ :enable-preview="false"
+ class="bordered-box gl-mt-0"
>
- {{ $options.i18n.saveAndAdd }}
- </gl-button>
- <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>
+ <template #textarea>
+ <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="$options.i18n.description"
+ :placeholder="$options.i18n.areaPlaceholder"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
</gl-form-group>
- </form>
- </div>
+ </div>
+ <gl-form-group class="gl-mb-0">
+ <gl-button
+ variant="confirm"
+ category="primary"
+ class="gl-mr-3"
+ :loading="isEventProcessed"
+ @click="handleSave(false)"
+ >
+ {{ $options.i18n.save }}
+ </gl-button>
+ <gl-button
+ v-if="showSaveAndAdd"
+ variant="confirm"
+ category="secondary"
+ class="gl-mr-3 gl-ml-n2"
+ :loading="isEventProcessed"
+ @click="handleSave(true)"
+ >
+ {{ $options.i18n.saveAndAdd }}
+ </gl-button>
+ <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ <div class="timeline-event-bottom-border"></div>
+ </gl-form-group>
+ </form>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
index 6175c9969ec..cbf3c387fa3 100644
--- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
+++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue
@@ -1,25 +1,13 @@
<script>
-import {
- GlButton,
- GlDropdown,
- GlDropdownItem,
- GlIcon,
- GlSafeHtmlDirective,
- GlSprintf,
-} from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui';
import { formatDate } from '~/lib/utils/datetime_utility';
-import { __ } from '~/locale';
+import { timelineItemI18n } from './constants';
import { getEventIcon } from './utils';
export default {
name: 'IncidentTimelineEventListItem',
- i18n: {
- delete: __('Delete'),
- moreActions: __('More actions'),
- timeUTC: __('%{time} UTC'),
- },
+ i18n: timelineItemI18n,
components: {
- GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
@@ -28,12 +16,8 @@ export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
- inject: ['canUpdate'],
+ inject: ['canUpdateTimelineEvent'],
props: {
- isLastItem: {
- type: Boolean,
- required: true,
- },
occurredAt: {
type: String,
required: true,
@@ -58,43 +42,41 @@ export default {
};
</script>
<template>
- <li
- class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
- >
- <div class="gl-display-flex gl-align-items-center">
- <div
- class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
- >
- <gl-icon :name="getEventIcon(action)" class="note-icon" />
+ <div class="gl-display-flex gl-align-items-start">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1"
+ >
+ <gl-icon :name="getEventIcon(action)" class="note-icon" />
+ </div>
+ <div
+ class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row"
+ data-testid="event-text-container"
+ >
+ <div>
+ <strong class="gl-font-lg" data-testid="event-time">
+ <gl-sprintf :message="$options.i18n.timeUTC">
+ <template #time>{{ time }}</template>
+ </gl-sprintf>
+ </strong>
+ <div v-safe-html="noteHtml"></div>
</div>
- <div
- class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row"
- :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }"
- data-testid="event-text-container"
+ <gl-dropdown
+ v-if="canUpdateTimelineEvent"
+ right
+ class="event-note-actions gl-ml-auto gl-align-self-start"
+ icon="ellipsis_v"
+ text-sr-only
+ :text="$options.i18n.moreActions"
+ category="tertiary"
+ no-caret
>
- <div>
- <strong class="gl-font-lg" data-testid="event-time">
- <gl-sprintf :message="$options.i18n.timeUTC">
- <template #time>{{ time }}</template>
- </gl-sprintf>
- </strong>
- <div v-safe-html="noteHtml"></div>
- </div>
- <gl-dropdown
- v-if="canUpdate"
- right
- class="event-note-actions gl-ml-auto gl-align-self-center"
- icon="ellipsis_v"
- text-sr-only
- :text="$options.i18n.moreActions"
- category="tertiary"
- no-caret
- >
- <gl-dropdown-item @click="$emit('delete')">
- <gl-button>{{ $options.i18n.delete }}</gl-button>
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
+ <gl-dropdown-item @click="$emit('edit')">
+ {{ $options.i18n.edit }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="$emit('delete')">
+ {{ $options.i18n.delete }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</div>
- </li>
+ </div>
</template>
diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue
index 80ac1c372cd..321b7ccc14a 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
@@ -5,7 +5,9 @@ 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 IncidentTimelineEventItem from './timeline_events_item.vue';
+import EditTimelineEvent from './edit_timeline_event.vue';
import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql';
+import editTimelineEvent from './graphql/queries/edit_timeline_event.mutation.graphql';
import { timelineListI18n } from './constants';
export default {
@@ -13,6 +15,7 @@ export default {
i18n: timelineListI18n,
components: {
IncidentTimelineEventItem,
+ EditTimelineEvent,
},
props: {
timelineEventLoading: {
@@ -26,6 +29,9 @@ export default {
default: () => [],
},
},
+ data() {
+ return { eventToEdit: null, editTimelineEventActive: false };
+ },
computed: {
dateGroupedEvents() {
const groupedEvents = new Map();
@@ -44,11 +50,12 @@ export default {
},
},
methods: {
- isLastItem(groups, groupIndex, events, eventIndex) {
- if (groupIndex < groups.size - 1) {
- return false;
- }
- return eventIndex === events.length - 1;
+ handleEditSelection(event) {
+ this.eventToEdit = event.id;
+ this.$emit('hide-new-incident-timeline-event-form');
+ },
+ hideEdit() {
+ this.eventToEdit = null;
},
handleDelete: ignoreWhilePending(async function handleDelete(event) {
const msg = this.$options.i18n.deleteModal;
@@ -85,6 +92,38 @@ export default {
createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error });
}
}),
+ handleSaveEdit(eventDetails) {
+ this.editTimelineEventActive = true;
+ return this.$apollo
+ .mutate({
+ mutation: editTimelineEvent,
+ variables: {
+ input: {
+ id: eventDetails.id,
+ note: eventDetails.note,
+ occurredAt: eventDetails.occurredAt,
+ },
+ },
+ })
+ .then(({ data }) => {
+ this.editTimelineEventActive = false;
+ const errors = data.timelineEventUpdate?.errors;
+ if (errors.length) {
+ createAlert({
+ message: sprintf(this.$options.i18n.editError, { error: errors.join('. ') }, false),
+ });
+ } else {
+ this.hideEdit();
+ }
+ })
+ .catch((error) => {
+ createAlert({
+ message: this.$options.i18n.editErrorGeneric,
+ captureError: true,
+ error,
+ });
+ });
+ },
},
};
</script>
@@ -92,9 +131,10 @@ export default {
<template>
<div class="issuable-discussion incident-timeline-events">
<div
- v-for="([eventDate, events], groupIndex) in dateGroupedEvents"
+ v-for="[eventDate, events] in dateGroupedEvents"
:key="eventDate"
data-testid="timeline-group"
+ class="timeline-group"
>
<div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid">
<strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong>
@@ -103,15 +143,25 @@ export default {
<li
v-for="(event, eventIndex) in events"
:key="eventIndex"
- class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!"
+ class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!"
>
+ <edit-timeline-event
+ v-if="eventToEdit === event.id"
+ :key="`edit-${event.id}`"
+ ref="eventForm"
+ :event="event"
+ :edit-timeline-event-active="editTimelineEventActive"
+ @handle-save-edit="handleSaveEdit"
+ @hide-edit="hideEdit()"
+ />
<incident-timeline-event-item
+ v-else
: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)"
+ @edit="handleEditSelection(event)"
/>
</li>
</ul>
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 7c2a7878c58..5f70d9acac9 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
@@ -3,10 +3,10 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ISSUE } from '~/graphql_shared/constants';
import { fetchPolicies } from '~/lib/graphql';
+import notesEventHub from '~/notes/event_hub';
import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql';
import { displayAndLogError } from './utils';
import { timelineTabI18n } from './constants';
-
import CreateTimelineEvent from './create_timeline_event.vue';
import IncidentTimelineEventsList from './timeline_events_list.vue';
@@ -20,7 +20,7 @@ export default {
IncidentTimelineEventsList,
},
i18n: timelineTabI18n,
- inject: ['canUpdate', 'fullPath', 'issuableId'],
+ inject: ['canUpdateTimelineEvent', 'fullPath', 'issuableId'],
data() {
return {
isEventFormVisible: false,
@@ -56,15 +56,21 @@ export default {
return !this.timelineEventLoading && !this.hasTimelineEvents;
},
},
+ mounted() {
+ notesEventHub.$on('comment-promoted-to-timeline-event', this.refreshTimelineEvents);
+ },
+ destroyed() {
+ notesEventHub.$off('comment-promoted-to-timeline-event', this.refreshTimelineEvents);
+ },
methods: {
+ refreshTimelineEvents() {
+ this.$apollo.queries.timelineEvents.refetch();
+ },
hideEventForm() {
this.isEventFormVisible = false;
},
- async showEventForm() {
- this.$refs.createEventForm.clearForm();
+ showEventForm() {
this.isEventFormVisible = true;
- await this.$nextTick();
- this.$refs.createEventForm.focusDate();
},
},
};
@@ -85,14 +91,19 @@ export default {
@hide-new-timeline-events-form="hideEventForm"
/>
<create-timeline-event
- v-show="isEventFormVisible"
+ v-if="isEventFormVisible"
ref="createEventForm"
:has-timeline-events="hasTimelineEvents"
class="timeline-event-note timeline-event-note-form"
:class="{ 'gl-pl-0': !hasTimelineEvents }"
@hide-new-timeline-events-form="hideEventForm"
/>
- <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm">
+ <gl-button
+ v-if="canUpdateTimelineEvent"
+ variant="default"
+ class="gl-mb-3 gl-mt-7"
+ @click="showEventForm"
+ >
{{ $options.i18n.addEventButton }}
</gl-button>
</gl-tab>
diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js
index cf790a11b67..5a009debd75 100644
--- a/app/assets/javascripts/issues/show/components/incidents/utils.js
+++ b/app/assets/javascripts/issues/show/components/incidents/utils.js
@@ -21,13 +21,14 @@ export const getEventIcon = (actionName) => {
};
/**
- * Returns a date shifted by the current timezone offset. Allows
- * date.getHours() and similar to return UTC values.
- *
+ * Returns a date shifted by the current timezone offset set to now
+ * by default but can accept an existing date as an ISO date string
+ * @param {string} ISOString
* @returns {Date}
*/
-export const getUtcShiftedDateNow = () => {
- const date = new Date();
+export const getUtcShiftedDate = (ISOString = null) => {
+ const date = ISOString ? new Date(ISOString) : new Date();
date.setMinutes(date.getMinutes() + date.getTimezoneOffset());
+
return date;
};
diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js
index 5b8630f7d63..deee034f9d1 100644
--- a/app/assets/javascripts/issues/show/graphql.js
+++ b/app/assets/javascripts/issues/show/graphql.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { defaultClient } from '~/sidebar/graphql';
+import { defaultClient } from '~/graphql_shared/issuable_client';
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js
index 459a3804837..e5eed9f6b79 100644
--- a/app/assets/javascripts/issues/show/index.js
+++ b/app/assets/javascripts/issues/show/index.js
@@ -32,6 +32,7 @@ export function initIncidentApp(issueData = {}) {
const {
canCreateIncident,
canUpdate,
+ canUpdateTimelineEvent,
iid,
issuableId,
projectNamespace,
@@ -51,6 +52,7 @@ export function initIncidentApp(issueData = {}) {
provide: {
issueType: INCIDENT_TYPE,
canCreateIncident,
+ canUpdateTimelineEvent,
canUpdate,
fullPath,
iid,
diff --git a/app/assets/javascripts/issues/show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js
index c5811290e61..aeb547b9194 100644
--- a/app/assets/javascripts/issues/show/utils/update_description.js
+++ b/app/assets/javascripts/issues/show/utils/update_description.js
@@ -13,6 +13,7 @@ const updateDescription = (descriptionHtml = '', details) => {
}
const placeholder = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
placeholder.innerHTML = descriptionHtml;
const newDetails = placeholder.getElementsByTagName('details');
diff --git a/app/assets/javascripts/jira_connect/subscriptions/api.js b/app/assets/javascripts/jira_connect/subscriptions/api.js
index de67703356f..c79d7002111 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/api.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/api.js
@@ -1,10 +1,25 @@
import axios from 'axios';
+import { buildApiUrl } from '~/api/api_utils';
+
+import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
import { getJwt } from './utils';
+const CURRENT_USER_PATH = '/api/:version/user';
+const JIRA_CONNECT_SUBSCRIPTIONS_PATH = '/api/:version/integrations/jira_connect/subscriptions';
+const JIRA_CONNECT_INSTALLATIONS_PATH = '/-/jira_connect/installations';
+const JIRA_CONNECT_OAUTH_APPLICATION_ID_PATH = '/-/jira_connect/oauth_application_id';
+
+// This export is only used for testing purposes
+export const axiosInstance = axios.create();
+
+export const setApiBaseURL = (baseURL = null) => {
+ axiosInstance.defaults.baseURL = baseURL;
+};
+
export const addSubscription = async (addPath, namespace) => {
const jwt = await getJwt();
- return axios.post(addPath, {
+ return axiosInstance.post(addPath, {
jwt,
namespace_path: namespace,
});
@@ -13,7 +28,7 @@ export const addSubscription = async (addPath, namespace) => {
export const removeSubscription = async (removePath) => {
const jwt = await getJwt();
- return axios.delete(removePath, {
+ return axiosInstance.delete(removePath, {
params: {
jwt,
},
@@ -21,7 +36,7 @@ export const removeSubscription = async (removePath) => {
};
export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
- return axios.get(groupsPath, {
+ return axiosInstance.get(groupsPath, {
params: {
page,
per_page: perPage,
@@ -33,9 +48,50 @@ export const fetchGroups = async (groupsPath, { page, perPage, search }) => {
export const fetchSubscriptions = async (subscriptionsPath) => {
const jwt = await getJwt();
- return axios.get(subscriptionsPath, {
+ return axiosInstance.get(subscriptionsPath, {
params: {
jwt,
},
});
};
+
+export const getCurrentUser = (options) => {
+ const url = buildApiUrl(CURRENT_USER_PATH);
+ return axiosInstance.get(url, { ...options });
+};
+
+export const addJiraConnectSubscription = (namespacePath, { jwt, accessToken }) => {
+ const url = buildApiUrl(JIRA_CONNECT_SUBSCRIPTIONS_PATH);
+
+ return axiosInstance.post(
+ url,
+ {
+ jwt,
+ namespace_path: namespacePath,
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ },
+ },
+ );
+};
+
+export const updateInstallation = async (instanceUrl) => {
+ const jwt = await getJwt();
+
+ return axiosInstance.put(JIRA_CONNECT_INSTALLATIONS_PATH, {
+ jwt,
+ installation: {
+ instance_url: instanceUrl === GITLAB_COM_BASE_PATH ? null : instanceUrl,
+ },
+ });
+};
+
+export const fetchOAuthApplicationId = () => {
+ return axiosInstance.get(JIRA_CONNECT_OAUTH_APPLICATION_ID_PATH);
+};
+
+export const fetchOAuthToken = (oauthTokenPath, data = {}) => {
+ return axiosInstance.post(oauthTokenPath, data);
+};
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
index 66aea60c5b5..22a6c0751f4 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue
@@ -83,7 +83,7 @@ export default {
* if the jiraConnectOauth flag is enabled.
*/
fetchSubscriptionsOauth() {
- if (!this.isOauthEnabled) return;
+ if (!this.isOauthEnabled || !this.userSignedIn) return;
this.fetchSubscriptions(this.subscriptionsPath);
},
@@ -146,12 +146,12 @@ export default {
<div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7">
<sign-in-page
- v-if="!userSignedIn"
+ v-show="!userSignedIn"
:has-subscriptions="hasSubscriptions"
@sign-in-oauth="onSignInOauth"
@error="onSignInError"
/>
- <subscriptions-page v-else :has-subscriptions="hasSubscriptions" />
+ <subscriptions-page v-if="userSignedIn" :has-subscriptions="hasSubscriptions" />
</div>
</div>
</main>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
index c5b56535247..9b50681515e 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/compatibility_alert.vue
@@ -3,6 +3,7 @@ import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const COMPATIBILITY_ALERT_STATE_KEY = 'compatibility_alert_dismissed';
@@ -14,6 +15,7 @@ export default {
GlLink,
LocalStorageSync,
},
+ mixins: [glFeatureFlagMixin()],
data() {
return {
alertDismissed: false,
@@ -23,6 +25,14 @@ export default {
shouldShowAlert() {
return !this.alertDismissed;
},
+ isOauthSelfManagedEnabled() {
+ return this.glFeatures.jiraConnectOauth && this.glFeatures.jiraConnectOauthSelfManaged;
+ },
+ alertBody() {
+ return this.isOauthSelfManagedEnabled
+ ? this.$options.i18n.body
+ : this.$options.i18n.bodyDotCom;
+ },
},
methods: {
dismissAlert() {
@@ -32,6 +42,9 @@ export default {
i18n: {
title: s__('Integrations|Known limitations'),
body: s__(
+ 'Integrations|Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
+ ),
+ bodyDotCom: s__(
'Integrations|This integration only works with GitLab.com. Adding a namespace only works in browsers that allow cross-site cookies. %{linkStart}Learn more%{linkEnd}.',
),
},
@@ -50,7 +63,7 @@ export default {
:title="$options.i18n.title"
@dismiss="dismissAlert"
>
- <gl-sprintf :message="$options.i18n.body">
+ <gl-sprintf :message="alertBody">
<template #link="{ content }">
<gl-link :href="$options.DOCS_LINK_URL" target="_blank">{{ content }}</gl-link>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
index ad3e70bcb5f..4cf3a1a0279 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue
@@ -1,30 +1,53 @@
<script>
import { mapActions, mapMutations } from 'vuex';
import { GlButton } from '@gitlab/ui';
-import axios from '~/lib/utils/axios_utils';
+import { sprintf } from '~/locale';
+
import {
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
+ I18N_CUSTOM_SIGN_IN_BUTTON_TEXT,
+ I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE,
+ I18N_OAUTH_FAILED_TITLE,
+ I18N_OAUTH_FAILED_MESSAGE,
+ OAUTH_SELF_MANAGED_DOC_LINK,
OAUTH_WINDOW_OPTIONS,
PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM,
} from '~/jira_connect/subscriptions/constants';
+import { fetchOAuthApplicationId, fetchOAuthToken } from '~/jira_connect/subscriptions/api';
import { setUrlParams } from '~/lib/utils/url_utility';
import AccessorUtilities from '~/lib/utils/accessor';
import { createCodeVerifier, createCodeChallenge } from '../pkce';
-import { SET_ACCESS_TOKEN } from '../store/mutation_types';
+import { SET_ACCESS_TOKEN, SET_ALERT } from '../store/mutation_types';
export default {
components: {
GlButton,
},
inject: ['oauthMetadata'],
+ props: {
+ gitlabBasePath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
data() {
return {
- token: null,
loading: false,
codeVerifier: null,
+ clientId: null,
canUseCrypto: AccessorUtilities.canUseCrypto(),
};
},
+ computed: {
+ buttonText() {
+ if (!this.gitlabBasePath) {
+ return I18N_DEFAULT_SIGN_IN_BUTTON_TEXT;
+ }
+
+ return sprintf(I18N_CUSTOM_SIGN_IN_BUTTON_TEXT, { url: this.gitlabBasePath });
+ },
+ },
created() {
window.addEventListener('message', this.handleWindowMessage);
},
@@ -35,30 +58,72 @@ export default {
...mapActions(['loadCurrentUser']),
...mapMutations({
setAccessToken: SET_ACCESS_TOKEN,
+ setAlert: SET_ALERT,
}),
- async startOAuthFlow() {
- this.loading = true;
-
+ async fetchOauthClientId() {
+ const {
+ data: { application_id: clientId },
+ } = await fetchOAuthApplicationId();
+ return clientId;
+ },
+ async getOauthAuthorizeURL() {
// Generate state necessary for PKCE OAuth flow
this.codeVerifier = createCodeVerifier();
const codeChallenge = await createCodeChallenge(this.codeVerifier);
+ try {
+ this.clientId = this.gitlabBasePath
+ ? await this.fetchOauthClientId()
+ : this.oauthMetadata?.oauth_token_payload?.client_id;
+ } catch {
+ throw new Error(I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE);
+ }
// Build the initial OAuth authorization URL
const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata;
-
- const oauthAuthorizeURLWithChallenge = setUrlParams(
- {
- code_challenge: codeChallenge,
- code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short,
- },
- oauthAuthorizeURL,
+ const oauthAuthorizeURLWithChallenge = new URL(
+ setUrlParams(
+ {
+ code_challenge: codeChallenge,
+ code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short,
+ client_id: this.clientId,
+ },
+ oauthAuthorizeURL,
+ ),
);
- window.open(
- oauthAuthorizeURLWithChallenge,
- this.$options.i18n.defaultButtonText,
- OAUTH_WINDOW_OPTIONS,
- );
+ // Rebase URL on the specified GitLab base path (if specified).
+ if (this.gitlabBasePath) {
+ const gitlabBasePathURL = new URL(this.gitlabBasePath);
+ oauthAuthorizeURLWithChallenge.hostname = gitlabBasePathURL.hostname;
+ oauthAuthorizeURLWithChallenge.pathname = `${
+ gitlabBasePathURL.pathname === '/' ? '' : gitlabBasePathURL.pathname
+ }${oauthAuthorizeURLWithChallenge.pathname}`;
+ }
+
+ return oauthAuthorizeURLWithChallenge.toString();
+ },
+ async startOAuthFlow() {
+ try {
+ this.loading = true;
+ const oauthAuthorizeURL = await this.getOauthAuthorizeURL();
+
+ window.open(oauthAuthorizeURL, I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, OAUTH_WINDOW_OPTIONS);
+ } catch (e) {
+ if (e.message) {
+ this.setAlert({
+ message: e.message,
+ variant: 'danger',
+ });
+ } else {
+ this.setAlert({
+ linkUrl: OAUTH_SELF_MANAGED_DOC_LINK,
+ title: I18N_OAUTH_FAILED_TITLE,
+ message: this.gitlabBasePath ? I18N_OAUTH_FAILED_MESSAGE : '',
+ variant: 'danger',
+ });
+ }
+ this.loading = false;
+ }
},
async handleWindowMessage(event) {
if (window.origin !== event.origin) {
@@ -94,20 +159,18 @@ export default {
async getOAuthToken(code) {
const {
oauth_token_payload: oauthTokenPayload,
- oauth_token_url: oauthTokenURL,
+ oauth_token_path: oauthTokenPath,
} = this.oauthMetadata;
- const { data } = await axios.post(oauthTokenURL, {
+ const { data } = await fetchOAuthToken(oauthTokenPath, {
...oauthTokenPayload,
code,
code_verifier: this.codeVerifier,
+ client_id: this.clientId,
});
return data.access_token;
},
},
- i18n: {
- defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
- },
};
</script>
<template>
@@ -119,7 +182,7 @@ export default {
@click="startOAuthFlow"
>
<slot>
- {{ $options.i18n.defaultButtonText }}
+ {{ buttonText }}
</slot>
</gl-button>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js
index 8faafb1b0d0..fc365746b54 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/constants.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js
@@ -3,11 +3,13 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export const DEFAULT_GROUPS_PER_PAGE = 10;
export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert';
+export const BASE_URL_LOCALSTORAGE_KEY = 'gitlab_base_url';
export const MINIMUM_SEARCH_TERM_LENGTH = 3;
export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal';
export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab');
+export const I18N_CUSTOM_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to %{url}');
export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.');
export const I18N_DEFAULT_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
'Integrations|Failed to load subscriptions.',
@@ -18,13 +20,28 @@ export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE = s__(
export const I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE = s__(
'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
);
-export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira_development_panel', {
- anchor: 'use-the-integration',
-});
-
export const I18N_ADD_SUBSCRIPTIONS_ERROR_MESSAGE = s__(
'Integrations|Failed to link namespace. Please try again.',
);
+export const I18N_UPDATE_INSTALLATION_ERROR_MESSAGE = s__(
+ 'Integrations|Failed to update GitLab version. Please try again.',
+);
+export const I18N_OAUTH_APPLICATION_ID_ERROR_MESSAGE = s__(
+ 'Integrations|Failed to load Jira Connect Application ID. Please try again.',
+);
+export const I18N_OAUTH_FAILED_TITLE = s__('Integrations|Failed to sign in to GitLab.');
+export const I18N_OAUTH_FAILED_MESSAGE = s__(
+ 'Integrations|Ensure your instance URL is correct and your instance is configured correctly. %{linkStart}Learn more%{linkEnd}.',
+);
+
+export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', {
+ anchor: 'use-the-integration',
+});
+export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', {
+ anchor: 'install-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances',
+});
+
+export const GITLAB_COM_BASE_PATH = 'https://gitlab.com';
const OAUTH_WINDOW_SIZE = 800;
export const OAUTH_WINDOW_OPTIONS = [
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
index 4f5aa4c255c..5ff75e19425 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue
@@ -1,6 +1,13 @@
<script>
+import { mapMutations } from 'vuex';
import { GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
+
+import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils';
+import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api';
+import { I18N_UPDATE_INSTALLATION_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
+import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
+
import SignInOauthButton from '../../../components/sign_in_oauth_button.vue';
import VersionSelectForm from './version_select_form.vue';
@@ -14,6 +21,7 @@ export default {
data() {
return {
gitlabBasePath: null,
+ loadingVersionSelect: false,
};
},
computed: {
@@ -26,12 +34,32 @@ export default {
: this.$options.i18n.versionSelectSubtitle;
},
},
+ mounted() {
+ this.gitlabBasePath = retrieveBaseUrl();
+ setApiBaseURL(this.gitlabBasePath);
+ },
methods: {
+ ...mapMutations({
+ setAlert: SET_ALERT,
+ }),
resetGitlabBasePath() {
this.gitlabBasePath = null;
+ setApiBaseURL();
},
onVersionSelect(gitlabBasePath) {
- this.gitlabBasePath = gitlabBasePath;
+ this.loadingVersionSelect = true;
+ updateInstallation(gitlabBasePath)
+ .then(() => {
+ persistBaseUrl(gitlabBasePath);
+ reloadPage();
+ })
+ .catch(() => {
+ this.setAlert({
+ message: I18N_UPDATE_INSTALLATION_ERROR_MESSAGE,
+ variant: 'danger',
+ });
+ this.loadingVersionSelect = false;
+ });
},
onSignInError() {
this.$emit('error');
@@ -53,11 +81,17 @@ export default {
<p data-testid="subtitle">{{ subtitle }}</p>
</div>
- <version-select-form v-if="!hasSelectedVersion" class="gl-mt-7" @submit="onVersionSelect" />
+ <version-select-form
+ v-if="!hasSelectedVersion"
+ class="gl-mt-7"
+ :loading="loadingVersionSelect"
+ @submit="onVersionSelect"
+ />
<div v-else class="gl-text-center">
<sign-in-oauth-button
class="gl-mb-5"
+ :gitlab-base-path="gitlabBasePath"
@sign-in="$emit('sign-in-oauth', $event)"
@error="onSignInError"
/>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
index 0fa745ed7e3..6b32225ed11 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
+++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue
@@ -9,13 +9,14 @@ import {
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
+import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants';
+
const RADIO_OPTIONS = {
saas: 'saas',
selfManaged: 'selfManaged',
};
const DEFAULT_RADIO_OPTION = RADIO_OPTIONS.saas;
-const GITLAB_COM_BASE_PATH = 'https://gitlab.com';
export default {
name: 'VersionSelectForm',
@@ -27,6 +28,13 @@ export default {
GlFormRadio,
GlButton,
},
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
data() {
return {
selected: DEFAULT_RADIO_OPTION,
@@ -82,7 +90,7 @@ export default {
</gl-form-group>
<div class="gl-display-flex gl-justify-content-end">
- <gl-button variant="confirm" type="submit">{{ __('Save') }}</gl-button>
+ <gl-button variant="confirm" type="submit" :loading="loading">{{ __('Save') }}</gl-button>
</div>
</gl-form>
</template>
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
index 4a83ee8671d..fff34e1d75d 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/actions.js
@@ -1,6 +1,8 @@
-import { fetchSubscriptions as fetchSubscriptionsREST } from '~/jira_connect/subscriptions/api';
-import { getCurrentUser } from '~/rest_api';
-import { addJiraConnectSubscription } from '~/api/integrations_api';
+import {
+ fetchSubscriptions as fetchSubscriptionsREST,
+ getCurrentUser,
+ addJiraConnectSubscription,
+} from '~/jira_connect/subscriptions/api';
import {
I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_TITLE,
I18N_ADD_SUBSCRIPTION_SUCCESS_ALERT_MESSAGE,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/store/state.js b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
index 03a83f18b4c..82a8517b511 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/store/state.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/store/state.js
@@ -1,4 +1,8 @@
-export default function createState({ subscriptions = [], subscriptionsLoading = false } = {}) {
+export default function createState({
+ subscriptions = [],
+ subscriptionsLoading = false,
+ currentUser = null,
+} = {}) {
return {
alert: undefined,
@@ -9,7 +13,7 @@ export default function createState({ subscriptions = [], subscriptionsLoading =
addSubscriptionLoading: false,
addSubscriptionError: false,
- currentUser: null,
+ currentUser,
currentUserError: null,
accessToken: null,
diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js
index b2d03a1fbba..6db8b62d692 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/utils.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js
@@ -1,32 +1,45 @@
import AccessorUtilities from '~/lib/utils/accessor';
import { objectToQuery } from '~/lib/utils/url_utility';
-import { ALERT_LOCALSTORAGE_KEY } from './constants';
+import { ALERT_LOCALSTORAGE_KEY, BASE_URL_LOCALSTORAGE_KEY } from './constants';
const isFunction = (fn) => typeof fn === 'function';
+const { canUseLocalStorage } = AccessorUtilities;
+
+const persistToStorage = (key, payload) => {
+ localStorage.setItem(key, payload);
+};
+
+const retrieveFromStorage = (key) => {
+ return localStorage.getItem(key);
+};
+
+const removeFromStorage = (key) => {
+ localStorage.removeItem(key);
+};
/**
* Persist alert data to localStorage.
*/
export const persistAlert = ({ title, message, linkUrl, variant } = {}) => {
- if (!AccessorUtilities.canUseLocalStorage()) {
+ if (!canUseLocalStorage()) {
return;
}
const payload = JSON.stringify({ title, message, linkUrl, variant });
- localStorage.setItem(ALERT_LOCALSTORAGE_KEY, payload);
+ persistToStorage(ALERT_LOCALSTORAGE_KEY, payload);
};
/**
* Return alert data from localStorage.
*/
export const retrieveAlert = () => {
- if (!AccessorUtilities.canUseLocalStorage()) {
+ if (!canUseLocalStorage()) {
return null;
}
- const initialAlertJSON = localStorage.getItem(ALERT_LOCALSTORAGE_KEY);
+ const initialAlertJSON = retrieveFromStorage(ALERT_LOCALSTORAGE_KEY);
// immediately clean up
- localStorage.removeItem(ALERT_LOCALSTORAGE_KEY);
+ removeFromStorage(ALERT_LOCALSTORAGE_KEY);
if (!initialAlertJSON) {
return null;
@@ -35,6 +48,22 @@ export const retrieveAlert = () => {
return JSON.parse(initialAlertJSON);
};
+export const persistBaseUrl = (baseUrl) => {
+ if (!canUseLocalStorage()) {
+ return;
+ }
+
+ persistToStorage(BASE_URL_LOCALSTORAGE_KEY, baseUrl);
+};
+
+export const retrieveBaseUrl = () => {
+ if (!canUseLocalStorage()) {
+ return null;
+ }
+
+ return retrieveFromStorage(BASE_URL_LOCALSTORAGE_KEY);
+};
+
export const getJwt = () => {
return new Promise((resolve) => {
if (isFunction(AP?.context?.getToken)) {
diff --git a/app/assets/javascripts/jobs/components/filtered_search/constants.js b/app/assets/javascripts/jobs/components/filtered_search/constants.js
new file mode 100644
index 00000000000..0daba892375
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/filtered_search/constants.js
@@ -0,0 +1,13 @@
+export const jobStatusValues = [
+ 'CANCELED',
+ 'CREATED',
+ 'FAILED',
+ 'MANUAL',
+ 'SUCCESS',
+ 'PENDING',
+ 'PREPARING',
+ 'RUNNING',
+ 'SCHEDULED',
+ 'SKIPPED',
+ 'WAITING_FOR_RESOURCE',
+];
diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
index fe7b7428c6e..e498a735898 100644
--- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
+++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue
@@ -11,6 +11,13 @@ export default {
components: {
GlFilteredSearch,
},
+ props: {
+ queryString: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ },
computed: {
tokens() {
return [
@@ -24,6 +31,20 @@ export default {
},
];
},
+ filteredSearchValue() {
+ if (this.queryString?.statuses) {
+ return [
+ {
+ type: 'status',
+ value: {
+ data: this.queryString?.statuses,
+ operator: '=',
+ },
+ },
+ ];
+ }
+ return [];
+ },
},
methods: {
onSubmit(filters) {
@@ -37,6 +58,7 @@ export default {
<gl-filtered-search
:placeholder="s__('Jobs|Filter jobs')"
:available-tokens="tokens"
+ :value="filteredSearchValue"
@submit="onSubmit"
/>
</template>
diff --git a/app/assets/javascripts/jobs/components/filtered_search/utils.js b/app/assets/javascripts/jobs/components/filtered_search/utils.js
new file mode 100644
index 00000000000..696cd8d4706
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/filtered_search/utils.js
@@ -0,0 +1,27 @@
+import { jobStatusValues } from './constants';
+
+// validates query string used for filtered search
+// on jobs table to ensure GraphQL query is called correctly
+export const validateQueryString = (queryStringObj) => {
+ // currently only one token is supported `statuses`
+ // this code will need to be expanded as more tokens
+ // are introduced
+
+ const filters = Object.keys(queryStringObj);
+
+ if (filters.includes('statuses')) {
+ const queryStringStatus = {
+ statuses: queryStringObj.statuses.toUpperCase(),
+ };
+
+ const found = jobStatusValues.find((status) => status === queryStringStatus.statuses);
+
+ if (found) {
+ return queryStringStatus;
+ }
+
+ return null;
+ }
+
+ return null;
+};
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/job/empty_state.vue
index e31c13f40b0..65b9600e664 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/job/empty_state.vue
@@ -1,12 +1,16 @@
<script>
import { GlLink } from '@gitlab/ui';
-import ManualVariablesForm from './manual_variables_form.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import LegacyManualVariablesForm from '~/jobs/components/job/legacy_manual_variables_form.vue';
+import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue';
export default {
components: {
GlLink,
+ LegacyManualVariablesForm,
ManualVariablesForm,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
illustrationPath: {
type: String,
@@ -50,6 +54,9 @@ export default {
},
},
computed: {
+ isGraphQL() {
+ return this.glFeatures?.graphqlJobApp;
+ },
shouldRenderManualVariables() {
return this.playable && !this.scheduled;
},
@@ -70,7 +77,12 @@ export default {
<p v-if="content" data-testid="job-empty-state-content">{{ content }}</p>
</div>
- <manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
+ <template v-if="isGraphQL">
+ <manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
+ </template>
+ <template v-else>
+ <legacy-manual-variables-form v-if="shouldRenderManualVariables" :action="action" />
+ </template>
<div class="text-content">
<div v-if="action && !shouldRenderManualVariables" class="text-center">
<gl-link
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/job/environments_block.vue
index 4046e1ade82..4046e1ade82 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/job/environments_block.vue
diff --git a/app/assets/javascripts/jobs/components/erased_block.vue b/app/assets/javascripts/jobs/components/job/erased_block.vue
index a815689659e..a815689659e 100644
--- a/app/assets/javascripts/jobs/components/erased_block.vue
+++ b/app/assets/javascripts/jobs/components/job/erased_block.vue
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue
index d5ee3423d70..81b65d175a7 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job/job_app.vue
@@ -6,15 +6,15 @@ import { mapGetters, mapState, mapActions } from 'vuex';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { __, sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import delayedJobMixin from '../mixins/delayed_job_mixin';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import Log from '~/jobs/components/log/log.vue';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue';
import LogTopBar from './job_log_controllers.vue';
-import Log from './log/log.vue';
-import Sidebar from './sidebar.vue';
import StuckBlock from './stuck_block.vue';
import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
+import Sidebar from './sidebar/sidebar.vue';
export default {
name: 'JobPageApp',
@@ -197,7 +197,7 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation gl-mt-6" />
+ <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-6" />
<template v-else-if="shouldRenderContent">
<div class="build-page" data-testid="job-content">
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
index e9809ac661b..e9809ac661b 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job/job_log_controllers.vue
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue
index 07ef4f054b4..1898e02c94e 100644
--- a/app/assets/javascripts/jobs/components/manual_variables_form.vue
+++ b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue
@@ -77,9 +77,6 @@ export default {
},
methods: {
...mapActions(['triggerManualJob']),
- canRemove(index) {
- return index < this.variables.length - 1;
- },
addEmptyVariable() {
const lastVar = this.variables[this.variables.length - 1];
@@ -93,12 +90,18 @@ export default {
id: uniqueId(),
});
},
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
deleteVariable(id) {
this.variables.splice(
this.variables.findIndex((el) => el.id === id),
1,
);
},
+ inputRef(type, id) {
+ return `${this.$options.inputTypes[type]}-${id}`;
+ },
trigger() {
this.triggerBtnDisabled = true;
@@ -125,7 +128,7 @@ export default {
</gl-input-group-text>
</template>
<gl-form-input
- :ref="`${$options.inputTypes.key}-${variable.id}`"
+ :ref="inputRef('key', variable.id)"
v-model="variable.key"
:placeholder="$options.i18n.keyPlaceholder"
data-testid="ci-variable-key"
@@ -140,20 +143,13 @@ export default {
</gl-input-group-text>
</template>
<gl-form-input
- :ref="`${$options.inputTypes.value}-${variable.id}`"
+ :ref="inputRef('value', variable.id)"
v-model="variable.secretValue"
:placeholder="$options.i18n.valuePlaceholder"
data-testid="ci-variable-value"
/>
</gl-form-input-group>
- <!-- delete variable button placeholder to not break flex layout -->
- <div
- v-if="!canRemove(index)"
- class="gl-w-7 gl-mr-3"
- data-testid="delete-variable-btn-placeholder"
- ></div>
-
<gl-button
v-if="canRemove(index)"
class="gl-flex-grow-0 gl-flex-basis-0"
@@ -164,6 +160,9 @@ export default {
data-testid="delete-variable-btn"
@click="deleteVariable(variable.id)"
/>
+
+ <!-- delete variable button placeholder to not break flex layout -->
+ <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div>
</div>
<div class="gl-text-center gl-mt-5">
diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
new file mode 100644
index 00000000000..2f97301979c
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue
@@ -0,0 +1,195 @@
+<script>
+import {
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlButton,
+ GlLink,
+ GlSprintf,
+} from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { mapActions } from 'vuex';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+// This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue
+// It is meant to fetch the job information via GraphQL instead of REST API.
+
+export default {
+ name: 'ManualVariablesForm',
+ components: {
+ GlFormInputGroup,
+ GlInputGroupText,
+ GlFormInput,
+ GlButton,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ action: {
+ type: Object,
+ required: false,
+ default: null,
+ validator(value) {
+ return (
+ value === null ||
+ (Object.prototype.hasOwnProperty.call(value, 'path') &&
+ Object.prototype.hasOwnProperty.call(value, 'method') &&
+ Object.prototype.hasOwnProperty.call(value, 'button_title'))
+ );
+ },
+ },
+ },
+ inputTypes: {
+ key: 'key',
+ value: 'value',
+ },
+ i18n: {
+ header: s__('CiVariables|Variables'),
+ keyLabel: s__('CiVariables|Key'),
+ valueLabel: s__('CiVariables|Value'),
+ keyPlaceholder: s__('CiVariables|Input variable key'),
+ valuePlaceholder: s__('CiVariables|Input variable value'),
+ formHelpText: s__(
+ 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
+ ),
+ },
+ data() {
+ return {
+ variables: [
+ {
+ key: '',
+ secretValue: '',
+ id: uniqueId(),
+ },
+ ],
+ triggerBtnDisabled: false,
+ };
+ },
+ computed: {
+ variableSettings() {
+ return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' });
+ },
+ preparedVariables() {
+ // we need to ensure no empty variables are passed to the API
+ // and secretValue should be snake_case when passed to the API
+ return this.variables
+ .filter((variable) => variable.key !== '')
+ .map(({ key, secretValue }) => ({ key, secret_value: secretValue }));
+ },
+ },
+ methods: {
+ ...mapActions(['triggerManualJob']),
+ addEmptyVariable() {
+ const lastVar = this.variables[this.variables.length - 1];
+
+ if (lastVar.key === '') {
+ return;
+ }
+
+ this.variables.push({
+ key: '',
+ secret_value: '',
+ id: uniqueId(),
+ });
+ },
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
+ deleteVariable(id) {
+ this.variables.splice(
+ this.variables.findIndex((el) => el.id === id),
+ 1,
+ );
+ },
+ inputRef(type, id) {
+ return `${this.$options.inputTypes[type]}-${id}`;
+ },
+ trigger() {
+ this.triggerBtnDisabled = true;
+
+ this.triggerManualJob(this.preparedVariables);
+ },
+ },
+};
+</script>
+<template>
+ <div class="row gl-justify-content-center">
+ <div class="col-10" data-testid="manual-vars-form">
+ <label>{{ $options.i18n.header }}</label>
+
+ <div
+ v-for="(variable, index) in variables"
+ :key="variable.id"
+ class="gl-display-flex gl-align-items-center gl-mb-4"
+ data-testid="ci-variable-row"
+ >
+ <gl-form-input-group class="gl-mr-4 gl-flex-grow-1">
+ <template #prepend>
+ <gl-input-group-text>
+ {{ $options.i18n.keyLabel }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :ref="inputRef('key', variable.id)"
+ v-model="variable.key"
+ :placeholder="$options.i18n.keyPlaceholder"
+ data-testid="ci-variable-key"
+ @change="addEmptyVariable"
+ />
+ </gl-form-input-group>
+
+ <gl-form-input-group class="gl-flex-grow-2">
+ <template #prepend>
+ <gl-input-group-text>
+ {{ $options.i18n.valueLabel }}
+ </gl-input-group-text>
+ </template>
+ <gl-form-input
+ :ref="inputRef('value', variable.id)"
+ v-model="variable.secretValue"
+ :placeholder="$options.i18n.valuePlaceholder"
+ data-testid="ci-variable-value"
+ />
+ </gl-form-input-group>
+
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-flex-grow-0 gl-flex-basis-0"
+ category="tertiary"
+ variant="danger"
+ icon="clear"
+ :aria-label="__('Delete variable')"
+ data-testid="delete-variable-btn"
+ @click="deleteVariable(variable.id)"
+ />
+
+ <!-- delete variable button placeholder to not break flex layout -->
+ <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div>
+ </div>
+
+ <div class="gl-text-center gl-mt-5">
+ <gl-sprintf :message="$options.i18n.formHelpText">
+ <template #link="{ content }">
+ <gl-link :href="variableSettings" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-display-flex gl-justify-content-center gl-mt-5">
+ <gl-button
+ class="gl-mt-5"
+ variant="confirm"
+ category="primary"
+ :aria-label="__('Trigger manual job')"
+ :disabled="triggerBtnDisabled"
+ data-testid="trigger-manual-job-btn"
+ @click="trigger"
+ >
+ {{ action.button_title }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
index 2018942a7e8..2018942a7e8 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue
index 7f25ca8a94d..7f25ca8a94d 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue
index 097ab3b4cf6..097ab3b4cf6 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue
diff --git a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
index e83ed6c6332..913924cc7b1 100644
--- a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlModal } from '@gitlab/ui';
-import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '../constants';
+import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants';
export default {
name: 'JobRetryForwardDeploymentModal',
diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
index a7bf365d35c..dd620977f0c 100644
--- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
@@ -1,12 +1,12 @@
<script>
import { GlButton, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
-import { JOB_SIDEBAR } from '../constants';
+import { JOB_SIDEBAR_COPY } from '~/jobs/constants';
export default {
name: 'JobSidebarRetryButton',
i18n: {
- retryLabel: JOB_SIDEBAR.retry,
+ retryLabel: JOB_SIDEBAR_COPY.retry,
},
components: {
GlButton,
diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue
index df64b6422c7..df64b6422c7 100644
--- a/app/assets/javascripts/jobs/components/jobs_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
new file mode 100644
index 00000000000..263b2d141c9
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+
+export default {
+ name: 'LegacySidebarHeader',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ JobSidebarRetryButton,
+ TooltipOnTruncate,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleSidebar']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="job.name" truncate-target="child"
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
+ {{ job.name }}
+ </h4>
+ </tooltip-on-truncate>
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-button
+ v-if="erasePath"
+ v-gl-tooltip.left
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
+ :href="erasePath"
+ :data-confirm="$options.i18n.eraseLogConfirmText"
+ class="gl-mr-2"
+ data-testid="job-log-erase-link"
+ data-confirm-btn-variant="danger"
+ data-method="post"
+ icon="remove"
+ />
+ <job-sidebar-retry-button
+ v-if="job.retry_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.retryJobButtonLabel"
+ :aria-label="$options.i18n.retryJobButtonLabel"
+ :category="retryButtonCategory"
+ :href="job.retry_path"
+ :modal-id="$options.forwardDeploymentFailureModalId"
+ variant="confirm"
+ data-qa-selector="retry_button"
+ data-testid="retry-button"
+ />
+ <gl-button
+ v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
+ :href="job.cancel_path"
+ variant="danger"
+ icon="cancel"
+ data-method="post"
+ data-testid="cancel-button"
+ rel="nofollow"
+ />
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
index a42e45ee7e4..b0db48df01f 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
@@ -1,48 +1,40 @@
<script>
-import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import { JOB_SIDEBAR } from '../constants';
-import ArtifactsBlock from './artifacts_block.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
import CommitBlock from './commit_block.vue';
-import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue';
-import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
import JobsContainer from './jobs_container.vue';
+import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue';
import JobSidebarDetailsContainer from './sidebar_job_details_container.vue';
+import ArtifactsBlock from './artifacts_block.vue';
+import LegacySidebarHeader from './legacy_sidebar_header.vue';
+import SidebarHeader from './sidebar_header.vue';
import StagesDropdown from './stages_dropdown.vue';
import TriggerBlock from './trigger_block.vue';
-export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
-
export default {
name: 'JobSidebar',
i18n: {
- eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
- eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
- cancelJobButtonLabel: s__('Job|Cancel'),
- retryJobButtonLabel: s__('Job|Retry'),
- ...JOB_SIDEBAR,
+ ...JOB_SIDEBAR_COPY,
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
ArtifactsBlock,
CommitBlock,
GlButton,
GlIcon,
JobsContainer,
- JobSidebarRetryButton,
JobRetryForwardDeploymentModal,
JobSidebarDetailsContainer,
+ LegacySidebarHeader,
+ SidebarHeader,
StagesDropdown,
- TooltipOnTruncate,
TriggerBlock,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
artifactHelpUrl: {
type: String,
@@ -58,9 +50,6 @@ export default {
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
- retryButtonCategory() {
- return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
- },
hasArtifact() {
// the artifact object will always have a locked property
return Object.keys(this.job.artifact).length > 1;
@@ -68,8 +57,8 @@ export default {
hasTriggers() {
return !isEmpty(this.job.trigger);
},
- hasStages() {
- return this.job?.pipeline?.stages?.length > 0;
+ isGraphQL() {
+ return this.glFeatures?.graphqlJobApp;
},
commit() {
return this.job?.pipeline?.commit || {};
@@ -79,7 +68,7 @@ export default {
},
},
methods: {
- ...mapActions(['fetchJobsForStage', 'toggleSidebar']),
+ ...mapActions(['fetchJobsForStage']),
},
};
</script>
@@ -87,61 +76,8 @@ export default {
<aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix">
<div class="sidebar-container">
<div class="blocks-container">
- <div class="gl-py-5 gl-display-flex gl-align-items-center">
- <tooltip-on-truncate :title="job.name" truncate-target="child"
- ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
- {{ job.name }}
- </h4>
- </tooltip-on-truncate>
- <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
- <gl-button
- v-if="erasePath"
- v-gl-tooltip.left
- :title="$options.i18n.eraseLogButtonLabel"
- :aria-label="$options.i18n.eraseLogButtonLabel"
- :href="erasePath"
- :data-confirm="$options.i18n.eraseLogConfirmText"
- class="gl-mr-2"
- data-testid="job-log-erase-link"
- data-confirm-btn-variant="danger"
- data-method="post"
- icon="remove"
- />
- <job-sidebar-retry-button
- v-if="job.retry_path"
- v-gl-tooltip.left
- :title="$options.i18n.retryJobButtonLabel"
- :aria-label="$options.i18n.retryJobButtonLabel"
- :category="retryButtonCategory"
- :href="job.retry_path"
- :modal-id="$options.forwardDeploymentFailureModalId"
- variant="confirm"
- data-qa-selector="retry_button"
- data-testid="retry-button"
- />
- <gl-button
- v-if="job.cancel_path"
- v-gl-tooltip.left
- :title="$options.i18n.cancelJobButtonLabel"
- :aria-label="$options.i18n.cancelJobButtonLabel"
- :href="job.cancel_path"
- variant="danger"
- icon="cancel"
- data-method="post"
- data-testid="cancel-button"
- rel="nofollow"
- />
- </div>
-
- <gl-button
- :aria-label="$options.i18n.toggleSidebar"
- category="tertiary"
- class="gl-md-display-none gl-ml-2"
- icon="chevron-double-lg-right"
- @click="toggleSidebar"
- />
- </div>
-
+ <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" />
+ <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" />
<div
v-if="job.terminal_path || job.new_issue_path"
class="gl-py-5"
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
index 05567328660..05567328660 100644
--- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
new file mode 100644
index 00000000000..523710598bf
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -0,0 +1,102 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+
+// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue
+// It is meant to fetch the job information via GraphQL instead of REST API.
+
+export default {
+ name: 'SidebarHeader',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ JobSidebarRetryButton,
+ TooltipOnTruncate,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleSidebar']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="job.name" truncate-target="child"
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
+ {{ job.name }}
+ </h4>
+ </tooltip-on-truncate>
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-button
+ v-if="erasePath"
+ v-gl-tooltip.left
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
+ :href="erasePath"
+ :data-confirm="$options.i18n.eraseLogConfirmText"
+ class="gl-mr-2"
+ data-testid="job-log-erase-link"
+ data-confirm-btn-variant="danger"
+ data-method="post"
+ icon="remove"
+ />
+ <job-sidebar-retry-button
+ v-if="job.retry_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.retryJobButtonLabel"
+ :aria-label="$options.i18n.retryJobButtonLabel"
+ :category="retryButtonCategory"
+ :href="job.retry_path"
+ :modal-id="$options.forwardDeploymentFailureModalId"
+ variant="confirm"
+ data-qa-selector="retry_button"
+ data-testid="retry-button"
+ />
+ <gl-button
+ v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
+ :href="job.cancel_path"
+ variant="danger"
+ icon="cancel"
+ data-method="post"
+ data-testid="cancel-button"
+ rel="nofollow"
+ />
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
index 3b1509e5be5..3b1509e5be5 100644
--- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
index 7c4811b2d6f..e3afe9b7c67 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -4,14 +4,14 @@ import { isEmpty } from 'lodash';
import Mousetrap from 'mousetrap';
import { s__ } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings';
export default {
components: {
CiIcon,
- clipboardButton,
+ ClipboardButton,
GlDropdown,
GlDropdownItem,
GlLink,
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue
index 1afc1c9a595..1afc1c9a595 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue
diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/job/stuck_block.vue
index d7a26d22406..d7a26d22406 100644
--- a/app/assets/javascripts/jobs/components/stuck_block.vue
+++ b/app/assets/javascripts/jobs/components/job/stuck_block.vue
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue
index c9747ca9f02..c9747ca9f02 100644
--- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
+++ b/app/assets/javascripts/jobs/components/job/unmet_prerequisites_block.vue
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 98b51e8c2c4..851be211b25 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
@@ -11,6 +11,7 @@ query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJo
}
nodes {
artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
downloadPath
fileType
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 c2f460cb647..0a4757d11a8 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -2,7 +2,9 @@
import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
+import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility';
import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue';
+import { validateQueryString } from '../filtered_search/utils';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
@@ -37,6 +39,7 @@ export default {
variables() {
return {
fullPath: this.fullPath,
+ ...this.validatedQueryString,
};
},
update(data) {
@@ -95,6 +98,11 @@ export default {
jobsCount() {
return this.jobs.count;
},
+ validatedQueryString() {
+ const queryStringObject = queryToObject(window.location.search);
+
+ return validateQueryString(queryStringObject);
+ },
},
watch: {
// this watcher ensures that the count on the all tab
@@ -133,6 +141,10 @@ export default {
}
if (filter.type === 'status') {
+ updateHistory({
+ url: setUrlParams({ statuses: filter.value.data }, window.location.href, true),
+ });
+
this.$apollo.queries.jobs.refetch({ statuses: filter.value.data });
}
});
@@ -171,12 +183,12 @@ export default {
:loading="loading"
@fetchJobsByStatus="fetchJobsByStatus"
/>
-
- <jobs-filtered-search
- v-if="showFilteredSearch"
- :class="$options.filterSearchBoxStyles"
- @filterJobsBySearch="filterJobsBySearch"
- />
+ <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles">
+ <jobs-filtered-search
+ :query-string="validatedQueryString"
+ @filterJobsBySearch="filterJobsBySearch"
+ />
+ </div>
<div v-if="showSkeletonLoader" class="gl-mt-5">
<gl-skeleton-loader :width="1248" :height="73">
diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js
index 3040d4e2379..50ee7bd20dd 100644
--- a/app/assets/javascripts/jobs/constants.js
+++ b/app/assets/javascripts/jobs/constants.js
@@ -3,11 +3,17 @@ import { __, s__ } from '~/locale';
const cancel = __('Cancel');
const moreInfo = __('More information');
-export const JOB_SIDEBAR = {
+export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
+
+export const JOB_SIDEBAR_COPY = {
cancel,
+ cancelJobButtonLabel: s__('Job|Cancel'),
debug: __('Debug'),
+ eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
+ eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
newIssue: __('New issue'),
retry: __('Retry'),
+ retryJobButtonLabel: s__('Job|Retry'),
toggleSidebar: __('Toggle Sidebar'),
};
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 5c63ad96ad0..9dd47f4046c 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -1,6 +1,6 @@
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
-import JobApp from './components/job_app.vue';
+import JobApp from './components/job/job_app.vue';
import createStore from './store';
Vue.use(GlToast);
diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js
index 3e5396c5bd8..51fedac339b 100644
--- a/app/assets/javascripts/labels/labels_select.js
+++ b/app/assets/javascripts/labels/labels_select.js
@@ -247,6 +247,7 @@ export default class LabelsSelect {
}
linkEl.className = selectedClass.join(' ');
+ // eslint-disable-next-line no-unsanitized/property
linkEl.innerHTML = `${colorEl} ${escape(label.title)}`;
const listItemEl = document.createElement('li');
diff --git a/app/assets/javascripts/lib/dateformat.js b/app/assets/javascripts/lib/dateformat.js
new file mode 100644
index 00000000000..1fd95dd03ab
--- /dev/null
+++ b/app/assets/javascripts/lib/dateformat.js
@@ -0,0 +1,60 @@
+import dateFormat, { i18n, masks } from 'dateformat';
+import { s__, __ } from '~/locale';
+
+i18n.dayNames = [
+ __('Sun'),
+ __('Mon'),
+ __('Tue'),
+ __('Wed'),
+ __('Thu'),
+ __('Fri'),
+ __('Sat'),
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+];
+
+i18n.monthNames = [
+ __('Jan'),
+ __('Feb'),
+ __('Mar'),
+ __('Apr'),
+ __('May'),
+ __('Jun'),
+ __('Jul'),
+ __('Aug'),
+ __('Sep'),
+ __('Oct'),
+ __('Nov'),
+ __('Dec'),
+ __('January'),
+ __('February'),
+ __('March'),
+ __('April'),
+ __('May'),
+ __('June'),
+ __('July'),
+ __('August'),
+ __('September'),
+ __('October'),
+ __('November'),
+ __('December'),
+];
+
+i18n.timeNames = [
+ s__('Time|a'),
+ s__('Time|p'),
+ s__('Time|am'),
+ s__('Time|pm'),
+ s__('Time|A'),
+ s__('Time|P'),
+ s__('Time|AM'),
+ s__('Time|PM'),
+];
+
+export { masks };
+export default dateFormat;
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index 3e28ca2a0f7..6f24590f9e7 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -1,6 +1,8 @@
-import { sanitize as dompurifySanitize, addHook } from 'dompurify';
+import DOMPurify from 'dompurify';
import { getNormalizedURL, getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility';
+const { sanitize: dompurifySanitize, addHook, isValidAttribute } = DOMPurify;
+
const defaultConfig = {
// Safely allow SVG <use> tags
ADD_TAGS: ['use', 'gl-emoji', 'copy-code'],
@@ -94,4 +96,4 @@ addHook('afterSanitizeAttributes', (node) => {
export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config });
-export { isValidAttribute } from 'dompurify';
+export { isValidAttribute };
diff --git a/app/assets/javascripts/lib/gfm/constants.js b/app/assets/javascripts/lib/gfm/constants.js
new file mode 100644
index 00000000000..eaabeb2a767
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/constants.js
@@ -0,0 +1,10 @@
+export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN = '[[';
+export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN = 'TOC';
+export const TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN = ']]';
+export const TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN = '[TOC]';
+
+export const MDAST_TEXT_NODE = 'text';
+export const MDAST_EMPHASIS_NODE = 'emphasis';
+export const MDAST_PARAGRAPH_NODE = 'paragraph';
+
+export const GLFM_TABLE_OF_CONTENTS_NODE = 'tableOfContents';
diff --git a/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js
new file mode 100644
index 00000000000..4d2484a657a
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/glfm_extensions/table_of_contents.js
@@ -0,0 +1,85 @@
+import { first, last } from 'lodash';
+import { u } from 'unist-builder';
+import { visitParents, SKIP, CONTINUE } from 'unist-util-visit-parents';
+import {
+ TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN,
+ TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN,
+ TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN,
+ TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN,
+ MDAST_TEXT_NODE,
+ MDAST_EMPHASIS_NODE,
+ MDAST_PARAGRAPH_NODE,
+ GLFM_TABLE_OF_CONTENTS_NODE,
+} from '../constants';
+
+const isTOCTextNode = ({ type, value }) =>
+ type === MDAST_TEXT_NODE && value === TABLE_OF_CONTENTS_DOUBLE_BRACKET_MIDDLE_TOKEN;
+
+const isTOCEmphasisNode = ({ type, children }) =>
+ type === MDAST_EMPHASIS_NODE && children.length === 1 && isTOCTextNode(first(children));
+
+const isTOCDoubleSquareBracketOpenTokenTextNode = ({ type, value }) =>
+ type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_OPEN_TOKEN;
+
+const isTOCDoubleSquareBracketCloseTokenTextNode = ({ type, value }) =>
+ type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_DOUBLE_BRACKET_CLOSE_TOKEN;
+
+/*
+ * Detects table of contents declaration with syntax [[_TOC_]]
+ */
+const isTableOfContentsDoubleSquareBracketSyntax = ({ children }) => {
+ if (children.length !== 3) {
+ return false;
+ }
+
+ const [firstChild, middleChild, lastChild] = children;
+
+ return (
+ isTOCDoubleSquareBracketOpenTokenTextNode(firstChild) &&
+ isTOCEmphasisNode(middleChild) &&
+ isTOCDoubleSquareBracketCloseTokenTextNode(lastChild)
+ );
+};
+
+/*
+ * Detects table of contents declaration with syntax [TOC]
+ */
+const isTableOfContentsSingleSquareBracketSyntax = ({ children }) => {
+ if (children.length !== 1) {
+ return false;
+ }
+
+ const [firstChild] = children;
+ const { type, value } = firstChild;
+
+ return type === MDAST_TEXT_NODE && value.trim() === TABLE_OF_CONTENTS_SINGLE_BRACKET_TOKEN;
+};
+
+const isTableOfContentsNode = (node) =>
+ node.type === MDAST_PARAGRAPH_NODE &&
+ (isTableOfContentsDoubleSquareBracketSyntax(node) ||
+ isTableOfContentsSingleSquareBracketSyntax(node));
+
+export default () => {
+ return (tree) => {
+ visitParents(tree, (node, ancestors) => {
+ const parent = last(ancestors);
+
+ if (!parent) {
+ return CONTINUE;
+ }
+
+ if (isTableOfContentsNode(node)) {
+ const index = parent.children.indexOf(node);
+
+ parent.children[index] = u(GLFM_TABLE_OF_CONTENTS_NODE, {
+ position: node.position,
+ });
+ }
+
+ return SKIP;
+ });
+
+ return tree;
+ };
+};
diff --git a/app/assets/javascripts/lib/gfm/index.js b/app/assets/javascripts/lib/gfm/index.js
index eaf653e9924..fad73f93c1a 100644
--- a/app/assets/javascripts/lib/gfm/index.js
+++ b/app/assets/javascripts/lib/gfm/index.js
@@ -6,6 +6,8 @@ import remarkFrontmatter from 'remark-frontmatter';
import remarkGfm from 'remark-gfm';
import remarkRehype, { all } from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
+import glfmTableOfContents from './glfm_extensions/table_of_contents';
+import * as glfmMdastToHastHandlers from './mdast_to_hast_handlers/glfm_mdast_to_hast_handlers';
const skipFrontmatterHandler = (language) => (h, node) =>
h(node.position, 'frontmatter', { language }, [{ type: 'text', value: node.value }]);
@@ -65,19 +67,22 @@ const skipRenderingHandlers = {
all(h, node),
);
},
+ tableOfContents: (h, node) => h(node.position, 'tableOfContents'),
toml: skipFrontmatterHandler('toml'),
yaml: skipFrontmatterHandler('yaml'),
json: skipFrontmatterHandler('json'),
};
-const createParser = ({ skipRendering = [] }) => {
+const createParser = ({ skipRendering }) => {
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFrontmatter, ['yaml', 'toml', { type: 'json', marker: ';' }])
+ .use(glfmTableOfContents)
.use(remarkRehype, {
allowDangerousHtml: true,
handlers: {
+ ...glfmMdastToHastHandlers,
...pick(skipRenderingHandlers, skipRendering),
},
})
@@ -99,13 +104,13 @@ const compilerFactory = (renderer) =>
* tree in any desired representation
*
* @param {String} params.markdown Markdown to parse
- * @param {(tree: MDast -> any)} params.renderer A function that accepts mdast
+ * @param {Function} params.renderer A function that accepts mdast
* AST tree and returns an object of any type that represents the result of
* rendering the tree. See the references below to for more information
* about MDast.
*
* MDastTree documentation https://github.com/syntax-tree/mdast
- * @returns {Promise<any>} Returns a promise with the result of rendering
+ * @returns {Promise} Returns a promise with the result of rendering
* the MDast tree
*/
export const render = async ({ markdown, renderer, skipRendering = [] }) => {
diff --git a/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js
new file mode 100644
index 00000000000..91b09e69405
--- /dev/null
+++ b/app/assets/javascripts/lib/gfm/mdast_to_hast_handlers/glfm_mdast_to_hast_handlers.js
@@ -0,0 +1 @@
+export const tableOfContents = (h, node) => h(node.position, 'nav');
diff --git a/app/assets/javascripts/lib/mermaid.js b/app/assets/javascripts/lib/mermaid.js
index d621c9ddf9e..c72561ce69d 100644
--- a/app/assets/javascripts/lib/mermaid.js
+++ b/app/assets/javascripts/lib/mermaid.js
@@ -9,6 +9,7 @@ const setIframeRenderedSize = (h, w) => {
const drawDiagram = (source) => {
const element = document.getElementById('app');
const insertSvg = (svgCode) => {
+ // eslint-disable-next-line no-unsanitized/property
element.innerHTML = svgCode;
const height = parseInt(element.firstElementChild.getAttribute('height'), 10);
diff --git a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
index 4e7086e62c5..6c5d4ecc901 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_calculation_utility.js
@@ -142,9 +142,16 @@ export const dayInQuarter = (date, quarter) => {
export const millisecondsPerDay = 1000 * 60 * 60 * 24;
-export const getDayDifference = (a, b) => {
- const date1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
- const date2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
+/**
+ * Calculates the number of days between 2 specified dates, excluding the current date
+ *
+ * @param {Date} startDate the earlier date that we will substract from the end date
+ * @param {Date} endDate the last date in the range
+ * @return {Number} number of days in between
+ */
+export const getDayDifference = (startDate, endDate) => {
+ const date1 = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
+ const date2 = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return Math.floor((date2 - date1) / millisecondsPerDay);
};
@@ -208,6 +215,19 @@ export const newDateAsLocaleTime = (date) => {
return new Date(`${date}${suffix}`);
};
+/**
+ * Takes a Date object (where timezone could be GMT or EST) and
+ * returns a Date object with the same date but in UTC.
+ *
+ * @param {Date} date A Date object
+ * @returns {Date|null} A Date object with the same date but in UTC
+ */
+export const getDateWithUTC = (date) => {
+ return date instanceof Date
+ ? new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
+ : null;
+};
+
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 830f4604382..d07abb72210 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -1,5 +1,5 @@
-import dateFormat from 'dateformat';
import { isString, mapValues, reduce, isDate, unescape } from 'lodash';
+import dateFormat from '~/lib/dateformat';
import { roundToNearestHalf } from '~/lib/utils/common_utils';
import { sanitize } from '~/lib/dompurify';
import { s__, n__, __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/lib/utils/datetime_range.js b/app/assets/javascripts/lib/utils/datetime_range.js
index 840cc4600fe..548f5a438df 100644
--- a/app/assets/javascripts/lib/utils/datetime_range.js
+++ b/app/assets/javascripts/lib/utils/datetime_range.js
@@ -1,5 +1,5 @@
-import dateformat from 'dateformat';
import { pick, omit, isEqual, isEmpty } from 'lodash';
+import dateformat from '~/lib/dateformat';
import { DATETIME_RANGE_TYPES } from './constants';
import { secondsToMilliseconds } from './datetime_utility';
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 9f4e12a3010..48be8af3ff6 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -263,10 +263,12 @@ export function insertMarkdownText({
if (tag === LINK_TAG_PATTERN) {
if (URL) {
try {
- new URL(selected); // eslint-disable-line no-new
- // valid url
- tag = '[text]({text})';
- select = 'text';
+ const url = new URL(selected);
+
+ if (url.origin !== 'null' || url.origin === null) {
+ tag = '[text]({text})';
+ select = 'text';
+ }
} catch (e) {
// ignore - no valid url
}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 7b00995b2e5..59645d50e29 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -119,6 +119,7 @@ const getAverageCharWidth = memoize(function getAverageCharWidth(options = {}) {
div.style.left = -1000;
div.style.top = -1000;
+ // eslint-disable-next-line no-unsanitized/property
div.innerHTML = chars;
document.body.appendChild(div);
diff --git a/app/assets/javascripts/linked_resources/index.js b/app/assets/javascripts/linked_resources/index.js
index 6d799d30b4b..f1d3026c2f1 100644
--- a/app/assets/javascripts/linked_resources/index.js
+++ b/app/assets/javascripts/linked_resources/index.js
@@ -22,7 +22,7 @@ export default function initLinkedResources() {
name: 'LinkedResourcesRoot',
apolloProvider,
components: {
- resourceLinksBlock: ResourceLinksBlock,
+ ResourceLinksBlock,
},
render: (createElement) =>
createElement('resource-links-block', {
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index e1749331d90..c8c6b51f374 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -14,6 +14,8 @@ import { escape } from 'lodash';
export default (input, parameters, escapeParameters = true) => {
let output = input;
+ output = output.replace(/%+/g, '%');
+
if (parameters) {
const mappedParameters = new Map(Object.entries(parameters));
diff --git a/app/assets/javascripts/members/components/modals/remove_member_modal.vue b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
index b82fb0030ff..1bb1f90302c 100644
--- a/app/assets/javascripts/members/components/modals/remove_member_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_member_modal.vue
@@ -88,7 +88,8 @@ export default {
:action-primary="actionPrimary"
:title="actionText"
:visible="removeMemberModalVisible"
- data-qa-selector="remove_member_modal_content"
+ data-qa-selector="remove_member_modal"
+ data-testid="remove-member-modal-content"
@primary="submitForm"
@hide="hideRemoveMemberModal"
>
diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js
index 93d113d1afe..3135ec602be 100644
--- a/app/assets/javascripts/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -196,3 +196,8 @@ export const MEMBER_ACCESS_LEVEL_PROPERTY_NAME = 'access_level';
export const GROUP_LINK_BASE_PROPERTY_NAME = 'group_link';
export const GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME = 'group_access';
+
+export const I18N_USER_YOU = __("It's you");
+export const I18N_USER_BLOCKED = __('Blocked');
+export const I18N_USER_BOT = __('Bot');
+export const I188N_USER_2FA = __('2FA');
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
index 7ec083646e9..0da44b7d468 100644
--- a/app/assets/javascripts/members/utils.js
+++ b/app/assets/javascripts/members/utils.js
@@ -1,28 +1,36 @@
import { isUndefined } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { getParameterByName, setUrlParams } from '~/lib/utils/url_utility';
-import { __ } from '~/locale';
import {
FIELDS,
DEFAULT_SORT,
GROUP_LINK_BASE_PROPERTY_NAME,
GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME,
+ I18N_USER_YOU,
+ I18N_USER_BLOCKED,
+ I18N_USER_BOT,
+ I188N_USER_2FA,
} from './constants';
export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [
{
show: isCurrentUser,
- text: __("It's you"),
+ text: I18N_USER_YOU,
variant: 'success',
},
{
show: member.user?.blocked,
- text: __('Blocked'),
+ text: I18N_USER_BLOCKED,
variant: 'danger',
},
{
+ show: member.user?.isBot,
+ text: I18N_USER_BOT,
+ variant: 'muted',
+ },
+ {
show: member.user?.twoFactorEnabled && (canManageMembers || isCurrentUser),
- text: __('2FA'),
+ text: I188N_USER_2FA,
variant: 'info',
},
];
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index ed2e6a5af58..0b53a8ede64 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -434,6 +434,7 @@ export default class MergeRequestTabs {
.get(`${source}.json`)
.then(({ data }) => {
const commitsDiv = document.querySelector('div#commits');
+ // eslint-disable-next-line no-unsanitized/property
commitsDiv.innerHTML = data.html;
localTimeAgo(commitsDiv.querySelectorAll('.js-timeago'));
this.commitsLoaded = true;
diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue
new file mode 100644
index 00000000000..f067982fce1
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue
@@ -0,0 +1,180 @@
+<script>
+import {
+ GlIntersectionObserver,
+ GlLink,
+ GlSprintf,
+ GlBadge,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import { mapGetters, mapState } from 'vuex';
+import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import StatusBox from '~/issuable/components/status_box.vue';
+import DiscussionCounter from '~/notes/components/discussion_counter.vue';
+import TodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+export default {
+ components: {
+ GlIntersectionObserver,
+ GlLink,
+ GlSprintf,
+ GlBadge,
+ StatusBox,
+ DiscussionCounter,
+ TodoWidget,
+ ClipboardButton,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ inject: {
+ projectPath: { default: null },
+ title: { default: '' },
+ tabs: { default: () => [] },
+ isFluidLayout: { default: false },
+ },
+ data() {
+ return {
+ isStickyHeaderVisible: false,
+ discussionCounter: 0,
+ };
+ },
+ computed: {
+ ...mapGetters(['getNoteableData', 'discussionTabCounter']),
+ ...mapState({
+ activeTab: (state) => state.page.activeTab,
+ doneFetchingBatchDiscussions: (state) => state.notes.doneFetchingBatchDiscussions,
+ }),
+ issuableId() {
+ return convertToGraphQLId(TYPE_MERGE_REQUEST, this.getNoteableData.id);
+ },
+ issuableIid() {
+ return `${this.getNoteableData.iid}`;
+ },
+ isSignedIn() {
+ return isLoggedIn();
+ },
+ },
+ watch: {
+ discussionTabCounter(val) {
+ if (this.glFeatures.paginatedMrDiscussions) {
+ if (this.doneFetchingBatchDiscussions) {
+ this.discussionCounter = val;
+ }
+ } else {
+ this.discussionCounter = val;
+ }
+ },
+ },
+ methods: {
+ setStickyHeaderVisible(val) {
+ this.isStickyHeaderVisible = val;
+ },
+ visitTab(e) {
+ window.mrTabs?.clickTab(e);
+ },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['gl-emoji'],
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer
+ @appear="setStickyHeaderVisible(false)"
+ @disappear="setStickyHeaderVisible(true)"
+ >
+ <div
+ v-if="isStickyHeaderVisible"
+ class="issue-sticky-header merge-request-sticky-header gl-fixed gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-pt-3 gl-display-none gl-md-display-block"
+ >
+ <div
+ class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5"
+ :class="{ 'gl-max-w-container-xl': !isFluidLayout }"
+ >
+ <div class="gl-w-full gl-display-flex gl-align-items-center">
+ <status-box :initial-state="getNoteableData.state" issuable-type="merge_request" />
+ <p
+ v-safe-html:[$options.safeHtmlConfig]="title"
+ class="gl-display-none gl-lg-display-block gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0 gl-mr-4"
+ ></p>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-sprintf :message="__('%{source} %{copyButton} into %{target}')">
+ <template #copyButton>
+ <clipboard-button
+ :text="getNoteableData.source_branch"
+ :title="__('Copy branch name')"
+ size="small"
+ category="tertiary"
+ tooltip-placement="bottom"
+ class="gl-m-0! gl-mx-1! js-source-branch-copy"
+ />
+ </template>
+ <template #source>
+ <gl-link
+ :title="getNoteableData.source_branch"
+ :href="getNoteableData.source_branch_path"
+ class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26"
+ >
+ {{ getNoteableData.source_branch }}
+ </gl-link>
+ </template>
+ <template #target>
+ <gl-link
+ :title="getNoteableData.target_branch"
+ :href="getNoteableData.target_branch_path"
+ class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26 gl-ml-2"
+ >
+ {{ getNoteableData.target_branch }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+ <div class="gl-w-full gl-display-flex">
+ <ul
+ class="merge-request-tabs nav-tabs nav nav-links gl-display-flex gl-flex-nowrap gl-m-0 gl-p-0 gl-border-b-0"
+ >
+ <li
+ v-for="(tab, index) in tabs"
+ :key="tab[0]"
+ :class="{ active: activeTab === tab[0] }"
+ >
+ <gl-link
+ :href="tab[2]"
+ :data-action="tab[0]"
+ class="gl-outline-0! gl-py-4!"
+ @click="visitTab"
+ >
+ {{ tab[1] }}
+ <gl-badge variant="muted" size="sm">
+ <template v-if="index === 0 && discussionCounter !== 0">
+ {{ discussionCounter }}
+ </template>
+ <template v-else>
+ {{ tab[3] }}
+ </template>
+ </gl-badge>
+ </gl-link>
+ </li>
+ </ul>
+ <div class="gl-display-none gl-lg-display-flex gl-align-items-center gl-ml-auto">
+ <discussion-counter blocks-merge hide-options />
+ <todo-widget
+ v-if="isSignedIn"
+ :issuable-id="issuableId"
+ :issuable-iid="issuableIid"
+ :full-path="projectPath"
+ issuable-type="merge_request"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index 59d2a2b29b3..5c3b969655b 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -243,7 +243,7 @@ export default {
v-for="(item, idx) in extraLinks"
:key="idx"
:href="item.url"
- :is-check-item="true"
+ is-check-item
data-testid="milestone-combobox-extra-links"
>
{{ item.text }}
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 250d4b3c55f..e3fcdf716d4 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -391,7 +391,11 @@ export default {
};
</script>
<template>
- <div class="prometheus-graphs" data-qa-selector="prometheus_graphs">
+ <div
+ class="prometheus-graphs"
+ data-qa-selector="prometheus_graphs_content"
+ data-testid="prometheus-graphs"
+ >
<div>
<gl-alert
v-if="!isDeprecationNoticeDismissed"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 3338635bf96..90d2498ac19 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -189,6 +189,7 @@ export default {
ref="monitorEnvironmentsDropdown"
class="flex-grow-1"
data-qa-selector="environments_dropdown"
+ data-testid="environments-dropdown"
toggle-class="dropdown-menu-toggle"
menu-class="monitor-environment-dropdown-menu"
:text="environmentDropdownText"
@@ -202,7 +203,7 @@ export default {
<gl-dropdown-item
v-for="environment in filteredEnvironments"
:key="environment.id"
- :is-check-item="true"
+ is-check-item
:is-checked="environment.name === currentEnvironmentName"
:href="getEnvironmentPath(environment.id)"
>
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 568c66cf152..7fae684315c 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -86,7 +86,7 @@ export default {
<gl-dropdown-item
v-for="dashboard in starredDashboards"
:key="dashboard.path"
- :is-check-item="true"
+ is-check-item
:is-checked="dashboard.path === selectedDashboardPath"
@click="selectDashboard(dashboard)"
>
@@ -105,7 +105,7 @@ export default {
<gl-dropdown-item
v-for="dashboard in nonStarredDashboards"
:key="dashboard.path"
- :is-check-item="true"
+ is-check-item
:is-checked="dashboard.path === selectedDashboardPath"
@click="selectDashboard(dashboard)"
>
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
index 0b80043a92c..544fe10f26e 100644
--- a/app/assets/javascripts/monitoring/components/refresh_button.vue
+++ b/app/assets/javascripts/monitoring/components/refresh_button.vue
@@ -163,7 +163,7 @@ export default {
:text="dropdownText"
>
<gl-dropdown-item
- :is-check-item="true"
+ is-check-item
:is-checked="refreshInterval === null"
@click="removeRefreshInterval()"
>{{ __('Off') }}</gl-dropdown-item
@@ -172,7 +172,7 @@ export default {
<gl-dropdown-item
v-for="(option, i) in $options.refreshIntervals"
:key="i"
- :is-check-item="true"
+ is-check-item
:is-checked="isChecked(option)"
@click="setRefreshInterval(option)"
>{{ option.label }}</gl-dropdown-item
diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js
index c7bc626eb11..f20fea48084 100644
--- a/app/assets/javascripts/monitoring/format_date.js
+++ b/app/assets/javascripts/monitoring/format_date.js
@@ -1,4 +1,4 @@
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
export const timezones = {
/**
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index 7424c011052..297420bf94d 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -5,9 +5,8 @@ import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal';
import initDiffsApp from '../diffs';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import MergeRequest from '../merge_request';
-import discussionCounter from '../notes/components/discussion_counter.vue';
+import DiscussionCounter from '../notes/components/discussion_counter.vue';
import initDiscussionFilters from '../notes/discussion_filters';
-import initSortDiscussions from '../notes/sort_discussions';
import initNotesApp from './init_notes';
export default function initMrNotes() {
@@ -38,7 +37,7 @@ export default function initMrNotes() {
el,
name: 'DiscussionCounter',
components: {
- discussionCounter,
+ DiscussionCounter,
},
store,
render(createElement) {
@@ -52,6 +51,5 @@ export default function initMrNotes() {
}
initDiscussionFilters(store);
- initSortDiscussions(store);
});
}
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index cf24d18c7b6..e4a7a7bd9fc 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -4,7 +4,7 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import store from '~/mr_notes/stores';
import discussionNavigator from '../notes/components/discussion_navigator.vue';
-import notesApp from '../notes/components/notes_app.vue';
+import NotesApp from '../notes/components/notes_app.vue';
import initWidget from '../vue_merge_request_widget';
export default () => {
@@ -13,7 +13,7 @@ export default () => {
el: '#js-vue-mr-discussions',
name: 'MergeRequestDiscussions',
components: {
- notesApp,
+ NotesApp,
},
store,
data() {
diff --git a/app/assets/javascripts/nav/components/top_nav_app.vue b/app/assets/javascripts/nav/components/top_nav_app.vue
index 08a2c6952c8..ca6e6567f74 100644
--- a/app/assets/javascripts/nav/components/top_nav_app.vue
+++ b/app/assets/javascripts/nav/components/top_nav_app.vue
@@ -1,14 +1,18 @@
<script>
-import { GlNav, GlNavItemDropdown, GlDropdownForm } from '@gitlab/ui';
+import { GlNav, GlIcon, GlNavItemDropdown, GlDropdownForm, GlTooltipDirective } from '@gitlab/ui';
import TopNavDropdownMenu from './top_nav_dropdown_menu.vue';
export default {
components: {
+ GlIcon,
GlNav,
GlNavItemDropdown,
GlDropdownForm,
TopNavDropdownMenu,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
navData: {
type: Object,
@@ -21,15 +25,20 @@ export default {
<template>
<gl-nav class="navbar-sub-nav">
<gl-nav-item-dropdown
- :text="navData.activeTitle"
+ v-gl-tooltip.bottom="navData.menuTooltip"
data-qa-selector="navbar_dropdown"
- :data-qa-title="navData.activeTitle"
- icon="hamburger"
+ data-qa-title="Menu"
menu-class="gl-mt-3! gl-max-w-none! gl-max-h-none! gl-sm-w-auto! js-top-nav-dropdown-menu"
toggle-class="top-nav-toggle js-top-nav-dropdown-toggle gl-px-3!"
no-flip
no-caret
>
+ <template #button-content>
+ <gl-icon name="hamburger" />
+ <span v-if="navData.menuTitle" class="gl-ml-3">
+ {{ navData.menuTitle }}
+ </span>
+ </template>
<gl-dropdown-form>
<top-nav-dropdown-menu
:primary="navData.primary"
diff --git a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
index b8555df53df..97e63c7324e 100644
--- a/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
+++ b/app/assets/javascripts/nav/components/top_nav_menu_sections.vue
@@ -49,15 +49,26 @@ export default {
:class="getMenuSectionClasses(sectionIndex)"
data-testid="menu-section"
>
- <top-nav-menu-item
- v-for="(menuItem, menuItemIndex) in menuItems"
- :key="menuItem.id"
- :menu-item="menuItem"
- data-testid="menu-item"
- class="gl-w-full"
- :class="{ 'gl-mt-1': menuItemIndex > 0 }"
- @click="onClick(menuItem)"
- />
+ <template v-for="(menuItem, menuItemIndex) in menuItems">
+ <strong
+ v-if="menuItem.type == 'header'"
+ :key="menuItem.title"
+ class="gl-px-4 gl-py-2 gl-text-gray-900 gl-display-block"
+ :class="{ 'gl-pt-3!': menuItemIndex > 0 }"
+ data-testid="menu-header"
+ >
+ {{ menuItem.title }}
+ </strong>
+ <top-nav-menu-item
+ v-else
+ :key="menuItem.id"
+ :menu-item="menuItem"
+ data-testid="menu-item"
+ class="gl-w-full"
+ :class="{ 'gl-mt-1': menuItemIndex > 0 }"
+ @click="onClick(menuItem)"
+ />
+ </template>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue
index f5a6f3a9817..bc1bab62553 100644
--- a/app/assets/javascripts/notebook/cells/code.vue
+++ b/app/assets/javascripts/notebook/cells/code.vue
@@ -13,11 +13,6 @@ export default {
type: Object,
required: true,
},
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
rawInputCode() {
@@ -39,18 +34,12 @@ export default {
<template>
<div class="cell">
- <code-output
- :raw-code="rawInputCode"
- :count="cell.execution_count"
- :code-css-class="codeCssClass"
- type="input"
- />
+ <code-output :raw-code="rawInputCode" :count="cell.execution_count" type="input" />
<output-cell
v-if="hasOutput"
:count="cell.execution_count"
:outputs="outputs"
:metadata="cell.metadata"
- :code-css-class="codeCssClass"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index e1ef9aa6d79..64e801a7516 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -1,10 +1,11 @@
<script>
-import Prism from '../../lib/highlight';
+import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue';
import Prompt from '../prompt.vue';
export default {
name: 'CodeOutput',
components: {
+ CodeBlockHighlighted,
Prompt,
},
props: {
@@ -13,11 +14,6 @@ export default {
required: false,
default: 0,
},
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
type: {
type: String,
required: true,
@@ -41,22 +37,21 @@ export default {
return type.charAt(0).toUpperCase() + type.slice(1);
},
- cellCssClass() {
- return {
- [this.codeCssClass]: true,
- 'jupyter-notebook-scrolled': this.metadata.scrolled,
- };
+ maxHeight() {
+ return this.metadata.scrolled ? '20rem' : 'initial';
},
},
- mounted() {
- Prism.highlightElement(this.$refs.code);
- },
};
</script>
<template>
<div :class="type">
<prompt :type="promptType" :count="count" />
- <pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre>
+ <code-block-highlighted
+ language="python"
+ :code="code"
+ :max-height="maxHeight"
+ class="gl-border"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 8351ae7ced6..127e046b5a9 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -137,7 +137,7 @@ marked.setOptions({
export default {
components: {
- prompt: Prompt,
+ Prompt,
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index 065f5def83c..da7d83539d3 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -3,7 +3,7 @@ import Prompt from '../prompt.vue';
export default {
components: {
- prompt: Prompt,
+ Prompt,
},
props: {
count: {
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 5f7ef4a4377..88d01ffa659 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -6,11 +6,6 @@ import LatexOutput from './latex.vue';
export default {
props: {
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
count: {
type: Number,
required: false,
@@ -96,7 +91,6 @@ export default {
:index="index"
:raw-code="rawCode(output)"
:metadata="metadata"
- :code-css-class="codeCssClass"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index 44dc1856e49..df9694b7cd8 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -11,11 +11,6 @@ export default {
type: Object,
required: true,
},
- codeCssClass: {
- type: String,
- required: false,
- default: '',
- },
},
computed: {
cells() {
@@ -52,7 +47,6 @@ export default {
v-for="(cell, index) in cells"
:key="index"
:cell="cell"
- :code-css-class="codeCssClass"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js
deleted file mode 100644
index 313aeecbd51..00000000000
--- a/app/assets/javascripts/notebook/lib/highlight.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import Prism from 'prismjs';
-import 'prismjs/components/prism-python';
-import 'prismjs/themes/prism.css';
-
-export default Prism;
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index bd5945a951b..bf35d5c3b25 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -14,7 +14,7 @@ import {
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
-import markdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -25,8 +25,8 @@ import { COMMENT_FORM } from '../i18n';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
import CommentTypeDropdown from './comment_type_dropdown.vue';
-import discussionLockedWidget from './discussion_locked_widget.vue';
-import noteSignedOutWidget from './note_signed_out_widget.vue';
+import DiscussionLockedWidget from './discussion_locked_widget.vue';
+import NoteSignedOutWidget from './note_signed_out_widget.vue';
const { UNPROCESSABLE_ENTITY } = httpStatusCodes;
@@ -34,9 +34,9 @@ export default {
name: 'CommentForm',
i18n: COMMENT_FORM,
components: {
- noteSignedOutWidget,
- discussionLockedWidget,
- markdownField,
+ NoteSignedOutWidget,
+ DiscussionLockedWidget,
+ MarkdownField,
GlAlert,
GlButton,
TimelineEntryItem,
@@ -214,11 +214,7 @@ export default {
note: {
noteable_type: this.noteableType,
noteable_id: this.getNoteableData.id,
- // Internal notes were identified as `confidential`
- // before we decided to treat them as _internal_
- // so now until API is updated we need to use `confidential`
- // in request payload.
- confidential: this.noteIsInternal,
+ internal: this.noteIsInternal,
note: this.note,
},
merge_request_diff_head_sha: this.getNoteableData.diff_head_sha,
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
index 3cf47f42e0c..1b1923a90f7 100644
--- a/app/assets/javascripts/notes/components/diff_discussion_header.vue
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -4,16 +4,16 @@ import { escape } from 'lodash';
import { mapActions } from 'vuex';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-import noteEditedText from './note_edited_text.vue';
-import noteHeader from './note_header.vue';
+import NoteEditedText from './note_edited_text.vue';
+import NoteHeader from './note_header.vue';
export default {
name: 'DiffDiscussionHeader',
components: {
GlAvatar,
GlAvatarLink,
- noteEditedText,
- noteHeader,
+ NoteEditedText,
+ NoteHeader,
},
directives: {
SafeHtml,
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 6f0745d4fb0..dcbf4a0e5d3 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -59,6 +59,7 @@ export default {
<resolve-discussion-button
v-if="discussion.resolvable"
data-qa-selector="resolve_discussion_button"
+ data-testid="resolve-discussion-button"
:is-resolving="isResolving"
:button-title="resolveButtonTitle"
@onClick="$emit('resolve')"
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index eedcb0c09d4..6521b86edbb 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -1,7 +1,16 @@
<script>
-import { GlTooltipDirective, GlButton, GlButtonGroup } from '@gitlab/ui';
+import {
+ GlTooltipDirective,
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+} from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
+import { throttle } from 'lodash';
import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import discussionNavigation from '../mixins/discussion_navigation';
export default {
@@ -11,14 +20,23 @@ export default {
components: {
GlButton,
GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
},
- mixins: [discussionNavigation],
+ mixins: [glFeatureFlagsMixin(), discussionNavigation],
props: {
blocksMerge: {
type: Boolean,
required: true,
},
},
+ data() {
+ return {
+ jumpNext: throttle(this.jumpToNextDiscussion, 500),
+ jumpPrevious: throttle(this.jumpToPreviousDiscussion, 500),
+ };
+ },
computed: {
...mapGetters([
'getNoteableData',
@@ -54,27 +72,44 @@ export default {
<template>
<div
v-if="resolvableDiscussionsCount > 0"
+ id="discussionCounter"
ref="discussionCounter"
class="gl-display-flex discussions-counter"
>
<div
- class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3"
+ class="gl-display-flex gl-align-items-center gl-pl-4 gl-rounded-base gl-mr-3 gl-min-h-7"
:class="{
'gl-bg-orange-50': blocksMerge && !allResolved,
'gl-bg-gray-50': !blocksMerge || allResolved,
- 'gl-pr-4': allResolved,
'gl-pr-2': !allResolved,
}"
data-testid="discussions-counter-text"
>
<template v-if="allResolved">
{{ __('All threads resolved!') }}
+ <gl-dropdown
+ size="small"
+ category="tertiary"
+ right
+ toggle-class="btn-icon"
+ class="gl-pt-0! gl-px-2 gl-h-full gl-ml-2"
+ >
+ <template #button-content>
+ <gl-icon name="ellipsis_v" class="mr-0" />
+ </template>
+ <gl-dropdown-item
+ data-testid="toggle-all-discussions-btn"
+ @click="handleExpandDiscussions"
+ >
+ {{ toggleThreadsLabel }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
<template v-else>
{{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
<gl-button-group class="gl-ml-3">
<gl-button
- v-gl-tooltip.hover
+ v-gl-tooltip:discussionCounter.hover.bottom
:title="__('Go to previous unresolved thread')"
:aria-label="__('Go to previous unresolved thread')"
class="discussion-previous-btn gl-rounded-base! gl-px-2!"
@@ -83,10 +118,10 @@ export default {
data-track-property="click_previous_unresolved_thread_top"
icon="chevron-lg-up"
category="tertiary"
- @click="jumpToPreviousDiscussion"
+ @click="jumpPrevious"
/>
<gl-button
- v-gl-tooltip.hover
+ v-gl-tooltip:discussionCounter.hover.bottom
:title="__('Go to next unresolved thread')"
:aria-label="__('Go to next unresolved thread')"
class="discussion-next-btn gl-rounded-base! gl-px-2!"
@@ -95,29 +130,33 @@ export default {
data-track-property="click_next_unresolved_thread_top"
icon="chevron-lg-down"
category="tertiary"
- @click="jumpToNextDiscussion"
+ @click="jumpNext"
/>
+ <gl-dropdown
+ size="small"
+ category="tertiary"
+ right
+ toggle-class="btn-icon"
+ class="gl-pt-0! gl-px-2"
+ >
+ <template #button-content>
+ <gl-icon name="ellipsis_v" class="mr-0" />
+ </template>
+ <gl-dropdown-item
+ data-testid="toggle-all-discussions-btn"
+ @click="handleExpandDiscussions"
+ >
+ {{ toggleThreadsLabel }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="resolveAllDiscussionsIssuePath && !allResolved"
+ :href="resolveAllDiscussionsIssuePath"
+ >
+ {{ __('Create issue to resolve all threads') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
</gl-button-group>
</template>
</div>
- <gl-button-group>
- <gl-button
- v-gl-tooltip
- :title="toggleThreadsLabel"
- :aria-label="toggleThreadsLabel"
- class="toggle-all-discussions-btn"
- :icon="allExpanded ? 'collapse' : 'expand'"
- @click="handleExpandDiscussions"
- />
- <gl-button
- v-if="resolveAllDiscussionsIssuePath && !allResolved"
- v-gl-tooltip
- :href="resolveAllDiscussionsIssuePath"
- :title="__('Create issue to resolve all threads')"
- :aria-label="__('Create issue to resolve all threads')"
- class="new-issue-for-discussion discussion-create-issue-btn"
- icon="issue-new"
- />
- </gl-button-group>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 15887c2738d..8a42fb6bd85 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -2,6 +2,9 @@
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
DISCUSSION_FILTERS_DEFAULT_VALUE,
HISTORY_ONLY_FILTER_VALUE,
@@ -9,15 +12,25 @@ import {
DISCUSSION_TAB_LABEL,
DISCUSSION_FILTER_TYPES,
NOTE_UNDERSCORE,
+ ASC,
+ DESC,
} from '../constants';
import notesEventHub from '../event_hub';
+const SORT_OPTIONS = [
+ { key: DESC, text: __('Newest first'), cls: 'js-newest-first' },
+ { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' },
+];
+
export default {
+ SORT_OPTIONS,
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
+ LocalStorageSync,
},
+ mixins: [Tracking.mixin()],
props: {
filters: {
type: Array,
@@ -39,11 +52,24 @@ export default {
};
},
computed: {
- ...mapGetters(['getNotesDataByProp', 'timelineEnabled', 'isLoading']),
+ ...mapGetters([
+ 'getNotesDataByProp',
+ 'timelineEnabled',
+ 'isLoading',
+ 'sortDirection',
+ 'persistSortOrder',
+ 'noteableType',
+ ]),
currentFilter() {
if (!this.currentValue) return this.filters[0];
return this.filters.find((filter) => filter.value === this.currentValue);
},
+ selectedSortOption() {
+ return SORT_OPTIONS.find(({ key }) => this.sortDirection === key);
+ },
+ sortStorageKey() {
+ return `sort_direction_${this.noteableType.toLowerCase()}`;
+ },
},
created() {
if (window.mrTabs) {
@@ -69,6 +95,7 @@ export default {
'setCommentsDisabled',
'setTargetNoteHash',
'setTimelineView',
+ 'setDiscussionSortDirection',
]),
selectFilter(value, persistFilter = true) {
const filter = parseInt(value, 10);
@@ -108,31 +135,73 @@ export default {
}
return DISCUSSION_FILTER_TYPES.HISTORY;
},
+ fetchSortedDiscussions(direction) {
+ if (this.isSortDropdownItemActive(direction)) {
+ return;
+ }
+
+ this.setDiscussionSortDirection({ direction });
+ this.track('change_discussion_sort_direction', { property: direction });
+ },
+ isSortDropdownItemActive(sortDir) {
+ return sortDir === this.sortDirection;
+ },
},
};
</script>
<template>
- <gl-dropdown
+ <div
v-if="displayFilters"
- id="discussion-filter-dropdown"
- class="full-width-mobile discussion-filter-container js-discussion-filter-container"
- data-qa-selector="discussion_filter_dropdown"
- :text="currentFilter.title"
- :disabled="isLoading"
+ id="discussion-preferences"
+ data-testid="discussion-preferences"
+ class="gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
>
- <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper">
- <gl-dropdown-item
- :is-check-item="true"
- :is-checked="filter.value === currentValue"
- :class="{ 'is-active': filter.value === currentValue }"
- :data-filter-type="filterType(filter.value)"
- data-qa-selector="filter_menu_item"
- @click.prevent="selectFilter(filter.value)"
+ <local-storage-sync
+ :value="sortDirection"
+ :storage-key="sortStorageKey"
+ :persist="persistSortOrder"
+ as-string
+ @input="setDiscussionSortDirection({ direction: $event })"
+ />
+ <gl-dropdown
+ id="discussion-preferences-dropdown"
+ class="full-width-mobile"
+ data-qa-selector="discussion_preferences_dropdown"
+ text="Sort or filter"
+ :disabled="isLoading"
+ right
+ >
+ <div id="discussion-sort">
+ <gl-dropdown-item
+ v-for="{ text, key, cls } in $options.SORT_OPTIONS"
+ :key="text"
+ :class="cls"
+ is-check-item
+ :is-checked="isSortDropdownItemActive(key)"
+ @click="fetchSortedDiscussions(key)"
+ >
+ {{ text }}
+ </gl-dropdown-item>
+ </div>
+ <gl-dropdown-divider />
+ <div
+ id="discussion-filter"
+ class="discussion-filter-container js-discussion-filter-container"
>
- {{ filter.title }}
- </gl-dropdown-item>
- <gl-dropdown-divider v-if="filter.value === defaultValue" />
- </div>
- </gl-dropdown>
+ <gl-dropdown-item
+ v-for="filter in filters"
+ :key="filter.value"
+ is-check-item
+ :is-checked="filter.value === currentValue"
+ :class="{ 'is-active': filter.value === currentValue }"
+ :data-filter-type="filterType(filter.value)"
+ data-qa-selector="filter_menu_item"
+ @click.prevent="selectFilter(filter.value)"
+ >
+ {{ filter.title }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue
index c1e39f31bbb..03bdc7a2cc6 100644
--- a/app/assets/javascripts/notes/components/discussion_navigator.vue
+++ b/app/assets/javascripts/notes/components/discussion_navigator.vue
@@ -1,6 +1,7 @@
<script>
/* global Mousetrap */
import 'mousetrap';
+import { throttle } from 'lodash';
import {
keysFor,
MR_NEXT_UNRESOLVED_DISCUSSION,
@@ -11,12 +12,18 @@ import discussionNavigation from '~/notes/mixins/discussion_navigation';
export default {
mixins: [discussionNavigation],
+ data() {
+ return {
+ jumpToNext: throttle(() => this.jumpToNextDiscussion({ behavior: 'auto' }), 200),
+ jumpToPrevious: throttle(() => this.jumpToPreviousDiscussion({ behavior: 'auto' }), 200),
+ };
+ },
created() {
eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion);
},
mounted() {
- Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion);
- Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion);
+ Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNext);
+ Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPrevious);
},
beforeDestroy() {
Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION));
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index c7f293a219a..9806f8e5dc2 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -1,6 +1,6 @@
<script>
import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import createFlash from '~/flash';
@@ -11,6 +11,7 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { splitCamelCase } from '~/lib/utils/text_utility';
import ReplyButton from './note_actions/reply_button.vue';
+import TimelineEventButton from './note_actions/timeline_event_button.vue';
export default {
i18n: {
@@ -23,6 +24,7 @@ export default {
components: {
GlIcon,
ReplyButton,
+ TimelineEventButton,
GlButton,
GlDropdownItem,
UserAccessRoleBadge,
@@ -133,7 +135,8 @@ export default {
},
},
computed: {
- ...mapGetters(['getUserDataByProp', 'getNoteableData']),
+ ...mapState(['isPromoteCommentToTimelineEventInProgress']),
+ ...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
@@ -199,7 +202,7 @@ export default {
},
},
methods: {
- ...mapActions(['toggleAwardRequest']),
+ ...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
this.$emit('handleEdit');
},
@@ -292,6 +295,12 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve"
/>
+ <timeline-event-button
+ v-if="canUserAddIncidentTimelineEvents"
+ :note-id="noteId"
+ :is-promotion-in-progress="isPromoteCommentToTimelineEventInProgress"
+ @click-promote-comment-to-event="promoteCommentToTimelineEvent"
+ />
<emoji-picker
v-if="canAwardEmoji"
toggle-class="note-action-button note-emoji-button btn-icon btn-default-tertiary"
diff --git a/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue
new file mode 100644
index 00000000000..4dd0c968282
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions/timeline_event_button.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ buttonText: __('Add comment to incident timeline'),
+ addError: __('Error promoting the note to timeline event: %{error}'),
+ addGenericError: __('Something went wrong while promoting the note to timeline event.'),
+ },
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ noteId: {
+ type: [String, Number],
+ required: true,
+ },
+ isPromotionInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ handleButtonClick() {
+ this.$emit('click-promote-comment-to-event', {
+ noteId: this.noteId,
+ addError: this.$options.i18n.addError,
+ addGenericError: this.$options.i18n.addGenericError,
+ });
+ },
+ },
+};
+</script>
+<template>
+ <span v-gl-tooltip :title="$options.i18n.buttonText">
+ <gl-button
+ category="tertiary"
+ icon="clock"
+ :aria-label="$options.i18n.buttonText"
+ :disabled="isPromotionInProgress"
+ @click="handleButtonClick"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index f1c41eea428..82c125b79ce 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -8,17 +8,17 @@ import { __ } from '~/locale';
import '~/behaviors/markdown/render_gfm';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import autosave from '../mixins/autosave';
-import noteAttachment from './note_attachment.vue';
-import noteAwardsList from './note_awards_list.vue';
-import noteEditedText from './note_edited_text.vue';
-import noteForm from './note_form.vue';
+import NoteAttachment from './note_attachment.vue';
+import NoteAwardsList from './note_awards_list.vue';
+import NoteEditedText from './note_edited_text.vue';
+import NoteForm from './note_form.vue';
export default {
components: {
- noteEditedText,
- noteAwardsList,
- noteAttachment,
- noteForm,
+ NoteEditedText,
+ NoteAwardsList,
+ NoteAttachment,
+ NoteForm,
Suggestions,
},
directives: {
@@ -71,7 +71,7 @@ export default {
return this.note.note;
},
saveButtonTitle() {
- return this.note.confidential ? __('Save internal note') : __('Save comment');
+ return this.note.internal ? __('Save internal note') : __('Save comment');
},
hasSuggestion() {
return this.note.suggestions && this.note.suggestions.length;
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 03cbdf45ddd..e0c3ed0c67a 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,11 +1,11 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'EditedNoteText',
components: {
- timeAgoTooltip,
+ TimeAgoTooltip,
},
props: {
actionText: {
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 30579a8eb0d..b6ede10d02b 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -4,7 +4,7 @@ import { mapGetters, mapActions, mapState } from 'vuex';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import markdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import eventHub from '../event_hub';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
@@ -15,7 +15,7 @@ export default {
i18n: COMMENT_FORM,
name: 'NoteForm',
components: {
- markdownField,
+ MarkdownField,
CommentFieldLayout,
GlButton,
GlSprintf,
@@ -136,7 +136,7 @@ export default {
);
},
textareaPlaceholder() {
- return this.discussionNote?.confidential
+ return this.discussionNote?.internal
? this.$options.i18n.bodyPlaceholderInternal
: this.$options.i18n.bodyPlaceholder;
},
@@ -331,7 +331,7 @@ export default {
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:noteable-data="getNoteableData"
- :is-internal-note="discussion.confidential"
+ :is-internal-note="discussion.internal"
>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
@@ -423,7 +423,7 @@ export default {
category="primary"
variant="confirm"
data-qa-selector="reply_comment_button"
- class="gl-mr-3 js-vue-issue-save js-comment-button"
+ class="gl-sm-mr-3 gl-xs-mb-3 js-vue-issue-save js-comment-button"
@click="handleUpdate()"
>
{{ saveButtonTitle }}
@@ -432,7 +432,7 @@ export default {
v-if="discussion.resolvable"
category="secondary"
variant="default"
- class="gl-mr-3 js-comment-resolve-button"
+ class="gl-sm-mr-3 gl-xs-mb-3 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
>
{{ resolveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 9917249f0db..f700802d6bc 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -8,13 +8,14 @@ import {
} from '@gitlab/ui';
import { mapActions } from 'vuex';
import { __, s__ } from '~/locale';
-import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
components: {
- timeAgoTooltip,
+ TimeAgoTooltip,
GitlabTeamMemberBadge: () =>
import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
GlIcon,
@@ -26,6 +27,7 @@ export default {
SafeHtml,
GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
author: {
type: Object,
@@ -183,22 +185,35 @@ export default {
:data-user-id="author.id"
:data-username="author.username"
>
- <slot name="note-header-info"></slot>
+ <span
+ v-if="glFeatures.removeUserAttributesProjects || glFeatures.removeUserAttributesGroups"
+ class="note-header-author-name gl-font-weight-bold"
+ >
+ {{ authorName }}
+ </span>
<user-name-with-status
+ v-else
:name="authorName"
:availability="userAvailability(author)"
container-classes="note-header-author-name gl-font-weight-bold"
/>
</a>
<span
- v-if="authorStatus"
+ v-if="
+ authorStatus &&
+ !glFeatures.removeUserAttributesProjects &&
+ !glFeatures.removeUserAttributesGroups
+ "
ref="authorStatus"
v-safe-html:[$options.safeHtmlConfig]="authorStatus"
v-on="
authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
"
></span>
- <span class="text-nowrap author-username">
+ <span
+ v-if="!glFeatures.removeUserAttributesProjects && !glFeatures.removeUserAttributesGroups"
+ class="text-nowrap author-username"
+ >
<a
ref="authorUsernameLink"
class="author-username-link"
@@ -207,6 +222,7 @@ export default {
@mouseleave="handleUsernameMouseLeave"
><span class="note-headline-light">@{{ author.username }}</span>
</a>
+ <slot name="note-header-info"></slot>
<gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
</span>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index c5d174ed890..afa5e39d8b0 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -10,25 +10,25 @@ import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending';
import { s__, __, sprintf } from '~/locale';
import diffLineNoteFormMixin from '~/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
-import diffDiscussionHeader from './diff_discussion_header.vue';
-import diffWithNote from './diff_with_note.vue';
+import DiffDiscussionHeader from './diff_discussion_header.vue';
+import DiffWithNote from './diff_with_note.vue';
import DiscussionActions from './discussion_actions.vue';
import DiscussionNotes from './discussion_notes.vue';
-import noteForm from './note_form.vue';
-import noteSignedOutWidget from './note_signed_out_widget.vue';
+import NoteForm from './note_form.vue';
+import NoteSignedOutWidget from './note_signed_out_widget.vue';
export default {
name: 'NoteableDiscussion',
components: {
GlIcon,
- userAvatarLink,
- diffDiscussionHeader,
- noteSignedOutWidget,
- noteForm,
+ UserAvatarLink,
+ DiffDiscussionHeader,
+ NoteSignedOutWidget,
+ NoteForm,
DraftNote,
TimelineEntryItem,
DiscussionNotes,
@@ -96,7 +96,7 @@ export default {
return isLoggedIn();
},
commentType() {
- return this.discussion.confidential ? __('internal note') : __('comment');
+ return this.discussion.internal ? __('internal note') : __('comment');
},
autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
@@ -108,7 +108,7 @@ export default {
return this.discussion.notes.slice(0, 1)[0];
},
saveButtonTitle() {
- return this.discussion.confidential ? __('Reply internally') : __('Reply');
+ return this.discussion.internal ? __('Reply internally') : __('Reply');
},
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
@@ -120,7 +120,7 @@ export default {
return !this.shouldRenderDiffs;
},
wrapperComponent() {
- return this.shouldRenderDiffs ? diffWithNote : 'div';
+ return this.shouldRenderDiffs ? DiffWithNote : 'div';
},
wrapperComponentProps() {
if (this.shouldRenderDiffs) {
@@ -269,6 +269,7 @@ export default {
<div class="timeline-content">
<div
:data-discussion-id="discussion.id"
+ :data-discussion-resolved="discussion.resolved"
class="discussion js-discussion-container"
data-qa-selector="discussion_content"
>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 875cfff74fe..e51969f95c7 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -22,16 +22,16 @@ import {
commentLineOptions,
formatLineRange,
} from './multiline_comment_utils';
-import noteActions from './note_actions.vue';
+import NoteActions from './note_actions.vue';
import NoteBody from './note_body.vue';
-import noteHeader from './note_header.vue';
+import NoteHeader from './note_header.vue';
export default {
name: 'NoteableNote',
components: {
GlSprintf,
- noteHeader,
- noteActions,
+ NoteHeader,
+ NoteActions,
NoteBody,
TimelineEntryItem,
GlAvatarLink,
@@ -109,7 +109,7 @@ export default {
return this.note.author;
},
commentType() {
- return this.note.confidential ? __('internal note') : __('comment');
+ return this.note.internal ? __('internal note') : __('comment');
},
classNameBindings() {
return {
@@ -259,7 +259,7 @@ export default {
});
const confirmed = await confirmAction(msg, {
primaryBtnVariant: 'danger',
- primaryBtnText: this.note.confidential ? __('Delete internal note') : __('Delete comment'),
+ primaryBtnText: this.note.internal ? __('Delete internal note') : __('Delete comment'),
});
if (confirmed) {
@@ -406,7 +406,7 @@ export default {
<template>
<timeline-entry-item
:id="noteAnchorId"
- :class="{ ...classNameBindings, 'internal-note': note.confidential }"
+ :class="{ ...classNameBindings, 'internal-note': note.internal }"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
class="note note-wrapper"
@@ -440,7 +440,7 @@ export default {
</gl-avatar-link>
</div>
- <div v-else class="gl-float-left gl-pl-3 gl-mr-3 gl-md-pl-2 gl-md-pr-2">
+ <div v-else class="gl-float-left gl-pl-3 gl-md-pl-2">
<gl-avatar-link :href="author.path">
<gl-avatar
:src="author.avatar_url"
@@ -459,7 +459,7 @@ export default {
:author="author"
:created-at="note.created_at"
:note-id="note.id"
- :is-internal-note="note.confidential"
+ :is-internal-note="note.internal"
:noteable-type="noteableType"
>
<template #note-header-info>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 754c2917182..37bc8bad305 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -6,34 +6,34 @@ import { __ } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import OrderedLayout from '~/vue_shared/components/ordered_layout.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import draftNote from '~/batch_comments/components/draft_note.vue';
+import DraftNote from '~/batch_comments/components/draft_note.vue';
import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility';
-import placeholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
-import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
-import skeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
-import systemNote from '~/vue_shared/components/notes/system_note.vue';
+import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
+import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue';
+import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import * as constants from '../constants';
import eventHub from '../event_hub';
-import commentForm from './comment_form.vue';
-import discussionFilterNote from './discussion_filter_note.vue';
-import noteableDiscussion from './noteable_discussion.vue';
-import noteableNote from './noteable_note.vue';
+import CommentForm from './comment_form.vue';
+import DiscussionFilterNote from './discussion_filter_note.vue';
+import NoteableDiscussion from './noteable_discussion.vue';
+import NoteableNote from './noteable_note.vue';
import SidebarSubscription from './sidebar_subscription.vue';
export default {
name: 'NotesApp',
components: {
- noteableNote,
- noteableDiscussion,
- systemNote,
- commentForm,
- placeholderNote,
- placeholderSystemNote,
- skeletonLoadingContainer,
- discussionFilterNote,
+ NoteableNote,
+ NoteableDiscussion,
+ SystemNote,
+ CommentForm,
+ PlaceholderNote,
+ PlaceholderSystemNote,
+ SkeletonLoadingContainer,
+ DiscussionFilterNote,
OrderedLayout,
SidebarSubscription,
- draftNote,
+ DraftNote,
TimelineEntryItem,
},
mixins: [glFeatureFlagsMixin()],
diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue
index 52dadc7b4c3..9fc11ff65d5 100644
--- a/app/assets/javascripts/notes/components/sidebar_subscription.vue
+++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue
@@ -3,7 +3,7 @@ import { mapActions } from 'vuex';
import { IssuableType } from '~/issues/constants';
import { fetchPolicies } from '~/lib/graphql';
import { confidentialityQueries } from '~/sidebar/constants';
-import { defaultClient as gqlClient } from '~/sidebar/graphql';
+import { defaultClient as gqlClient } from '~/graphql_shared/issuable_client';
export default {
props: {
diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue
deleted file mode 100644
index bcc5d12b7c8..00000000000
--- a/app/assets/javascripts/notes/components/sort_discussion.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<script>
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-import { ASC, DESC } from '../constants';
-
-const SORT_OPTIONS = [
- { key: DESC, text: __('Newest first'), cls: 'js-newest-first' },
- { key: ASC, text: __('Oldest first'), cls: 'js-oldest-first' },
-];
-
-export default {
- SORT_OPTIONS,
- components: {
- GlDropdown,
- GlDropdownItem,
- LocalStorageSync,
- },
- mixins: [Tracking.mixin()],
- computed: {
- ...mapGetters(['sortDirection', 'persistSortOrder', 'noteableType']),
- selectedOption() {
- return SORT_OPTIONS.find(({ key }) => this.sortDirection === key);
- },
- dropdownText() {
- return this.selectedOption.text;
- },
- storageKey() {
- return `sort_direction_${this.noteableType.toLowerCase()}`;
- },
- },
- methods: {
- ...mapActions(['setDiscussionSortDirection']),
- fetchSortedDiscussions(direction) {
- if (this.isDropdownItemActive(direction)) {
- return;
- }
-
- this.setDiscussionSortDirection({ direction });
- this.track('change_discussion_sort_direction', { property: direction });
- },
- isDropdownItemActive(sortDir) {
- return sortDir === this.sortDirection;
- },
- },
-};
-</script>
-
-<template>
- <div
- data-testid="sort-discussion-filter"
- class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"
- >
- <local-storage-sync
- :value="sortDirection"
- :storage-key="storageKey"
- :persist="persistSortOrder"
- as-string
- @input="setDiscussionSortDirection({ direction: $event })"
- />
- <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile">
- <gl-dropdown-item
- v-for="{ text, key, cls } in $options.SORT_OPTIONS"
- :key="key"
- :class="cls"
- :is-check-item="true"
- :is-checked="isDropdownItemActive(key)"
- @click="fetchSortedDiscussions(key)"
- >
- {{ text }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/notes/components/timeline_toggle.vue b/app/assets/javascripts/notes/components/timeline_toggle.vue
index e4d89f54652..8632eea5d8e 100644
--- a/app/assets/javascripts/notes/components/timeline_toggle.vue
+++ b/app/assets/javascripts/notes/components/timeline_toggle.vue
@@ -53,7 +53,6 @@ export default {
:selected="timelineEnabled"
:title="tooltip"
:aria-label="tooltip"
- class="gl-mr-3"
@click="toggleTimeline"
/>
</template>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index a5f459c8910..88f438975f6 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -13,6 +13,7 @@ export const MERGED = 'merged';
export const ISSUE_NOTEABLE_TYPE = 'Issue';
export const EPIC_NOTEABLE_TYPE = 'Epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'MergeRequest';
+export const INCIDENT_NOTEABLE_TYPE = 'INCIDENT'; // TODO: check if value can be converted to `Incident`
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description';
@@ -31,6 +32,7 @@ export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
Epic: EPIC_NOTEABLE_TYPE,
+ Incident: INCIDENT_NOTEABLE_TYPE,
};
export const DISCUSSION_FILTER_TYPES = {
diff --git a/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql
new file mode 100644
index 00000000000..c9df9cfd6d3
--- /dev/null
+++ b/app/assets/javascripts/notes/graphql/promote_timeline_event.mutation.graphql
@@ -0,0 +1,8 @@
+mutation PromoteTimelineEvent($input: TimelineEventPromoteFromNoteInput!) {
+ timelineEventPromoteFromNote(input: $input) {
+ timelineEvent {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 19fa484d659..054a5bd36e2 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
-import notesApp from './components/notes_app.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import NotesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
-import initSortDiscussions from './sort_discussions';
import { store } from './stores';
import initTimelineToggle from './timeline';
@@ -16,7 +16,7 @@ export default () => {
el,
name: 'NotesRoot',
components: {
- notesApp,
+ NotesApp,
},
store,
data() {
@@ -40,6 +40,7 @@ export default () => {
username: parsedUserData.username,
avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url,
path: parsedUserData.path,
+ can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents),
};
}
@@ -61,6 +62,5 @@ export default () => {
});
initDiscussionFilters(store);
- initSortDiscussions(store);
initTimelineToggle(store);
};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 45df91796fc..db5f9ebf3f0 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,5 +1,5 @@
import { mapGetters, mapActions, mapState } from 'vuex';
-import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils';
+import { scrollToElementWithContext, scrollToElement, contentTop } from '~/lib/utils/common_utils';
import { updateHistory } from '~/lib/utils/url_utility';
import eventHub from '../event_hub';
@@ -7,13 +7,14 @@ import eventHub from '../event_hub';
* @param {string} selector
* @returns {boolean}
*/
-function scrollTo(selector, { withoutContext = false } = {}) {
+function scrollTo(selector, { withoutContext = false, offset = 0 } = {}) {
const el = document.querySelector(selector);
const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext;
if (el) {
scrollFunction(el, {
behavior: 'auto',
+ offset,
});
return true;
}
@@ -67,7 +68,10 @@ function diffsJump({ expandDiscussion }, id, firstNoteId) {
function discussionJump({ expandDiscussion }, id) {
const selector = `div.discussion[data-discussion-id="${id}"]`;
expandDiscussion({ discussionId: id });
- return scrollTo(selector, { withoutContext: true });
+ return scrollTo(selector, {
+ withoutContext: true,
+ offset: window.gon?.features?.movedMrSidebar ? -28 : 0,
+ });
}
/**
@@ -94,8 +98,6 @@ function jumpToDiscussion(self, discussion) {
if (activeTab === 'diffs' && isDiffDiscussion) {
diffsJump(self, id, firstNoteId);
- } else if (activeTab === 'show') {
- discussionJump(self, id);
} else {
switchToDiscussionsTabAndJumpTo(self, id);
}
@@ -105,11 +107,10 @@ function jumpToDiscussion(self, discussion) {
/**
* @param {object} self Component instance with mixin applied
* @param {function} fn Which function used to get the target discussion's id
- * @param {string} [discussionId=this.currentDiscussionId] Current discussion id, will be null if discussions have not been traversed yet
*/
-function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) {
+function handleDiscussionJump(self, fn) {
const isDiffView = window.mrTabs.currentAction === 'diffs';
- const targetId = fn(discussionId, isDiffView);
+ const targetId = fn(self.currentDiscussionId, isDiffView);
const discussion = self.getDiscussion(targetId);
const discussionFilePath = discussion?.diff_file?.file_path;
@@ -127,6 +128,70 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId)
});
}
+function getAllDiscussionElements() {
+ return Array.from(
+ document.querySelectorAll('[data-discussion-id]:not([data-discussion-resolved])'),
+ );
+}
+
+function hasReachedPageEnd() {
+ return document.body.scrollHeight <= Math.ceil(window.scrollY + window.innerHeight);
+}
+
+function findNextClosestVisibleDiscussion(discussionElements) {
+ const offsetHeight = contentTop();
+ let isActive;
+ const index = discussionElements.findIndex((element) => {
+ const { y } = element.getBoundingClientRect();
+ const visibleHorizontalOffset = Math.ceil(y) - offsetHeight;
+ // handle rect rounding errors
+ isActive = visibleHorizontalOffset < 2;
+ return visibleHorizontalOffset >= 0;
+ });
+ return [discussionElements[index], index, isActive];
+}
+
+function getNextDiscussion() {
+ const discussionElements = getAllDiscussionElements();
+ const firstDiscussion = discussionElements[0];
+ if (hasReachedPageEnd()) {
+ return firstDiscussion;
+ }
+ const [nextClosestDiscussion, index, isActive] = findNextClosestVisibleDiscussion(
+ discussionElements,
+ );
+ if (nextClosestDiscussion && !isActive) {
+ return nextClosestDiscussion;
+ }
+ const nextDiscussion = discussionElements[index + 1];
+ if (!nextClosestDiscussion || !nextDiscussion) {
+ return firstDiscussion;
+ }
+ return nextDiscussion;
+}
+
+function getPreviousDiscussion() {
+ const discussionElements = getAllDiscussionElements();
+ const lastDiscussion = discussionElements[discussionElements.length - 1];
+ const [, index] = findNextClosestVisibleDiscussion(discussionElements);
+ const previousDiscussion = discussionElements[index - 1];
+ if (previousDiscussion) {
+ return previousDiscussion;
+ }
+ return lastDiscussion;
+}
+
+function handleJumpForBothPages(getDiscussion, ctx, fn, scrollOptions) {
+ if (window.mrTabs.currentAction !== 'show') {
+ handleDiscussionJump(ctx, fn);
+ } else {
+ const discussion = getDiscussion();
+ const id = discussion.dataset.discussionId;
+ ctx.expandDiscussion({ discussionId: id });
+ scrollToElement(discussion, scrollOptions);
+ }
+}
+
export default {
computed: {
...mapGetters([
@@ -142,12 +207,22 @@ export default {
...mapActions(['expandDiscussion', 'setCurrentDiscussionId']),
...mapActions('diffs', ['scrollToFile']),
- jumpToNextDiscussion() {
- handleDiscussionJump(this, this.nextUnresolvedDiscussionId);
+ jumpToNextDiscussion(scrollOptions) {
+ handleJumpForBothPages(
+ getNextDiscussion,
+ this,
+ this.nextUnresolvedDiscussionId,
+ scrollOptions,
+ );
},
- jumpToPreviousDiscussion() {
- handleDiscussionJump(this, this.previousUnresolvedDiscussionId);
+ jumpToPreviousDiscussion(scrollOptions) {
+ handleJumpForBothPages(
+ getPreviousDiscussion,
+ this,
+ this.previousUnresolvedDiscussionId,
+ scrollOptions,
+ );
},
jumpToFirstUnresolvedDiscussion() {
@@ -157,13 +232,5 @@ export default {
})
.catch(() => {});
},
-
- /**
- * Go to the next discussion from the given discussionId
- * @param {String} discussionId The id we are jumping from
- */
- jumpToNextRelativeDiscussion(discussionId) {
- handleDiscussionJump(this, this.nextUnresolvedDiscussionId, discussionId);
- },
},
};
diff --git a/app/assets/javascripts/notes/sort_discussions.js b/app/assets/javascripts/notes/sort_discussions.js
deleted file mode 100644
index ca8df880fe4..00000000000
--- a/app/assets/javascripts/notes/sort_discussions.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import Vue from 'vue';
-import SortDiscussion from './components/sort_discussion.vue';
-
-export default (store) => {
- const el = document.getElementById('js-vue-sort-issue-discussions');
-
- if (!el) return null;
-
- return new Vue({
- el,
- name: 'SortDiscussionRoot',
- store,
- render(createElement) {
- return createElement(SortDiscussion);
- },
- });
-};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 82417c9134b..fcef26d720c 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -6,6 +6,7 @@ import createFlash from '~/flash';
import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants';
import axios from '~/lib/utils/axios_utils';
import { __, sprintf } from '~/locale';
+import toast from '~/vue_shared/plugins/global_toast';
import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql';
import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql';
@@ -18,6 +19,12 @@ import sidebarTimeTrackingEventHub from '~/sidebar/event_hub';
import TaskList from '~/task_list';
import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub';
import SidebarStore from '~/sidebar/stores/sidebar_store';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import { TYPE_NOTE } from '~/graphql_shared/constants';
+import notesEventHub from '../event_hub';
+
+import promoteTimelineEvent from '../graphql/promote_timeline_event.mutation.graphql';
+
import * as constants from '../constants';
import * as types from './mutation_types';
import * as utils from './utils';
@@ -226,6 +233,54 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes)
});
};
+export const promoteCommentToTimelineEvent = (
+ { commit },
+ { noteId, addError, addGenericError },
+) => {
+ commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, true); // Set loading state
+ return utils.gqClient
+ .mutate({
+ mutation: promoteTimelineEvent,
+ variables: {
+ input: {
+ noteId: convertToGraphQLId(TYPE_NOTE, noteId),
+ },
+ },
+ })
+ .then(({ data = {} }) => {
+ const errors = data.timelineEventPromoteFromNote?.errors;
+ if (errors.length) {
+ const errorMessage = sprintf(addError, {
+ error: errors.join('. '),
+ });
+ throw new Error(errorMessage);
+ } else {
+ notesEventHub.$emit('comment-promoted-to-timeline-event');
+ toast(__('Comment added to the timeline.'));
+ }
+ })
+ .catch((error) => {
+ const message = error.message || addGenericError;
+
+ let captureError = false;
+ let errorObj = null;
+
+ if (message === addGenericError) {
+ captureError = true;
+ errorObj = error;
+ }
+
+ createFlash({
+ message,
+ captureError,
+ error: errorObj,
+ });
+ })
+ .finally(() => {
+ commit(types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false); // Revert loading state
+ });
+};
+
export const replyToDiscussion = (
{ commit, state, getters, dispatch },
{ endpoint, data: reply },
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 1fe82d96435..6876220f75c 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -93,6 +93,13 @@ export const getUserDataByProp = (state) => (prop) => state.userData && state.us
export const descriptionVersions = (state) => state.descriptionVersions;
+export const canUserAddIncidentTimelineEvents = (state) => {
+ return (
+ state.userData.can_add_timeline_events &&
+ state.noteableData.type === constants.NOTEABLE_TYPE_MAPPING.Incident
+ );
+};
+
export const notesById = (state) =>
state.discussions.reduce((acc, note) => {
note.notes.every((n) => Object.assign(acc, { [n.id]: n }));
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index f779aad5679..7ba1f470b05 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -30,6 +30,7 @@ export default () => ({
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
+ isPromoteCommentToTimelineEventInProgress: false,
// holds endpoints and permissions provided through haml
notesData: {
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index e28a7bc5cdd..42df6bc0980 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -57,3 +57,6 @@ export const RECEIVE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DESCRIPTION_VERSION_ER
export const REQUEST_DELETE_DESCRIPTION_VERSION = 'REQUEST_DELETE_DESCRIPTION_VERSION';
export const RECEIVE_DELETE_DESCRIPTION_VERSION = 'RECEIVE_DELETE_DESCRIPTION_VERSION';
export const RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR = 'RECEIVE_DELETE_DESCRIPTION_VERSION_ERROR';
+
+// Incidents
+export const SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS = 'SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 0823eacf1b7..83c15c12eac 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -425,4 +425,7 @@ export default {
[types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](state, value) {
state.doneFetchingBatchDiscussions = value;
},
+ [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) {
+ state.isPromoteCommentToTimelineEventInProgress = value;
+ },
};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue
new file mode 100644
index 00000000000..b55204de875
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
+import {
+ NO_ARTIFACTS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
+} from '~/packages_and_registries/harbor_registry/constants';
+import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue';
+
+export default {
+ name: 'TagsList',
+ components: {
+ GlEmptyState,
+ ArtifactsListRow,
+ TagsLoader,
+ RegistryList,
+ },
+ inject: ['noContainersImage'],
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ filter: {
+ type: String,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ tags: [],
+ tagsPageInfo: {},
+ };
+ },
+ computed: {
+ hasNoTags() {
+ return this.artifacts.length === 0;
+ },
+ emptyStateTitle() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE;
+ },
+ emptyStateDescription() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : '';
+ },
+ },
+ methods: {
+ fetchNextPage() {
+ this.$emit('next-page');
+ },
+ fetchPreviousPage() {
+ this.$emit('prev-page');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-loader v-if="isLoading" />
+ <template v-else>
+ <gl-empty-state
+ v-if="hasNoTags"
+ :title="emptyStateTitle"
+ :svg-path="noContainersImage"
+ :description="emptyStateDescription"
+ class="gl-mx-auto gl-my-0"
+ />
+ <template v-else>
+ <registry-list
+ :pagination="pageInfo"
+ :items="artifacts"
+ :hidden-delete="true"
+ id-property="name"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ >
+ <template #default="{ item }">
+ <artifacts-list-row :artifact="item" />
+ </template>
+ </registry-list>
+ </template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue
new file mode 100644
index 00000000000..b489f126f75
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue
@@ -0,0 +1,133 @@
+<script>
+import { GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ NOT_AVAILABLE_TEXT,
+ NOT_AVAILABLE_SIZE,
+} from '~/packages_and_registries/harbor_registry/constants';
+import { artifactPullCommand } from '~/packages_and_registries/harbor_registry/utils';
+
+export default {
+ name: 'TagsListRow',
+ components: {
+ GlSprintf,
+ GlIcon,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['repositoryUrl', 'harborIntegrationProjectName'],
+ props: {
+ artifact: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ digestLabel: DIGEST_LABEL,
+ createdAtLabel: CREATED_AT_LABEL,
+ },
+ computed: {
+ formattedSize() {
+ return this.artifact.size
+ ? numberToHumanSize(Number(this.artifact.size))
+ : NOT_AVAILABLE_SIZE;
+ },
+ tagsCountText() {
+ const count = this.artifact?.tags.length ? this.artifact?.tags.length : 0;
+
+ return n__('%d tag', '%d tags', count);
+ },
+ shortDigest() {
+ // remove sha256: from the string, and show only the first 7 char
+ const PREFIX_LENGTH = 'sha256:'.length;
+ const DIGEST_LENGTH = 7;
+ return (
+ this.artifact.digest?.substring(PREFIX_LENGTH, PREFIX_LENGTH + DIGEST_LENGTH) ??
+ NOT_AVAILABLE_TEXT
+ );
+ },
+ getPullCommand() {
+ if (this.artifact?.digest) {
+ const { image } = this.$route.params;
+ return artifactPullCommand({
+ digest: this.artifact.digest,
+ imageName: image,
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ });
+ }
+
+ return '';
+ },
+ linkTo() {
+ const { project, image } = this.$route.params;
+
+ return { name: 'tags', params: { project, image, digest: this.artifact.digest } };
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs">
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center">
+ <router-link
+ class="gl-text-body gl-font-weight-bold gl-word-break-all"
+ data-testid="name"
+ :to="linkTo"
+ >
+ {{ artifact.digest }}
+ </router-link>
+ <clipboard-button
+ v-if="getPullCommand"
+ :title="getPullCommand"
+ :text="getPullCommand"
+ category="tertiary"
+ />
+ </div>
+ </template>
+
+ <template #left-secondary>
+ <span class="gl-mr-3" data-testid="size">
+ {{ formattedSize }}
+ </span>
+ <span id="tagsCount" class="gl-display-flex gl-align-items-center" data-testid="tags-count">
+ <gl-icon name="tag" class="gl-mr-2" />
+ {{ tagsCountText }}
+ </span>
+ </template>
+ <template #right-primary>
+ <span data-testid="time">
+ <gl-sprintf :message="$options.i18n.createdAtLabel">
+ <template #timeInfo>
+ <time-ago-tooltip :time="artifact.pushTime" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-secondary>
+ <span data-testid="digest">
+ <gl-sprintf :message="$options.i18n.digestLabel">
+ <template #imageId>{{ shortDigest }}</template>
+ </gl-sprintf>
+ </span>
+ <clipboard-button
+ v-if="artifact.digest"
+ :title="artifact.digest"
+ :text="artifact.digest"
+ category="tertiary"
+ />
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue
new file mode 100644
index 00000000000..bfb097601d5
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue
@@ -0,0 +1,47 @@
+<script>
+import { isEmpty } from 'lodash';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import {
+ ROOT_IMAGE_TEXT,
+ EMPTY_ARTIFACTS_LABEL,
+ artifactsLabel,
+} from '~/packages_and_registries/harbor_registry/constants/index';
+
+export default {
+ name: 'DetailsHeader',
+ components: { TitleArea, MetadataItem },
+ mixins: [timeagoMixin],
+ props: {
+ imagesDetail: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ artifactCountText() {
+ if (isEmpty(this.imagesDetail)) {
+ return EMPTY_ARTIFACTS_LABEL;
+ }
+ return artifactsLabel(this.imagesDetail.artifactCount);
+ },
+ repositoryFullName() {
+ return this.imagesDetail.name || ROOT_IMAGE_TEXT;
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area>
+ <template #title>
+ <span data-testid="title">
+ {{ repositoryFullName }}
+ </span>
+ </template>
+ <template #metadata-tags-count>
+ <metadata-item icon="package" :text="artifactCountText" data-testid="artifacts-count" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue
new file mode 100644
index 00000000000..ac1df5cf93f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue
@@ -0,0 +1,68 @@
+<script>
+// Since app/assets/javascripts/packages_and_registries/shared/components/registry_breadcrumb.vue
+// can only handle two levels of breadcrumbs, but we have three levels here.
+// So we extended the registry_breadcrumb.vue component with harbor_registry_breadcrumb.vue to support multiple levels of breadcrumbs
+import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
+import { isArray, last } from 'lodash';
+
+export default {
+ components: {
+ GlBreadcrumb,
+ GlIcon,
+ },
+ computed: {
+ rootRoute() {
+ return this.$router.options.routes.find((r) => r.meta.root);
+ },
+ isRootRoute() {
+ return this.$route.name === this.rootRoute.name;
+ },
+ currentRoute() {
+ const currentName = this.$route.meta.nameGenerator();
+ const currentHref = this.$route.meta.hrefGenerator();
+ let routeInfoList = [
+ {
+ text: currentName,
+ to: currentHref,
+ },
+ ];
+
+ if (isArray(currentName) && isArray(currentHref)) {
+ routeInfoList = currentName.map((name, index) => {
+ return {
+ text: name,
+ to: currentHref[index],
+ };
+ });
+ }
+
+ return routeInfoList;
+ },
+ isLoaded() {
+ return this.isRootRoute || last(this.currentRoute).text;
+ },
+ allCrumbs() {
+ let crumbs = [
+ {
+ text: this.rootRoute.meta.nameGenerator(),
+ to: this.rootRoute.path,
+ },
+ ];
+ if (!this.isRootRoute) {
+ crumbs = crumbs.concat(this.currentRoute);
+ }
+ return crumbs;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-breadcrumb :key="isLoaded" :items="allCrumbs">
+ <template #separator>
+ <span class="gl-mx-n5">
+ <gl-icon name="chevron-lg-right" :size="8" />
+ </span>
+ </template>
+ </gl-breadcrumb>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
index 086b9c73d75..db66ebef937 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue
@@ -5,6 +5,7 @@ import {
HARBOR_REGISTRY_TITLE,
LIST_INTRO_TEXT,
imagesCountInfoText,
+ HARBOR_REGISTRY_HELP_PAGE_PATH,
} from '~/packages_and_registries/harbor_registry/constants';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
@@ -20,11 +21,6 @@ export default {
default: 0,
required: false,
},
- helpPagePath: {
- type: String,
- default: '',
- required: false,
- },
metadataLoading: {
type: Boolean,
required: false,
@@ -32,7 +28,7 @@ export default {
},
},
i18n: {
- HARBOR_REGISTRY_TITLE,
+ harborRegistryTitle: HARBOR_REGISTRY_TITLE,
},
computed: {
imagesCountText() {
@@ -40,7 +36,7 @@ export default {
return sprintf(pluralisedString, { count: this.imagesCount });
},
infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
+ return [{ text: LIST_INTRO_TEXT, link: HARBOR_REGISTRY_HELP_PAGE_PATH }];
},
},
};
@@ -48,7 +44,7 @@ export default {
<template>
<title-area
- :title="$options.i18n.HARBOR_REGISTRY_TITLE"
+ :title="$options.i18n.harborRegistryTitle"
:info-messages="infoMessages"
:metadata-loading="metadataLoading"
>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
index 258472fe16e..bfe0c250dd9 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue
@@ -1,15 +1,14 @@
<script>
-import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { GlIcon, GlSkeletonLoader } from '@gitlab/ui';
import { n__ } from '~/locale';
-
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { getNameFromParams } from '~/packages_and_registries/harbor_registry/utils';
export default {
name: 'HarborListRow',
components: {
ClipboardButton,
- GlSprintf,
GlIcon,
ListItem,
GlSkeletonLoader,
@@ -26,19 +25,18 @@ export default {
},
},
computed: {
- id() {
- return this.item.id;
+ linkTo() {
+ const { projectName, imageName } = getNameFromParams(this.item.name);
+
+ return { name: 'details', params: { project: projectName, image: imageName } };
},
artifactCountText() {
return n__(
- 'HarborRegistry|%{count} Tag',
- 'HarborRegistry|%{count} Tags',
+ 'HarborRegistry|%d artifact',
+ 'HarborRegistry|%d artifacts',
this.item.artifactCount,
);
},
- imageName() {
- return this.item.name;
- },
},
};
</script>
@@ -50,9 +48,9 @@ export default {
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
data-qa-selector="registry_image_content"
- :to="{ name: 'details', params: { id } }"
+ :to="linkTo"
>
- {{ imageName }}
+ {{ item.name }}
</router-link>
<clipboard-button
v-if="item.location"
@@ -63,13 +61,9 @@ export default {
</template>
<template #left-secondary>
<template v-if="!metadataLoading">
- <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
- <gl-icon name="tag" class="gl-mr-2" />
- <gl-sprintf :message="artifactCountText">
- <template #count>
- {{ item.artifactCount }}
- </template>
- </gl-sprintf>
+ <span class="gl-display-flex gl-align-items-center" data-testid="artifacts-count">
+ <gl-icon name="package" class="gl-mr-2" />
+ {{ artifactCountText }}
</span>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue
new file mode 100644
index 00000000000..e7f6989c49f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_header.vue
@@ -0,0 +1,54 @@
+<script>
+import { isEmpty } from 'lodash';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import {
+ EMPTY_TAG_LABEL,
+ tagsCountText,
+} from '~/packages_and_registries/harbor_registry/constants';
+
+export default {
+ name: 'TagsHeader',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ artifactDetail: {
+ type: Object,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ tagsLoading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ tagCountText() {
+ if (isEmpty(this.pageInfo)) {
+ return EMPTY_TAG_LABEL;
+ }
+ return tagsCountText(this.pageInfo.total);
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area :metadata-loading="tagsLoading">
+ <template #title>
+ <span class="gl-word-break-all" data-testid="title">
+ {{ artifactDetail.digest }}
+ </span>
+ </template>
+ <template #metadata-tags-count>
+ <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue
new file mode 100644
index 00000000000..b34d3a950c0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
+import TagsListRow from '~/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue';
+import {
+ NO_ARTIFACTS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_TITLE,
+ NO_TAGS_MATCHING_FILTERS_DESCRIPTION,
+} from '~/packages_and_registries/harbor_registry/constants';
+
+export default {
+ name: 'TagsList',
+ components: {
+ GlEmptyState,
+ TagsLoader,
+ TagsListRow,
+ RegistryList,
+ },
+ inject: ['noContainersImage'],
+ props: {
+ tags: {
+ type: Array,
+ required: true,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ isLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ hasNoTags() {
+ return this.tags.length === 0;
+ },
+ emptyStateTitle() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE;
+ },
+ emptyStateDescription() {
+ return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : '';
+ },
+ },
+ methods: {
+ fetchNextPage() {
+ this.$emit('next-page');
+ },
+ fetchPreviousPage() {
+ this.$emit('prev-page');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-loader v-if="isLoading" />
+ <gl-empty-state
+ v-else-if="hasNoTags"
+ :title="emptyStateTitle"
+ :svg-path="noContainersImage"
+ :description="emptyStateDescription"
+ class="gl-mx-auto gl-my-0"
+ />
+ <registry-list
+ v-else
+ :pagination="pageInfo"
+ :items="tags"
+ hidden-delete
+ id-property="name"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ >
+ <template #default="{ item }">
+ <tags-list-row :tag="item" />
+ </template>
+ </registry-list>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue
new file mode 100644
index 00000000000..63e046c1abc
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/tags/tags_list_row.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { CREATED_AT_LABEL } from '~/packages_and_registries/harbor_registry/constants';
+import { tagPullCommand } from '~/packages_and_registries/harbor_registry/utils';
+
+export default {
+ name: 'TagsListRow',
+ components: {
+ GlSprintf,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ },
+ inject: ['harborIntegrationProjectName', 'repositoryUrl'],
+ props: {
+ tag: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ createdAtLabel: CREATED_AT_LABEL,
+ },
+ methods: {
+ getPullCommand(tagName) {
+ if (tagName) {
+ const { image } = this.$route.params;
+
+ return tagPullCommand({
+ imageName: image,
+ tag: tagName,
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ });
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs">
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center">
+ <div
+ data-testid="name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ >
+ {{ tag.name }}
+ </div>
+ <clipboard-button
+ :title="getPullCommand(tag.name)"
+ :text="getPullCommand(tag.name)"
+ category="tertiary"
+ />
+ </div>
+ </template>
+
+ <template #right-primary>
+ <span data-testid="time">
+ <gl-sprintf :message="$options.i18n.createdAtLabel">
+ <template #timeInfo>
+ <time-ago-tooltip :time="tag.pushTime" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
index a7891821755..7f3c3da02b0 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js
@@ -1,4 +1,5 @@
import { s__, __ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
export const ROOT_IMAGE_TEXT = s__('HarborRegistry|Root image');
export const NAME_SORT_FIELD = { orderBy: 'NAME', label: __('Name') };
@@ -16,14 +17,8 @@ export const SORT_FIELD_MAPPING = {
CREATED: CREATED_SORT_FIELD_KEY,
};
-/* eslint-disable @gitlab/require-i18n-strings */
-export const dockerBuildCommand = (repositoryUrl) => {
- return `docker build -t ${repositoryUrl} .`;
-};
-export const dockerPushCommand = (repositoryUrl) => {
- return `docker push ${repositoryUrl}`;
-};
-export const dockerLoginCommand = (registryHostUrlWithPort) => {
- return `docker login ${registryHostUrlWithPort}`;
-};
-/* eslint-enable @gitlab/require-i18n-strings */
+export const DEFAULT_PER_PAGE = 10;
+
+export const HARBOR_REGISTRY_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/harbor_container_registry/index',
+);
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
index b62c51bd208..5b4b85ec31e 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js
@@ -1,22 +1,10 @@
-import { s__, __ } from '~/locale';
+import { s__, __, n__ } from '~/locale';
-export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}');
-
-export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
- 'HarborRegistry|The image repository could not be found.',
-);
-
-export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
- 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
+export const FETCH_ARTIFACT_LIST_ERROR_MESSAGE = s__(
+ 'HarborRegistry|Something went wrong while fetching the artifact list.',
);
-export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags');
-
-export const NO_TAGS_MESSAGE = s__(
- `HarborRegistry|The last tag related to this image was recently removed.
-This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
-If you have any questions, contact your administrator.`,
-);
+export const NO_ARTIFACTS_TITLE = s__('HarborRegistry|This image has no artifacts');
export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results');
@@ -26,14 +14,24 @@ export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__(
export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}');
export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}');
-export const PUBLISHED_DETAILS_ROW_TEXT = s__(
- 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}',
-);
-export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}');
-export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}');
-export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
- 'HarborRegistry|Invalid tag: missing manifest digest',
-);
export const NOT_AVAILABLE_TEXT = __('Not applicable.');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
+
+export const TOKEN_TYPE_TAG_NAME = 'tag_name';
+
+export const FETCH_TAGS_ERROR_MESSAGE = s__(
+ 'HarborRegistry|Something went wrong while fetching the tags.',
+);
+
+export const TAG_LABEL = s__('HarborRegistry|Tag');
+export const EMPTY_TAG_LABEL = s__('HarborRegistry|-- tags');
+
+export const EMPTY_ARTIFACTS_LABEL = s__('HarborRegistry|-- artifacts');
+export const artifactsLabel = (count) => {
+ return n__('%d artifact', '%d artifacts', count);
+};
+
+export const tagsCountText = (count) => {
+ return n__('%d tag', '%d tags', count);
+};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
index a6cd59918ff..33950993125 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js
@@ -7,8 +7,13 @@ export const HARBOR_REGISTRY_TITLE = s__('HarborRegistry|Harbor Registry');
export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection error');
export const CONNECTION_ERROR_MESSAGE = s__(
- `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
+ `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the documentation%{docLinkEnd}.`,
);
+
+export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
+ 'HarborRegistry|Something went wrong while fetching the repository list.',
+);
+
export const LIST_INTRO_TEXT = s__(
`HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
);
@@ -26,6 +31,13 @@ export const EMPTY_RESULT_MESSAGE = s__(
'HarborRegistry|To widen your search, change or remove the filters above.',
);
+export const EMPTY_IMAGES_TITLE = s__(
+ 'HarborRegistry|There are no harbor images stored for this project',
+);
+export const EMPTY_IMAGES_MESSAGE = s__(
+ 'HarborRegistry|With the Harbor Registry, every project can connect to a harbor space to store its Docker images.',
+);
+
export const SORT_FIELDS = [
{ orderBy: 'UPDATED', label: __('Updated') },
{ orderBy: 'CREATED', label: __('Created') },
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
index ecfefead61a..6185e4c7bc6 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js
@@ -3,14 +3,8 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import PerformancePlugin from '~/performance/vue_performance_plugin';
import Translate from '~/vue_shared/translate';
-import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue';
+import RegistryBreadcrumb from '~/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue';
import { renderBreadcrumb } from '~/packages_and_registries/shared/utils';
-import { helpPagePath } from '~/helpers/help_page_helper';
-import {
- dockerBuildCommand,
- dockerPushCommand,
- dockerLoginCommand,
-} from '~/packages_and_registries/harbor_registry/constants';
import createRouter from './router';
import HarborRegistryExplorer from './pages/index.vue';
@@ -35,13 +29,27 @@ export default (id) => {
return null;
}
- const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset;
+ const {
+ endpoint,
+ connectionError,
+ invalidPathError,
+ isGroupPage,
+ noContainersImage,
+ containersErrorImage,
+ repositoryUrl,
+ harborIntegrationProjectName,
+ projectName,
+ } = el.dataset;
const breadCrumbState = Vue.observable({
name: '',
+ href: '',
updateName(value) {
this.name = value;
},
+ updateHref(value) {
+ this.href = value;
+ },
});
const router = createRouter(endpoint, breadCrumbState);
@@ -53,16 +61,15 @@ export default (id) => {
provide() {
return {
breadCrumbState,
- config: {
- ...config,
- connectionError: parseBoolean(connectionError),
- invalidPathError: parseBoolean(invalidPathError),
- isGroupPage: parseBoolean(isGroupPage),
- helpPagePath: helpPagePath('user/packages/container_registry/index'),
- },
- dockerBuildCommand: dockerBuildCommand(config.repositoryUrl),
- dockerPushCommand: dockerPushCommand(config.repositoryUrl),
- dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort),
+ endpoint,
+ connectionError: parseBoolean(connectionError),
+ invalidPathError: parseBoolean(invalidPathError),
+ isGroupPage: parseBoolean(isGroupPage),
+ repositoryUrl,
+ harborIntegrationProjectName,
+ projectName,
+ containersErrorImage,
+ noContainersImage,
};
},
render(createElement) {
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js
deleted file mode 100644
index 50c7df1483c..00000000000
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js
+++ /dev/null
@@ -1,200 +0,0 @@
-const mockRequestFn = (mockData) => {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(mockData);
- }, 2000);
- });
-};
-export const harborListResponse = () => {
- const harborListResponseData = {
- repositories: [
- {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 25,
- name: 'shao/flinkx',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- },
- {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 26,
- name: 'shao/flinkx1',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- },
- {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 27,
- name: 'shao/flinkx2',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- },
- ],
- totalCount: 3,
- pageInfo: {
- hasNextPage: false,
- hasPreviousPage: false,
- },
- };
-
- return mockRequestFn(harborListResponseData);
-};
-
-export const getHarborRegistryImageDetail = () => {
- const harborRegistryImageDetailData = {
- artifactCount: 1,
- creationTime: '2022-03-02T06:35:53.205Z',
- id: 25,
- name: 'shao/flinkx',
- projectId: 21,
- pullCount: 0,
- updateTime: '2022-03-02T06:35:53.205Z',
- location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
- tagsCount: 10,
- };
-
- return mockRequestFn(harborRegistryImageDetailData);
-};
-
-export const harborTagsResponse = () => {
- const harborTagsResponseData = {
- tags: [
- {
- digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
- name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
- revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255',
- shortRevision: 'f53bde3d4',
- createdAt: '2022-03-02T23:59:05+00:00',
- totalSize: '6623124',
- },
- {
- digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
- name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
- revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e',
- shortRevision: 'e1fe52d8b',
- createdAt: '2022-02-10T01:09:56+00:00',
- totalSize: '920760',
- },
- {
- digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
- name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
- revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f',
- shortRevision: 'c72770c6e',
- createdAt: '2021-12-22T04:48:48+00:00',
- totalSize: '48609053',
- },
- {
- digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
- name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
- revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a',
- shortRevision: '1ac2a4319',
- createdAt: '2022-03-09T11:02:27+00:00',
- totalSize: '35141894',
- },
- {
- digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
- name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
- revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c',
- shortRevision: 'cf8fee086',
- createdAt: '2022-01-21T11:31:43+00:00',
- totalSize: '48716070',
- },
- {
- digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
- name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
- revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15',
- shortRevision: '1a4b48198',
- createdAt: '2022-01-21T11:31:51+00:00',
- totalSize: '6623127',
- },
- {
- digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
- name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
- revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61',
- shortRevision: '03e2e2777',
- createdAt: '2022-03-02T23:58:20+00:00',
- totalSize: '911377',
- },
- {
- digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
- name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
- revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012',
- shortRevision: '350e78d60',
- createdAt: '2022-01-19T13:49:14+00:00',
- totalSize: '48710241',
- },
- {
- digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
- name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
- revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18',
- shortRevision: '76038370b',
- createdAt: '2022-01-24T12:56:22+00:00',
- totalSize: '280065',
- },
- {
- digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07',
- location:
- 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
- path:
- 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
- name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
- revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f',
- shortRevision: '3d4b49a7b',
- createdAt: '2022-02-17T17:37:52+00:00',
- totalSize: '48655767',
- },
- ],
- totalCount: 10,
- pageInfo: {
- hasNextPage: false,
- hasPreviousPage: true,
- },
- };
-
- return mockRequestFn(harborTagsResponseData);
-};
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
index e69de29bb2d..c6ab746b9f4 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue
@@ -0,0 +1,156 @@
+<script>
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import {
+ NAME_SORT_FIELD,
+ ROOT_IMAGE_TEXT,
+ DEFAULT_PER_PAGE,
+ FETCH_ARTIFACT_LIST_ERROR_MESSAGE,
+ TOKEN_TYPE_TAG_NAME,
+ TAG_LABEL,
+} from '~/packages_and_registries/harbor_registry/constants/index';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { createAlert } from '~/flash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue';
+import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
+import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue';
+import {
+ extractSortingDetail,
+ parseFilter,
+ formatPagination,
+} from '~/packages_and_registries/harbor_registry/utils';
+import { getHarborArtifacts } from '~/rest_api';
+
+export default {
+ name: 'HarborDetailsPage',
+ components: {
+ ArtifactsList,
+ TagsLoader,
+ DetailsHeader,
+ PersistedSearch,
+ },
+ inject: ['endpoint', 'breadCrumbState'],
+ searchConfig: { nameSortFields: [NAME_SORT_FIELD] },
+ tokens: [
+ {
+ type: TOKEN_TYPE_TAG_NAME,
+ icon: 'tag',
+ title: TAG_LABEL,
+ unique: true,
+ token: GlFilteredSearchToken,
+ operators: OPERATOR_IS_ONLY,
+ },
+ ],
+ data() {
+ return {
+ artifactsList: [],
+ pageInfo: {},
+ mutationLoading: false,
+ deleteAlertType: null,
+ isLoading: true,
+ filterString: '',
+ sorting: null,
+ };
+ },
+ computed: {
+ currentPage() {
+ return this.pageInfo.page || 1;
+ },
+ imagesDetail() {
+ return {
+ name: this.fullName,
+ artifactCount: this.pageInfo?.total || 0,
+ };
+ },
+ fullName() {
+ const { project, image } = this.$route.params;
+
+ if (project && image) {
+ return `${project}/${image}`;
+ }
+ return '';
+ },
+ },
+ mounted() {
+ this.updateBreadcrumb();
+ },
+ methods: {
+ updateBreadcrumb() {
+ const name = this.fullName || ROOT_IMAGE_TEXT;
+ this.breadCrumbState.updateName(name);
+ this.breadCrumbState.updateHref(this.$route.path);
+ },
+ handleSearchUpdate({ sort, filters }) {
+ this.sorting = sort;
+ this.filterString = parseFilter(filters, 'digest');
+
+ this.fetchArtifacts(1);
+ },
+ fetchPrevPage() {
+ const prevPageNum = this.currentPage - 1;
+ this.fetchArtifacts(prevPageNum);
+ },
+ fetchNextPage() {
+ const nextPageNum = this.currentPage + 1;
+ this.fetchArtifacts(nextPageNum);
+ },
+ fetchArtifacts(requestPage) {
+ this.isLoading = true;
+
+ const { orderBy, sort } = extractSortingDetail(this.sorting);
+ const sortOptions = `${orderBy} ${sort}`;
+
+ const { image } = this.$route.params;
+
+ const params = {
+ requestPath: this.endpoint,
+ repoName: image,
+ limit: DEFAULT_PER_PAGE,
+ page: requestPage,
+ sort: sortOptions,
+ search: this.filterString,
+ };
+
+ getHarborArtifacts(params)
+ .then((res) => {
+ this.pageInfo = formatPagination(res.headers);
+
+ this.artifactsList = (res?.data || []).map((artifact) => {
+ return convertObjectPropsToCamelCase(artifact);
+ });
+ })
+ .catch(() => {
+ createAlert({ message: FETCH_ARTIFACT_LIST_ERROR_MESSAGE });
+ })
+ .finally(() => {
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-my-3">
+ <details-header :images-detail="imagesDetail" />
+ <persisted-search
+ class="gl-mb-5"
+ :sortable-fields="$options.searchConfig.nameSortFields"
+ :default-order="$options.searchConfig.nameSortFields[0].orderBy"
+ default-sort="asc"
+ :tokens="$options.tokens"
+ @update="handleSearchUpdate"
+ />
+ <tags-loader v-if="isLoading" />
+ <artifacts-list
+ v-else
+ :filter="filterString"
+ :is-loading="isLoading"
+ :artifacts="artifactsList"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
new file mode 100644
index 00000000000..1323d347d10
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/harbor_tags.vue
@@ -0,0 +1,103 @@
+<script>
+import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue';
+import TagsList from '~/packages_and_registries/harbor_registry/components/tags/tags_list.vue';
+import { getHarborTags } from '~/rest_api';
+import { FETCH_TAGS_ERROR_MESSAGE } from '~/packages_and_registries/harbor_registry/constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createAlert } from '~/flash';
+import { formatPagination } from '~/packages_and_registries/harbor_registry/utils';
+
+export default {
+ name: 'HarborTagsPage',
+ components: {
+ TagsHeader,
+ TagsList,
+ },
+ inject: ['endpoint', 'breadCrumbState'],
+ data() {
+ return {
+ tagsLoading: false,
+ pageInfo: {},
+ tags: [],
+ };
+ },
+ computed: {
+ currentPage() {
+ return this.pageInfo?.page || 1;
+ },
+ artifactDetail() {
+ const { project, image, digest } = this.$route.params;
+
+ return {
+ project,
+ image,
+ digest,
+ };
+ },
+ },
+ mounted() {
+ this.updateBreadcrumb();
+ this.fetchTagsData();
+ },
+ methods: {
+ updateBreadcrumb() {
+ const artifactPath = `${this.artifactDetail.project}/${this.artifactDetail.image}`;
+ const nameList = [artifactPath, this.artifactDetail.digest];
+ const hrefList = [`/${artifactPath}`, this.$route.path];
+
+ this.breadCrumbState.updateName(nameList);
+ this.breadCrumbState.updateHref(hrefList);
+ },
+ fetchPrevPage() {
+ const prevPageNum = this.currentPage - 1;
+ this.fetchTagsData(prevPageNum);
+ },
+ fetchNextPage() {
+ const nextPageNum = this.currentPage + 1;
+ this.fetchTagsData(nextPageNum);
+ },
+ fetchTagsData(requestPage) {
+ this.tagsLoading = true;
+
+ const params = {
+ page: requestPage,
+ requestPath: this.endpoint,
+ repoName: this.artifactDetail.image,
+ digest: this.artifactDetail.digest,
+ };
+
+ getHarborTags(params)
+ .then((res) => {
+ this.pageInfo = formatPagination(res.headers);
+
+ this.tags = (res?.data || []).map((tagInfo) => {
+ return convertObjectPropsToCamelCase(tagInfo);
+ });
+ })
+ .catch(() => {
+ createAlert({ message: FETCH_TAGS_ERROR_MESSAGE });
+ })
+ .finally(() => {
+ this.tagsLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-header
+ :artifact-detail="artifactDetail"
+ :page-info="pageInfo"
+ :tags-loading="tagsLoading"
+ />
+ <tags-list
+ :tags="tags"
+ :is-loading="tagsLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
index 9c69059c968..931a99649cb 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue
@@ -1,19 +1,32 @@
<script>
import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
-import { escape } from 'lodash';
import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue';
import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue';
-import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import {
+ extractSortingDetail,
+ formatPagination,
+ parseFilter,
+ dockerBuildCommand,
+ dockerPushCommand,
+ dockerLoginCommand,
+} from '~/packages_and_registries/harbor_registry/utils';
+import { createAlert } from '~/flash';
import {
SORT_FIELDS,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
+ DEFAULT_PER_PAGE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+ EMPTY_IMAGES_TITLE,
+ EMPTY_IMAGES_MESSAGE,
+ HARBOR_REGISTRY_HELP_PAGE_PATH,
} from '~/packages_and_registries/harbor_registry/constants';
import Tracking from '~/tracking';
-import { harborListResponse } from '../mock_api';
+import { getHarborRepositoriesList } from '~/rest_api';
export default {
name: 'HarborListPage',
@@ -31,19 +44,28 @@ export default {
),
},
mixins: [Tracking.mixin()],
- inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ inject: [
+ 'endpoint',
+ 'repositoryUrl',
+ 'harborIntegrationProjectName',
+ 'projectName',
+ 'isGroupPage',
+ 'connectionError',
+ 'invalidPathError',
+ 'containersErrorImage',
+ 'noContainersImage',
+ ],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
i18n: {
- CONNECTION_ERROR_TITLE,
- CONNECTION_ERROR_MESSAGE,
- EMPTY_RESULT_TITLE,
- EMPTY_RESULT_MESSAGE,
+ connectionErrorTitle: CONNECTION_ERROR_TITLE,
+ connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
},
searchConfig: SORT_FIELDS,
+ helpPagePath: HARBOR_REGISTRY_HELP_PAGE_PATH,
data() {
return {
images: [],
@@ -56,42 +78,81 @@ export default {
};
},
computed: {
+ dockerCommand() {
+ return {
+ build: dockerBuildCommand({
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ projectName: this.projectName,
+ }),
+ push: dockerPushCommand({
+ repositoryUrl: this.repositoryUrl,
+ harborProjectName: this.harborIntegrationProjectName,
+ projectName: this.projectName,
+ }),
+ login: dockerLoginCommand(this.repositoryUrl),
+ };
+ },
showCommands() {
- return !this.isLoading && !this.config?.isGroupPage && this.images?.length;
+ return !this.isLoading && !this.isGroupPage && this.images?.length;
},
showConnectionError() {
- return this.config.connectionError || this.config.invalidPathError;
+ return this.connectionError || this.invalidPathError;
+ },
+ currentPage() {
+ return this.pageInfo.page || 1;
+ },
+ emptyStateTexts() {
+ return {
+ title: this.name ? EMPTY_RESULT_TITLE : EMPTY_IMAGES_TITLE,
+ message: this.name ? EMPTY_RESULT_MESSAGE : EMPTY_IMAGES_MESSAGE,
+ };
},
},
methods: {
- fetchHarborImages() {
- // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
+ fetchHarborImages(requestPage) {
this.isLoading = true;
- harborListResponse()
+ const { orderBy, sort } = extractSortingDetail(this.sorting);
+ const sortOptions = `${orderBy} ${sort}`;
+
+ const params = {
+ requestPath: this.endpoint,
+ limit: DEFAULT_PER_PAGE,
+ search: this.name,
+ page: requestPage,
+ sort: sortOptions,
+ };
+
+ getHarborRepositoriesList(params)
.then((res) => {
- this.images = res?.repositories || [];
- this.totalCount = res?.totalCount || 0;
- this.pageInfo = res?.pageInfo || {};
+ this.images = (res?.data || []).map((item) => {
+ return convertObjectPropsToCamelCase(item);
+ });
+ const pagination = formatPagination(res.headers);
+
+ this.totalCount = pagination?.total || 0;
+ this.pageInfo = pagination;
+
this.isLoading = false;
})
- .catch(() => {});
+ .catch(() => {
+ createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ });
},
handleSearchUpdate({ sort, filters }) {
this.sorting = sort;
+ this.name = parseFilter(filters, 'name');
- const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM);
- this.name = escape(search?.value?.data);
-
- this.fetchHarborImages();
+ this.fetchHarborImages(1);
},
fetchPrevPage() {
- // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
- this.fetchHarborImages();
+ const prevPageNum = this.currentPage - 1;
+ this.fetchHarborImages(prevPageNum);
},
fetchNextPage() {
- // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777
- this.fetchHarborImages();
+ const nextPageNum = this.currentPage + 1;
+ this.fetchHarborImages(nextPageNum);
},
},
};
@@ -101,14 +162,14 @@ export default {
<div>
<gl-empty-state
v-if="showConnectionError"
- :title="$options.i18n.CONNECTION_ERROR_TITLE"
- :svg-path="config.containersErrorImage"
+ :title="$options.i18n.connectionErrorTitle"
+ :svg-path="containersErrorImage"
>
<template #description>
<p>
- <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
+ <gl-sprintf :message="$options.i18n.connectionErrorMessage">
<template #docLink="{ content }">
- <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
+ <gl-link :href="$options.helpPagePath" target="_blank">
{{ content }}
</gl-link>
</template>
@@ -117,17 +178,13 @@ export default {
</template>
</gl-empty-state>
<template v-else>
- <harbor-list-header
- :metadata-loading="isLoading"
- :images-count="totalCount"
- :help-page-path="config.helpPagePath"
- >
+ <harbor-list-header :metadata-loading="isLoading" :images-count="totalCount">
<template #commands>
<cli-commands
v-if="showCommands"
- :docker-build-command="dockerBuildCommand"
- :docker-push-command="dockerPushCommand"
- :docker-login-command="dockerLoginCommand"
+ :docker-build-command="dockerCommand.build"
+ :docker-push-command="dockerCommand.push"
+ :docker-login-command="dockerCommand.login"
/>
</template>
</harbor-list-header>
@@ -152,26 +209,24 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <template v-if="images.length > 0 || name">
- <harbor-list
- v-if="images.length"
- :images="images"
- :meta-data-loading="isLoading"
- :page-info="pageInfo"
- @prev-page="fetchPrevPage"
- @next-page="fetchNextPage"
- />
- <gl-empty-state
- v-else
- :svg-path="config.noContainersImage"
- data-testid="emptySearch"
- :title="$options.i18n.EMPTY_RESULT_TITLE"
- >
- <template #description>
- {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
- </template>
- </gl-empty-state>
- </template>
+ <harbor-list
+ v-if="images.length"
+ :images="images"
+ :metadata-loading="isLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPrevPage"
+ @next-page="fetchNextPage"
+ />
+ <gl-empty-state
+ v-else
+ :svg-path="noContainersImage"
+ data-testid="emptySearch"
+ :title="emptyStateTexts.title"
+ >
+ <template #description>
+ {{ emptyStateTexts.message }}
+ </template>
+ </gl-empty-state>
</template>
</template>
</div>
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
index 572dd382be3..5a792e30c62 100644
--- a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js
@@ -3,6 +3,7 @@ import VueRouter from 'vue-router';
import { HARBOR_REGISTRY_TITLE } from './constants/index';
import List from './pages/list.vue';
import Details from './pages/details.vue';
+import HarborTags from './pages/harbor_tags.vue';
Vue.use(VueRouter);
@@ -22,10 +23,20 @@ export default function createRouter(base, breadCrumbState) {
},
{
name: 'details',
- path: '/:id',
+ path: '/:project/:image',
component: Details,
meta: {
nameGenerator: () => breadCrumbState.name,
+ hrefGenerator: () => breadCrumbState.href,
+ },
+ },
+ {
+ name: 'tags',
+ path: '/:project/:image/:digest',
+ component: HarborTags,
+ meta: {
+ nameGenerator: () => breadCrumbState.name,
+ hrefGenerator: () => breadCrumbState.href,
},
},
],
diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js
new file mode 100644
index 00000000000..13df303cffe
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js
@@ -0,0 +1,84 @@
+import { isFinite } from 'lodash';
+import {
+ SORT_FIELD_MAPPING,
+ TOKEN_TYPE_TAG_NAME,
+} from '~/packages_and_registries/harbor_registry/constants';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
+
+export const extractSortingDetail = (parsedSorting = '') => {
+ const [orderBy, sortOrder] = parsedSorting.split('_');
+ if (orderBy && sortOrder) {
+ return {
+ orderBy: SORT_FIELD_MAPPING[orderBy],
+ sort: sortOrder.toLowerCase(),
+ };
+ }
+
+ return {
+ orderBy: '',
+ sort: '',
+ };
+};
+
+export const parseFilter = (filters = [], defaultPrefix = '') => {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ const prefixMap = {
+ [FILTERED_SEARCH_TERM]: `${defaultPrefix}=`,
+ [TOKEN_TYPE_TAG_NAME]: 'tags=',
+ };
+ /* eslint-enable @gitlab/require-i18n-strings */
+ const filterList = [];
+ filters.forEach((i) => {
+ if (i.value?.data) {
+ const filterVal = i.value?.data;
+ const prefix = prefixMap[i.type];
+ const filterString = `${prefix}${filterVal}`;
+
+ filterList.push(filterString);
+ }
+ });
+
+ return filterList.join(',');
+};
+
+export const getNameFromParams = (fullName) => {
+ const names = fullName.split('/');
+ return {
+ projectName: names[0] || '',
+ imageName: names[1] || '',
+ };
+};
+
+export const formatPagination = (headers) => {
+ const pagination = parseIntPagination(normalizeHeaders(headers)) || {};
+
+ if (pagination.nextPage || pagination.previousPage) {
+ pagination.hasNextPage = isFinite(pagination.nextPage);
+ pagination.hasPreviousPage = isFinite(pagination.previousPage);
+ }
+
+ return pagination;
+};
+
+/* eslint-disable @gitlab/require-i18n-strings */
+export const dockerBuildCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => {
+ return `docker build -t ${repositoryUrl}/${harborProjectName}/${projectName} .`;
+};
+
+export const dockerPushCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => {
+ return `docker push ${repositoryUrl}/${harborProjectName}/${projectName}`;
+};
+
+export const dockerLoginCommand = (repositoryUrl) => {
+ return `docker login ${repositoryUrl}`;
+};
+
+export const artifactPullCommand = ({ repositoryUrl, harborProjectName, imageName, digest }) => {
+ return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}@${digest}`;
+};
+
+export const tagPullCommand = ({ repositoryUrl, harborProjectName, imageName, tag }) => {
+ return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}:${tag}`;
+};
+/* eslint-enable @gitlab/require-i18n-strings */
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
index 425fb4596fd..fd099ee4e69 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue
@@ -114,7 +114,7 @@ export default {
deleteModalContent: s__(
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
- deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`),
+ deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`),
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
index 28bfb82093c..e45b88bc6d5 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/package_files.vue
@@ -84,14 +84,14 @@ export default {
},
},
i18n: {
- deleteFile: __('Delete file'),
+ deleteFile: __('Delete asset'),
},
};
</script>
<template>
<div>
- <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3>
<gl-table
:fields="filesTableHeaderFields"
:items="filesTableRows"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
index a465fea0b74..dab4a051d0c 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list.vue
@@ -98,7 +98,7 @@ export default {
</div>
<template v-else>
- <div data-qa-selector="packages-table">
+ <div data-testid="packages-table">
<packages-list-row
v-for="packageEntity in list"
:key="packageEntity.id"
diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
index 3c6b8344c34..cc52235eaf3 100644
--- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/shared/package_list_row.vue
@@ -78,7 +78,7 @@ export default {
</script>
<template>
- <list-item data-qa-selector="package_row" :disabled="disabledRow">
+ <list-item data-testid="package-row" :disabled="disabledRow">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<gl-link
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue
index 122d444e859..f581469b12b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/installation_commands.vue
@@ -14,16 +14,17 @@ import NpmInstallation from './npm_installation.vue';
import NugetInstallation from './nuget_installation.vue';
import PypiInstallation from './pypi_installation.vue';
+const components = {
+ [PACKAGE_TYPE_CONAN]: ConanInstallation,
+ [PACKAGE_TYPE_MAVEN]: MavenInstallation,
+ [PACKAGE_TYPE_NPM]: NpmInstallation,
+ [PACKAGE_TYPE_NUGET]: NugetInstallation,
+ [PACKAGE_TYPE_PYPI]: PypiInstallation,
+ [PACKAGE_TYPE_COMPOSER]: ComposerInstallation,
+};
+
export default {
name: 'InstallationCommands',
- components: {
- [PACKAGE_TYPE_CONAN]: ConanInstallation,
- [PACKAGE_TYPE_MAVEN]: MavenInstallation,
- [PACKAGE_TYPE_NPM]: NpmInstallation,
- [PACKAGE_TYPE_NUGET]: NugetInstallation,
- [PACKAGE_TYPE_PYPI]: PypiInstallation,
- [PACKAGE_TYPE_COMPOSER]: ComposerInstallation,
- },
props: {
packageEntity: {
type: Object,
@@ -32,7 +33,7 @@ export default {
},
computed: {
installationComponent() {
- return this.$options.components[this.packageEntity.packageType];
+ return components[this.packageEntity.packageType];
},
},
};
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 b872294d2cf..8eb8654cddd 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
@@ -139,7 +139,7 @@ export default {
},
},
i18n: {
- deleteFile: __('Delete file'),
+ deleteFile: __('Delete asset'),
deleteSelected: s__('PackageRegistry|Delete selected'),
moreActionsText: __('More actions'),
},
@@ -149,7 +149,7 @@ export default {
<template>
<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>
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Assets') }}</h3>
<gl-button
v-if="canDelete"
:disabled="isLoading || !areFilesSelected"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
index 04faff1a75b..7a000aca0f2 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <list-item data-qa-selector="package_row">
+ <list-item data-testid="package-row">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<router-link
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index a6ac2eb1b2b..e84f181e9b2 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -151,7 +151,7 @@ export default {
@primaryAction="showConfirmationModal"
>{{ $options.i18n.errorMessageBodyAlert }}</gl-alert
>
- <div data-qa-selector="packages-table">
+ <div data-testid="packages-table">
<packages-list-row
v-for="packageEntity in list"
:key="packageEntity.id"
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 5b2a347a4ee..06a04ee248a 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -79,10 +79,10 @@ 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.',
+ 'PackageRegistry|Something went wrong while deleting the package asset.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
- 'PackageRegistry|Package file deleted successfully',
+ 'PackageRegistry|Package asset deleted successfully',
);
export const DELETE_PACKAGE_FILES_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package assets.',
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 e83962bb608..c10fc914d56 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
@@ -256,7 +256,7 @@ export default {
deleteModalContent: s__(
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
- deleteFileModalTitle: s__(`PackageRegistry|Delete Package File`),
+ deleteFileModalTitle: s__(`PackageRegistry|Delete package asset`),
deleteFileModalContent: s__(
`PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`,
),
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
deleted file mode 100644
index 51a97aead49..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/duplicates_settings.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<script>
-import { GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import { isEqual } from 'lodash';
-
-import {
- DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_SETTING_EXCEPTION_TITLE,
- DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
-} from '~/packages_and_registries/settings/group/constants';
-
-export default {
- name: 'DuplicatesSettings',
- i18n: {
- DUPLICATES_TOGGLE_LABEL,
- DUPLICATES_SETTING_EXCEPTION_TITLE,
- DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
- },
- components: {
- GlToggle,
- GlFormGroup,
- GlFormInput,
- },
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- duplicatesAllowed: {
- type: Boolean,
- default: false,
- required: false,
- },
- duplicateExceptionRegex: {
- type: String,
- default: '',
- required: false,
- },
- duplicateExceptionRegexError: {
- type: String,
- default: '',
- required: false,
- },
- modelNames: {
- type: Object,
- required: true,
- validator(value) {
- return isEqual(Object.keys(value), ['allowed', 'exception']);
- },
- },
- toggleQaSelector: {
- type: String,
- required: false,
- default: null,
- },
- labelQaSelector: {
- type: String,
- required: false,
- default: null,
- },
- },
- computed: {
- isExceptionRegexValid() {
- return !this.duplicateExceptionRegexError;
- },
- },
- methods: {
- update(type, value) {
- this.$emit('update', { [type]: value });
- },
- },
-};
-</script>
-
-<template>
- <form>
- <gl-toggle
- :data-qa-selector="toggleQaSelector"
- :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
- :value="!duplicatesAllowed"
- :disabled="loading"
- @change="update(modelNames.allowed, !$event)"
- />
- <gl-form-group
- v-if="!duplicatesAllowed"
- class="gl-mt-4"
- :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
- label-size="sm"
- :state="isExceptionRegexValid"
- :invalid-feedback="duplicateExceptionRegexError"
- :description="$options.i18n.DUPLICATES_SETTINGS_EXCEPTION_LEGEND"
- label-for="maven-duplicated-settings-regex-input"
- >
- <gl-form-input
- id="maven-duplicated-settings-regex-input"
- :disabled="loading"
- size="lg"
- :value="duplicateExceptionRegex"
- @change="update(modelNames.exception, $event)"
- />
- </gl-form-group>
- </form>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue
new file mode 100644
index 00000000000..9ac1673dbf3
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/exceptions_input.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+import {
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+} from '~/packages_and_registries/settings/group/constants';
+
+export default {
+ name: 'ExceptionsInput',
+ i18n: {
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_SETTINGS_EXCEPTION_LEGEND,
+ },
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ duplicatesAllowed: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ duplicateExceptionRegex: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ duplicateExceptionRegexError: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ id: {
+ type: String,
+ required: true,
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isExceptionRegexValid() {
+ return !this.duplicateExceptionRegexError;
+ },
+ },
+ methods: {
+ update(type, value) {
+ this.$emit('update', { [type]: value });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="gl-mb-0"
+ :label="$options.i18n.DUPLICATES_SETTING_EXCEPTION_TITLE"
+ label-sr-only
+ :invalid-feedback="duplicateExceptionRegexError"
+ :label-for="id"
+ >
+ <gl-form-input
+ :id="id"
+ :disabled="duplicatesAllowed || loading"
+ size="lg"
+ :value="duplicateExceptionRegex"
+ :state="isExceptionRegexValid"
+ @change="update(name, $event)"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
deleted file mode 100644
index e5f63fe8d0d..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/generic_settings.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { s__ } from '~/locale';
-import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
-
-export default {
- name: 'GenericSettings',
- components: {
- SettingsTitles,
- },
- i18n: {
- title: s__('PackageRegistry|Generic'),
- subTitle: s__('PackageRegistry|Settings for Generic packages'),
- },
- modelNames: {
- allowed: 'genericDuplicatesAllowed',
- exception: 'genericDuplicateExceptionRegex',
- },
-};
-</script>
-
-<template>
- <div>
- <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
- <slot :model-names="$options.modelNames"></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
deleted file mode 100644
index a1cbd695f34..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-import { s__ } from '~/locale';
-import SettingsTitles from '~/packages_and_registries/settings/group/components/settings_titles.vue';
-
-export default {
- name: 'MavenSettings',
- components: {
- SettingsTitles,
- },
- i18n: {
- title: s__('PackageRegistry|Maven'),
- subTitle: s__('PackageRegistry|Settings for Maven packages'),
- },
- modelNames: {
- allowed: 'mavenDuplicatesAllowed',
- exception: 'mavenDuplicateExceptionRegex',
- },
-};
-</script>
-
-<template>
- <div>
- <settings-titles :title="$options.i18n.title" :sub-title="$options.i18n.subTitle" />
- <slot :model-names="$options.modelNames"></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
index abb9f02d290..de087a8fcc5 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue
@@ -1,27 +1,50 @@
<script>
-import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
-import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
-import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
+import { GlTableLite, GlToggle } from '@gitlab/ui';
import {
+ GENERIC_PACKAGE_FORMAT,
+ MAVEN_PACKAGE_FORMAT,
+ PACKAGE_FORMATS_TABLE_HEADER,
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_TOGGLE_LABEL,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
+import ExceptionsInput from '~/packages_and_registries/settings/group/components/exceptions_input.vue';
export default {
name: 'PackageSettings',
i18n: {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
+ DUPLICATES_SETTING_EXCEPTION_TITLE,
+ DUPLICATES_TOGGLE_LABEL,
},
+ tableHeaderFields: [
+ {
+ key: 'packageFormat',
+ label: PACKAGE_FORMATS_TABLE_HEADER,
+ thClass: 'gl-bg-gray-10!',
+ },
+ {
+ key: 'allowDuplicates',
+ label: DUPLICATES_TOGGLE_LABEL,
+ thClass: 'gl-bg-gray-10!',
+ },
+ {
+ key: 'exceptions',
+ label: DUPLICATES_SETTING_EXCEPTION_TITLE,
+ thClass: 'gl-bg-gray-10!',
+ },
+ ],
components: {
SettingsBlock,
- MavenSettings,
- GenericSettings,
- DuplicatesSettings,
+ GlTableLite,
+ GlToggle,
+ ExceptionsInput,
},
inject: ['groupPath'],
props: {
@@ -40,6 +63,37 @@ export default {
errors: {},
};
},
+ computed: {
+ tableRows() {
+ return [
+ {
+ id: 'maven-duplicated-settings-regex-input',
+ format: MAVEN_PACKAGE_FORMAT,
+ duplicatesAllowed: this.packageSettings.mavenDuplicatesAllowed,
+ duplicateExceptionRegex: this.packageSettings.mavenDuplicateExceptionRegex,
+ duplicateExceptionRegexError: this.errors.mavenDuplicateExceptionRegex,
+ modelNames: {
+ allowed: 'mavenDuplicatesAllowed',
+ exception: 'mavenDuplicateExceptionRegex',
+ },
+ testid: 'maven-settings',
+ dataQaSelector: 'allow_duplicates_toggle',
+ },
+ {
+ id: 'generic-duplicated-settings-regex-input',
+ format: GENERIC_PACKAGE_FORMAT,
+ duplicatesAllowed: this.packageSettings.genericDuplicatesAllowed,
+ duplicateExceptionRegex: this.packageSettings.genericDuplicateExceptionRegex,
+ duplicateExceptionRegexError: this.errors.genericDuplicateExceptionRegex,
+ modelNames: {
+ allowed: 'genericDuplicatesAllowed',
+ exception: 'genericDuplicateExceptionRegex',
+ },
+ testid: 'generic-settings',
+ },
+ ];
+ },
+ },
methods: {
async updateSettings(payload) {
this.errors = {};
@@ -79,6 +133,9 @@ export default {
this.$emit('error');
}
},
+ update(type, value) {
+ this.updateSettings({ [type]: value });
+ },
},
};
</script>
@@ -92,32 +149,40 @@ export default {
</span>
</template>
<template #default>
- <maven-settings data-testid="maven-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- toggle-qa-selector="reject_duplicates_toggle"
- label-qa-selector="reject_duplicates_label"
- @update="updateSettings"
- />
- </template>
- </maven-settings>
- <generic-settings class="gl-mt-6" data-testid="generic-settings">
- <template #default="{ modelNames }">
- <duplicates-settings
- :duplicates-allowed="packageSettings.genericDuplicatesAllowed"
- :duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
- :duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
- :model-names="modelNames"
- :loading="isLoading"
- @update="updateSettings"
- />
- </template>
- </generic-settings>
+ <form>
+ <gl-table-lite
+ :fields="$options.tableHeaderFields"
+ :items="tableRows"
+ stacked="sm"
+ :tbody-tr-attr="(item) => ({ 'data-testid': item.testid })"
+ >
+ <template #cell(packageFormat)="{ item }">
+ <span class="gl-md-pt-3">{{ item.format }}</span>
+ </template>
+ <template #cell(allowDuplicates)="{ item }">
+ <gl-toggle
+ :data-qa-selector="item.dataQaSelector"
+ :label="$options.i18n.DUPLICATES_TOGGLE_LABEL"
+ :value="item.duplicatesAllowed"
+ :disabled="isLoading"
+ label-position="hidden"
+ class="gl-align-items-flex-end gl-sm-align-items-flex-start"
+ @change="update(item.modelNames.allowed, $event)"
+ />
+ </template>
+ <template #cell(exceptions)="{ item }">
+ <exceptions-input
+ :id="item.id"
+ :duplicates-allowed="item.duplicatesAllowed"
+ :duplicate-exception-regex="item.duplicateExceptionRegex"
+ :duplicate-exception-regex-error="item.duplicateExceptionRegexError"
+ :name="item.modelNames.exception"
+ :loading="isLoading"
+ @update="updateSettings"
+ />
+ </template>
+ </gl-table-lite>
+ </form>
</template>
</settings-block>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
deleted file mode 100644
index 1e93875c1e3..00000000000
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/settings_titles.vue
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
-export default {
- name: 'SettingsTitle',
- props: {
- title: {
- type: String,
- required: true,
- },
- subTitle: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-
-<template>
- <div>
- <h5 class="gl-border-b-solid gl-border-b-1 gl-border-gray-200 gl-pb-3">
- {{ title }}
- </h5>
- <p v-if="subTitle">{{ subTitle }}</p>
- <slot></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
index 34764663892..2dd6d3f76f6 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js
@@ -5,10 +5,11 @@ export const PACKAGE_SETTINGS_HEADER = s__('PackageRegistry|Duplicate packages')
export const PACKAGE_SETTINGS_DESCRIPTION = s__(
'PackageRegistry|Allow packages with the same name and version to be uploaded to the registry. The newest version of a package is always used when installing.',
);
+export const PACKAGE_FORMATS_TABLE_HEADER = s__('PackageRegistry|Package formats');
+export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven');
+export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic');
-export const DUPLICATES_TOGGLE_LABEL = s__(
- 'PackageRegistry|Reject packages with the same name and version',
-);
+export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates');
export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions');
export const DUPLICATES_SETTINGS_EXCEPTION_LEGEND = s__(
'PackageRegistry|Publish packages if their name or version matches this regex.',
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue
new file mode 100644
index 00000000000..72e68aca070
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/cleanup_image_tags.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { isEqual, get, isEmpty } from 'lodash';
+import {
+ CONTAINER_CLEANUP_POLICY_TITLE,
+ CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ UNAVAILABLE_USER_FEATURE_TEXT,
+ UNAVAILABLE_ADMIN_FEATURE_TEXT,
+} from '~/packages_and_registries/settings/project/constants';
+import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
+
+import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
+
+export default {
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ ContainerExpirationPolicyForm,
+ },
+ inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
+ i18n: {
+ CONTAINER_CLEANUP_POLICY_TITLE,
+ CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ UNAVAILABLE_FEATURE_TITLE,
+ UNAVAILABLE_FEATURE_INTRO_TEXT,
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ },
+ apollo: {
+ containerExpirationPolicy: {
+ query: expirationPolicyQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: (data) => data.project?.containerExpirationPolicy,
+ result({ data }) {
+ this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
+ },
+ error(e) {
+ this.fetchSettingsError = e;
+ },
+ },
+ },
+ data() {
+ return {
+ fetchSettingsError: false,
+ containerExpirationPolicy: null,
+ workingCopy: {},
+ };
+ },
+ computed: {
+ isEnabled() {
+ return this.containerExpirationPolicy || this.enableHistoricEntries;
+ },
+ showDisabledFormMessage() {
+ return !this.isEnabled && !this.fetchSettingsError;
+ },
+ unavailableFeatureMessage() {
+ return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
+ },
+ isEdited() {
+ if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
+ return false;
+ }
+ return !isEqual(this.containerExpirationPolicy, this.workingCopy);
+ },
+ },
+};
+</script>
+
+<template>
+ <div data-testid="container-expiration-policy-project-settings">
+ <h4 data-testid="title">{{ $options.i18n.CONTAINER_CLEANUP_POLICY_TITLE }}</h4>
+ <p data-testid="description">
+ <gl-sprintf :message="$options.i18n.CONTAINER_CLEANUP_POLICY_DESCRIPTION">
+ <template #link="{ content }">
+ <gl-link :href="helpPagePath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <container-expiration-policy-form
+ v-if="isEnabled"
+ v-model="workingCopy"
+ :is-loading="$apollo.queries.containerExpirationPolicy.loading"
+ :is-edited="isEdited"
+ />
+ <template v-else>
+ <gl-alert
+ v-if="showDisabledFormMessage"
+ :dismissible="false"
+ :title="$options.i18n.UNAVAILABLE_FEATURE_TITLE"
+ variant="tip"
+ >
+ {{ $options.i18n.UNAVAILABLE_FEATURE_INTRO_TEXT }}
+
+ <gl-sprintf :message="unavailableFeatureMessage">
+ <template #link="{ content }">
+ <gl-link :href="adminSettingsPath">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <gl-alert v-else-if="fetchSettingsError" variant="warning" :dismissible="false">
+ <gl-sprintf :message="$options.i18n.FETCH_SETTINGS_ERROR_MESSAGE" />
+ </gl-alert>
+ </template>
+ </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 1c44d2bc38b..b003b6aeb6b 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
@@ -1,9 +1,11 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { isEqual, get, isEmpty } from 'lodash';
+import { GlAlert, GlSprintf, GlLink, GlCard, GlButton } from '@gitlab/ui';
import {
CONTAINER_CLEANUP_POLICY_TITLE,
CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_EDIT_RULES,
+ CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_SET_RULES,
FETCH_SETTINGS_ERROR_MESSAGE,
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
@@ -13,20 +15,29 @@ import {
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
import SettingsBlock from '~/packages_and_registries/shared/components/settings_block.vue';
-import ContainerExpirationPolicyForm from './container_expiration_policy_form.vue';
-
export default {
components: {
SettingsBlock,
GlAlert,
GlSprintf,
GlLink,
- ContainerExpirationPolicyForm,
+ GlCard,
+ GlButton,
},
- inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries', 'helpPagePath'],
+ inject: [
+ 'projectPath',
+ 'isAdmin',
+ 'adminSettingsPath',
+ 'enableHistoricEntries',
+ 'helpPagePath',
+ 'cleanupSettingsPath',
+ ],
i18n: {
CONTAINER_CLEANUP_POLICY_TITLE,
CONTAINER_CLEANUP_POLICY_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_EDIT_RULES,
+ CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION,
+ CONTAINER_CLEANUP_POLICY_SET_RULES,
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
FETCH_SETTINGS_ERROR_MESSAGE,
@@ -40,9 +51,6 @@ export default {
};
},
update: (data) => data.project?.containerExpirationPolicy,
- result({ data }) {
- this.workingCopy = { ...get(data, 'project.containerExpirationPolicy', {}) };
- },
error(e) {
this.fetchSettingsError = e;
},
@@ -52,29 +60,25 @@ export default {
return {
fetchSettingsError: false,
containerExpirationPolicy: null,
- workingCopy: {},
};
},
computed: {
- isDisabled() {
- return !(this.containerExpirationPolicy || this.enableHistoricEntries);
+ isCleanupEnabled() {
+ return this.containerExpirationPolicy?.enabled ?? false;
+ },
+ isEnabled() {
+ return this.containerExpirationPolicy || this.enableHistoricEntries;
},
showDisabledFormMessage() {
- return this.isDisabled && !this.fetchSettingsError;
+ return !this.isEnabled && !this.fetchSettingsError;
},
unavailableFeatureMessage() {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
- isEdited() {
- if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
- return false;
- }
- return !isEqual(this.containerExpirationPolicy, this.workingCopy);
- },
- },
- methods: {
- restoreOriginal() {
- this.workingCopy = { ...this.containerExpirationPolicy };
+ cleanupRulesButtonText() {
+ return this.isCleanupEnabled
+ ? this.$options.i18n.CONTAINER_CLEANUP_POLICY_EDIT_RULES
+ : this.$options.i18n.CONTAINER_CLEANUP_POLICY_SET_RULES;
},
},
};
@@ -93,13 +97,19 @@ export default {
</span>
</template>
<template #default>
- <container-expiration-policy-form
- v-if="!isDisabled"
- v-model="workingCopy"
- :is-loading="$apollo.queries.containerExpirationPolicy.loading"
- :is-edited="isEdited"
- @reset="restoreOriginal"
- />
+ <gl-card v-if="isEnabled">
+ <p data-testid="description">
+ {{ $options.i18n.CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION }}
+ </p>
+ <gl-button
+ data-testid="rules-button"
+ :href="cleanupSettingsPath"
+ category="secondary"
+ variant="confirm"
+ >
+ {{ cleanupRulesButtonText }}
+ </gl-button>
+ </gl-card>
<template v-else>
<gl-alert
v-if="showDisabledFormMessage"
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
index ae2d5f4fbc5..11d8732426d 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/container_expiration_policy_form.vue
@@ -1,8 +1,9 @@
<script>
import { GlCard, GlButton, GlSprintf } from '@gitlab/ui';
+import { objectToQuery, visitUrl } from '~/lib/utils/url_utility';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
- UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ SHOW_SETUP_SUCCESS_ALERT,
SET_CLEANUP_POLICY_BUTTON,
KEEP_HEADER_TEXT,
KEEP_INFO_TEXT,
@@ -37,7 +38,7 @@ export default {
ExpirationRunText,
},
mixins: [Tracking.mixin()],
- inject: ['projectPath'],
+ inject: ['projectPath', 'projectSettingsPath'],
props: {
value: {
type: Object,
@@ -95,10 +96,10 @@ export default {
return Object.values(this.localErrors).every((error) => error);
},
isSubmitButtonDisabled() {
- return !this.fieldsAreValid || this.showLoadingIcon;
+ return !this.isEdited || !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
- return !this.isEdited || this.isLoading || this.mutationLoading;
+ return this.isLoading || this.mutationLoading;
},
isFieldDisabled() {
return this.showLoadingIcon || !this.value.enabled;
@@ -119,12 +120,6 @@ export default {
findDefaultOption(option) {
return this.value[option] || this.$options.formOptions[option].find((f) => f.default)?.key;
},
- reset() {
- this.track('reset_form');
- this.apiErrors = {};
- this.localErrors = {};
- this.$emit('reset');
- },
setApiErrors(response) {
this.apiErrors = response.graphQLErrors.reduce((acc, curr) => {
curr.extensions.problems.forEach((item) => {
@@ -168,7 +163,7 @@ export default {
const customError = this.encapsulateError('nameRegex', errorMessage);
throw customError;
} else {
- this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE);
+ this.navigateToSettingsWithSuccessAlert();
}
})
.catch((error) => {
@@ -183,12 +178,17 @@ export default {
this.$emit('input', { ...this.value, [model]: newValue });
this.apiErrors[model] = undefined;
},
+ navigateToSettingsWithSuccessAlert() {
+ const alertQuery = objectToQuery({ [SHOW_SETUP_SUCCESS_ALERT]: true });
+
+ visitUrl(`${this.projectSettingsPath}?${alertQuery}`);
+ },
},
};
</script>
<template>
- <form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
+ <form @submit.prevent="submit">
<expiration-toggle
:value="prefilledForm.enabled"
:disabled="showLoadingIcon"
@@ -199,7 +199,7 @@ export default {
<div class="gl-display-flex gl-mt-7">
<expiration-dropdown
- v-model="prefilledForm.cadence"
+ :value="prefilledForm.cadence"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.cadence"
:label="$options.i18n.CADENCE_LABEL"
@@ -231,7 +231,7 @@ export default {
</gl-sprintf>
</p>
<expiration-dropdown
- v-model="prefilledForm.keepN"
+ :value="prefilledForm.keepN"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.keepN"
:label="$options.i18n.KEEP_N_LABEL"
@@ -270,7 +270,7 @@ export default {
</gl-sprintf>
</p>
<expiration-dropdown
- v-model="prefilledForm.olderThan"
+ :value="prefilledForm.olderThan"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.olderThan"
:label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
@@ -306,7 +306,7 @@ export default {
</gl-button>
<gl-button
data-testid="cancel-button"
- type="reset"
+ :href="projectSettingsPath"
:disabled="isCancelButtonDisabled"
class="gl-mr-4"
>
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 710cfe7b1eb..2c1368262f2 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -1,18 +1,57 @@
<script>
+import { GlAlert } from '@gitlab/ui';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { getParameterByName } from '~/lib/utils/url_utility';
+import {
+ SHOW_SETUP_SUCCESS_ALERT,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+} from '~/packages_and_registries/settings/project/constants';
import ContainerExpirationPolicy from './container_expiration_policy.vue';
import PackagesCleanupPolicy from './packages_cleanup_policy.vue';
export default {
components: {
ContainerExpirationPolicy,
+ GlAlert,
PackagesCleanupPolicy,
},
inject: ['showContainerRegistrySettings', 'showPackageRegistrySettings'],
+ i18n: {
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+ },
+ data() {
+ return {
+ showAlert: false,
+ };
+ },
+ mounted() {
+ this.checkAlert();
+ },
+ methods: {
+ checkAlert() {
+ const showAlert = getParameterByName(SHOW_SETUP_SUCCESS_ALERT);
+
+ if (showAlert) {
+ this.showAlert = true;
+ const cleanUrl = window.location.href.split('?')[0];
+ historyReplaceState(cleanUrl);
+ }
+ },
+ },
};
</script>
<template>
<div>
+ <gl-alert
+ v-if="showAlert"
+ variant="success"
+ class="gl-mt-5"
+ dismissible
+ @dismiss="showAlert = false"
+ >
+ {{ $options.i18n.UPDATE_SETTINGS_SUCCESS_MESSAGE }}
+ </gl-alert>
<packages-cleanup-policy v-if="showPackageRegistrySettings" />
<container-expiration-policy v-if="showContainerRegistrySettings" />
</div>
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 fcb4a8ee297..a9b47cbd343 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js
@@ -4,6 +4,13 @@ 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 CONTAINER_CLEANUP_POLICY_RULES_DESCRIPTION = s__(
+ 'ContainerRegistry|Set rules to automatically remove unused packages to save storage space.',
+);
+export const CONTAINER_CLEANUP_POLICY_EDIT_RULES = s__('ContainerRegistry|Edit cleanup rules');
+export const CONTAINER_CLEANUP_POLICY_SET_RULES = s__('ContainerRegistry|Set cleanup rules');
+export const SHOW_SETUP_SUCCESS_ALERT = 'showSetupSuccessAlert';
+
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/settings/project/registry_settings_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
index daf1da6eac8..57c8d07e620 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_bundle.js
@@ -18,6 +18,7 @@ export default () => {
enableHistoricEntries,
projectPath,
adminSettingsPath,
+ cleanupSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
showContainerRegistrySettings,
@@ -34,6 +35,7 @@ export default () => {
enableHistoricEntries: parseBoolean(enableHistoricEntries),
projectPath,
adminSettingsPath,
+ cleanupSettingsPath,
tagsRegexHelpPagePath,
helpPagePath,
showContainerRegistrySettings: parseBoolean(showContainerRegistrySettings),
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js
new file mode 100644
index 00000000000..b1401c448a1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle.js
@@ -0,0 +1,41 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
+import CleanupImageTags from './components/cleanup_image_tags.vue';
+import { apolloProvider } from './graphql/index';
+
+Vue.use(GlToast);
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-registry-settings-cleanup-image-tags');
+ if (!el) {
+ return null;
+ }
+ const {
+ isAdmin,
+ enableHistoricEntries,
+ projectPath,
+ adminSettingsPath,
+ projectSettingsPath,
+ tagsRegexHelpPagePath,
+ helpPagePath,
+ } = el.dataset;
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ isAdmin: parseBoolean(isAdmin),
+ enableHistoricEntries: parseBoolean(enableHistoricEntries),
+ projectPath,
+ adminSettingsPath,
+ projectSettingsPath,
+ tagsRegexHelpPagePath,
+ helpPagePath,
+ },
+ render(createElement) {
+ return createElement(CleanupImageTags, {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
index b2b1d2c8212..363304c20ce 100644
--- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
+++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue
@@ -18,6 +18,11 @@ export default {
type: String,
required: true,
},
+ tokens: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
data() {
return {
@@ -68,7 +73,7 @@ export default {
v-if="mountRegistrySearch"
:filters="filters"
:sorting="sorting"
- :tokens="$options.tokens"
+ :tokens="tokens"
:sortable-fields="sortableFields"
@sorting:changed="updateSortingAndEmitUpdate"
@filter:changed="updateFilters"
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 0458b914b58..7740924b058 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 gl-gap-6">
- <div class="gl-lg-w-40p gl-pr-10 gl-flex-shrink-0">
+ <div class="row">
+ <div class="col-lg-4">
<h4>
<slot name="title"></slot>
</h4>
@@ -9,7 +9,7 @@
<slot name="description"></slot>
</p>
</div>
- <div class="gl-pt-3 gl-flex-grow-1">
+ <div class="col-lg-8 gl-pt-3">
<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 6744e821565..7fd440d0b27 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
@@ -33,10 +33,10 @@ export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package.',
);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package file.',
+ 'PackageRegistry|Something went wrong while deleting the package asset.',
);
export const DELETE_PACKAGE_FILE_SUCCESS_MESSAGE = s__(
- 'PackageRegistry|Package file deleted successfully',
+ 'PackageRegistry|Package asset deleted successfully',
);
export const PACKAGE_ERROR_STATUS = 'error';
diff --git a/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js b/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js
new file mode 100644
index 00000000000..9b6fba9876e
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/ci_cd/index.js
@@ -0,0 +1,3 @@
+import initRunnerTokenExpirationIntervals from '~/admin/application_settings/runner_token_expiration/index';
+
+initRunnerTokenExpirationIntervals();
diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 84027203783..616005565c4 100644
--- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -63,6 +63,8 @@ export default class PayloadPreviewer {
insertPayload(data) {
this.isInserted = true;
+
+ // eslint-disable-next-line no-unsanitized/property
this.getContainer().innerHTML = data;
this.showPayload();
}
diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js
index a4d89889d57..4cd312b403c 100644
--- a/app/assets/javascripts/pages/admin/jobs/index/index.js
+++ b/app/assets/javascripts/pages/admin/jobs/index/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import stopJobsModal from './components/stop_jobs_modal.vue';
+import StopJobsModal from './components/stop_jobs_modal.vue';
Vue.use(Translate);
@@ -14,7 +14,7 @@ function initJobs() {
new Vue({
el: `#js-${modalId}`,
components: {
- stopJobsModal,
+ StopJobsModal,
},
mounted() {
stopJobsButton.classList.remove('disabled');
diff --git a/app/assets/javascripts/pages/admin/runners/edit/index.js b/app/assets/javascripts/pages/admin/runners/edit/index.js
index ddf135a2732..03d31f49a99 100644
--- a/app/assets/javascripts/pages/admin/runners/edit/index.js
+++ b/app/assets/javascripts/pages/admin/runners/edit/index.js
@@ -1,3 +1,3 @@
-import { initAdminRunnerEdit } from '~/runner/admin_runner_edit';
+import { initRunnerEdit } from '~/runner/runner_edit';
-initAdminRunnerEdit();
+initRunnerEdit('#js-admin-runner-edit');
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
index f5e6d044865..b2cbd52fb27 100644
--- a/app/assets/javascripts/pages/admin/topics/edit/index.js
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
-import initRemoveAvatar from '~/admin/topics';
+import { initRemoveAvatar } from '~/admin/topics';
new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
diff --git a/app/assets/javascripts/pages/admin/topics/index.js b/app/assets/javascripts/pages/admin/topics/index.js
new file mode 100644
index 00000000000..ec0e11660d2
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/index.js
@@ -0,0 +1,3 @@
+import { initMergeTopics } from '~/admin/topics';
+
+initMergeTopics();
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index e45a40bd44c..b6f42a27002 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -140,10 +140,10 @@ export default class Todos {
restoreBtn.classList.add('hidden');
doneBtn.classList.remove('hidden');
} else if (target === doneBtn) {
- row.classList.add('done-reversible');
+ row.classList.add('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100');
restoreBtn.classList.remove('hidden');
} else if (target === restoreBtn) {
- row.classList.remove('done-reversible');
+ row.classList.remove('done-reversible', 'gl-bg-gray-50', 'gl-border-gray-100');
doneBtn.classList.remove('hidden');
} else {
row.parentNode.removeChild(row);
@@ -200,9 +200,11 @@ export default class Todos {
});
document.dispatchEvent(event);
+ // eslint-disable-next-line no-unsanitized/property
document.querySelector('.js-todos-pending .js-todos-badge').innerHTML = addDelimiter(
data.count,
);
+ // eslint-disable-next-line no-unsanitized/property
document.querySelector('.js-todos-done .js-todos-badge').innerHTML = addDelimiter(
data.done_count,
);
diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js
index 0417134f2a7..92490368b15 100644
--- a/app/assets/javascripts/pages/groups/details/index.js
+++ b/app/assets/javascripts/pages/groups/details/index.js
@@ -1,3 +1,5 @@
+import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
import initGroupDetails from '../shared/group_details';
initGroupDetails('details');
+initGroupOverviewTabs();
diff --git a/app/assets/javascripts/pages/groups/runners/edit/index.js b/app/assets/javascripts/pages/groups/runners/edit/index.js
new file mode 100644
index 00000000000..febb0026b67
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/runners/edit/index.js
@@ -0,0 +1,3 @@
+import { initRunnerEdit } from '~/runner/runner_edit';
+
+initRunnerEdit('#js-group-runner-edit');
diff --git a/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js
new file mode 100644
index 00000000000..1943704ac3d
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/settings/repository/create_deploy_token/index.js
@@ -0,0 +1,6 @@
+// This "page" is only rendered as response to the create_deploy_token form.
+// It shows the secret token to the user one time, but is otherwise identical
+// with the Settings/Repository page.
+//
+// This is why we just import the other page's JavaScript here.
+import '../show/index';
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index e4a84dd5eec..161fca83a58 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,5 +1,7 @@
import leaveByUrl from '~/namespaces/leave_by_url';
+import { initGroupOverviewTabs } from '~/groups/init_overview_tabs';
import initGroupDetails from '../shared/group_details';
leaveByUrl('group');
initGroupDetails();
+initGroupOverviewTabs();
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index 6b12604c76b..28b1aa02dfa 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -1,5 +1,6 @@
import initConfirmModal from '~/confirm_modal';
import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
+import { initExpiresAtField } from '~/access_tokens/index';
initConfirmModal();
@@ -23,3 +24,5 @@ function initSshKeyValidation() {
}
initSshKeyValidation();
+
+initExpiresAtField();
diff --git a/app/assets/javascripts/pages/profiles/show/emoji_menu.js b/app/assets/javascripts/pages/profiles/show/emoji_menu.js
deleted file mode 100644
index 286c1f1e929..00000000000
--- a/app/assets/javascripts/pages/profiles/show/emoji_menu.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import '~/commons/bootstrap';
-import { AwardsHandler } from '~/awards_handler';
-
-class EmojiMenu extends AwardsHandler {
- constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback) {
- super(emoji);
-
- this.selectEmojiCallback = selectEmojiCallback;
- this.toggleButtonSelector = toggleButtonSelector;
- this.menuClass = menuClass;
- }
-
- postEmoji($emojiButton, awardUrl, selectedEmoji, callback) {
- this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
- callback();
- }
-}
-
-export default EmojiMenu;
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index 226ef4c4e23..96ea7329e6e 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,88 +1,18 @@
import emojiRegex from 'emoji-regex';
-import $ from 'jquery';
-import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import * as Emoji from '~/emoji';
-import createFlash from '~/flash';
import { __ } from '~/locale';
-import EmojiMenu from './emoji_menu';
+import { initSetStatusForm } from '~/profile/profile';
-const defaultStatusEmoji = 'speech_balloon';
-const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu';
-const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector);
-const statusEmojiField = document.getElementById('js-status-emoji-field');
-const statusMessageField = document.getElementById('js-status-message-field');
-
-const toggleNoEmojiPlaceholder = (isVisible) => {
- const placeholderElement = document.getElementById('js-no-emoji-placeholder');
- placeholderElement.classList.toggle('hidden', !isVisible);
-};
-
-const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji');
-const removeStatusEmoji = () => {
- const statusEmoji = findStatusEmoji();
- if (statusEmoji) {
- statusEmoji.remove();
- }
-};
-
-const selectEmojiCallback = (emoji, emojiTag) => {
- statusEmojiField.value = emoji;
- toggleNoEmojiPlaceholder(false);
- removeStatusEmoji();
- toggleEmojiMenuButton.innerHTML += emojiTag;
-};
-
-const clearEmojiButton = document.getElementById('js-clear-user-status-button');
-clearEmojiButton.addEventListener('click', () => {
- statusEmojiField.value = '';
- statusMessageField.value = '';
- removeStatusEmoji();
- toggleNoEmojiPlaceholder(true);
-});
-
-const emojiAutocomplete = new GfmAutoComplete();
-emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+initSetStatusForm();
const userNameInput = document.getElementById('user_name');
-userNameInput.addEventListener('input', () => {
- const EMOJI_REGEX = emojiRegex();
- if (EMOJI_REGEX.test(userNameInput.value)) {
- // set field to invalid so it gets detected by GlFieldErrors
- userNameInput.setCustomValidity(__('Invalid field'));
- } else {
- userNameInput.setCustomValidity('');
- }
-});
-
-Emoji.initEmojiMap()
- .then(() => {
- const emojiMenu = new EmojiMenu(
- Emoji,
- toggleEmojiMenuButtonSelector,
- 'js-status-emoji-menu',
- selectEmojiCallback,
- );
- emojiMenu.bindEvents();
-
- const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji);
- statusMessageField.addEventListener('input', () => {
- const hasStatusMessage = statusMessageField.value.trim() !== '';
- const statusEmoji = findStatusEmoji();
- if (hasStatusMessage && statusEmoji) {
- return;
- }
-
- if (hasStatusMessage) {
- toggleNoEmojiPlaceholder(false);
- toggleEmojiMenuButton.innerHTML += defaultEmojiTag;
- } else if (statusEmoji.dataset.name === defaultStatusEmoji) {
- toggleNoEmojiPlaceholder(true);
- removeStatusEmoji();
- }
- });
- })
- .catch(() =>
- createFlash({
- message: __('Failed to load emoji list.'),
- }),
- );
+if (userNameInput) {
+ userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity(__('Invalid field'));
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+ });
+}
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 49fdf5bb6b5..96c4d0e0670 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -8,7 +8,10 @@ const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSk
if (skippable) {
const button = `<br/><a class="btn gl-button btn-sm btn-confirm gl-mt-3" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
const flashAlert = document.querySelector('.flash-alert');
- if (flashAlert) flashAlert.insertAdjacentHTML('beforeend', button);
+ if (flashAlert) {
+ // eslint-disable-next-line no-unsanitized/method
+ flashAlert.insertAdjacentHTML('beforeend', button);
+ }
}
mount2faRegistration();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 740fdb8a96a..e45f9a10294 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -9,7 +9,7 @@ import GpgBadges from '~/gpg_badges';
import createDefaultClient from '~/lib/graphql';
import initBlob from '~/pages/projects/init_blob';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
-import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobContentViewer from '~/repository/components/blob_content_viewer.vue';
import '~/sourcegraph/load';
import createStore from '~/code_navigation/store';
@@ -64,7 +64,7 @@ if (statusLink) {
new Vue({
el: CommitPipelineStatusEl,
components: {
- commitPipelineStatus,
+ CommitPipelineStatus,
},
render(createElement) {
return createElement('commit-pipeline-status', {
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
index f92a40e057f..b415e36bf09 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue
@@ -3,15 +3,12 @@ import {
GlIcon,
GlLink,
GlForm,
- GlFormInputGroup,
- GlInputGroupText,
GlFormInput,
GlFormGroup,
GlFormTextarea,
GlButton,
GlFormRadio,
GlFormRadioGroup,
- GlFormSelect,
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import { buildApiUrl } from '~/api/api_utils';
@@ -21,16 +18,13 @@ import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
-
-const PRIVATE_VISIBILITY = 'private';
-const INTERNAL_VISIBILITY = 'internal';
-const PUBLIC_VISIBILITY = 'public';
-
-const VISIBILITY_LEVEL = {
- [PRIVATE_VISIBILITY]: 0,
- [INTERNAL_VISIBILITY]: 10,
- [PUBLIC_VISIBILITY]: 20,
-};
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
+ VISIBILITY_LEVELS_STRING_TO_INTEGER,
+} from '~/visibility_level/constants';
+import ProjectNamespace from './project_namespace.vue';
const initFormField = ({ value, required = true, skipValidation = false }) => ({
value,
@@ -39,28 +33,18 @@ const initFormField = ({ value, required = true, skipValidation = false }) => ({
feedback: null,
});
-function sortNamespaces(namespaces) {
- if (!namespaces || !namespaces?.length) {
- return namespaces;
- }
-
- return namespaces.sort((a, b) => a.full_name.localeCompare(b.full_name));
-}
-
export default {
components: {
GlForm,
GlIcon,
GlLink,
GlButton,
- GlFormInputGroup,
- GlInputGroupText,
GlFormInput,
GlFormTextarea,
GlFormGroup,
GlFormRadio,
GlFormRadioGroup,
- GlFormSelect,
+ ProjectNamespace,
},
directives: {
validation: validation(),
@@ -72,9 +56,6 @@ export default {
visibilityHelpPath: {
default: '',
},
- endpoint: {
- default: '',
- },
projectFullPath: {
default: '',
},
@@ -96,6 +77,9 @@ export default {
restrictedVisibilityLevels: {
default: [],
},
+ namespaceId: {
+ default: '',
+ },
},
data() {
const form = {
@@ -117,20 +101,17 @@ export default {
};
return {
isSaving: false,
- namespaces: [],
form,
};
},
computed: {
- projectUrl() {
- return `${gon.gitlab_url}/`;
- },
projectVisibilityLevel() {
- return VISIBILITY_LEVEL[this.projectVisibility];
+ return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility];
},
namespaceVisibilityLevel() {
- const visibility = this.form.fields.namespace.value?.visibility || PUBLIC_VISIBILITY;
- return VISIBILITY_LEVEL[visibility];
+ const visibility =
+ this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING;
+ return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility];
},
visibilityLevelCap() {
return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel);
@@ -139,7 +120,7 @@ export default {
return new Set(this.restrictedVisibilityLevels);
},
allowedVisibilityLevels() {
- const allowedLevels = Object.entries(VISIBILITY_LEVEL).reduce(
+ const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce(
(levels, [levelName, levelValue]) => {
if (
!this.restrictedVisibilityLevelsSet.has(levelValue) &&
@@ -153,7 +134,7 @@ export default {
);
if (!allowedLevels.length) {
- return [PRIVATE_VISIBILITY];
+ return [VISIBILITY_LEVEL_PRIVATE_STRING];
}
return allowedLevels;
@@ -162,58 +143,56 @@ export default {
return [
{
text: s__('ForkProject|Private'),
- value: PRIVATE_VISIBILITY,
+ value: VISIBILITY_LEVEL_PRIVATE_STRING,
icon: 'lock',
help: s__(
'ForkProject|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.',
),
- disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY),
+ disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_PRIVATE_STRING),
},
{
text: s__('ForkProject|Internal'),
- value: INTERNAL_VISIBILITY,
+ value: VISIBILITY_LEVEL_INTERNAL_STRING,
icon: 'shield',
help: s__('ForkProject|The project can be accessed by any logged in user.'),
- disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY),
+ disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_INTERNAL_STRING),
},
{
text: s__('ForkProject|Public'),
- value: PUBLIC_VISIBILITY,
+ value: VISIBILITY_LEVEL_PUBLIC_STRING,
icon: 'earth',
help: s__('ForkProject|The project can be accessed without any authentication.'),
- disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY),
+ disabled: this.isVisibilityLevelDisabled(VISIBILITY_LEVEL_PUBLIC_STRING),
},
];
},
},
watch: {
// eslint-disable-next-line func-names
- 'form.fields.namespace.value': function () {
- this.form.fields.visibility.value =
- this.restrictedVisibilityLevels.length !== 0 ? null : PRIVATE_VISIBILITY;
- },
- // eslint-disable-next-line func-names
'form.fields.name.value': function (newVal) {
this.form.fields.slug.value = kebabCase(newVal);
},
},
- mounted() {
- this.fetchNamespaces();
- },
methods: {
- async fetchNamespaces() {
- const { data } = await axios.get(this.endpoint);
- this.namespaces = sortNamespaces(data.namespaces);
- },
isVisibilityLevelDisabled(visibility) {
return !this.allowedVisibilityLevels.includes(visibility);
},
getInitialVisibilityValue() {
return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility;
},
+ setNamespace(namespace) {
+ this.form.fields.visibility.value =
+ this.restrictedVisibilityLevels.length !== 0 ? null : VISIBILITY_LEVEL_PRIVATE_STRING;
+ this.form.fields.namespace.value = namespace;
+ this.form.fields.namespace.state = true;
+ },
async onSubmit() {
this.form.showValidation = true;
+ if (!this.form.fields.namespace.value) {
+ this.form.fields.namespace.state = false;
+ }
+
if (!this.form.state) {
return;
}
@@ -282,30 +261,7 @@ export default {
:state="form.fields.namespace.state"
:invalid-feedback="s__('ForkProject|Please select a namespace')"
>
- <gl-form-input-group>
- <template #prepend>
- <gl-input-group-text>
- {{ projectUrl }}
- </gl-input-group-text>
- </template>
- <gl-form-select
- id="fork-url"
- v-model="form.fields.namespace.value"
- v-validation:[form.showValidation]
- name="namespace"
- data-testid="fork-url-input"
- data-qa-selector="fork_namespace_dropdown"
- :state="form.fields.namespace.state"
- required
- >
- <template #first>
- <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option>
- </template>
- <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace">
- {{ namespace.full_name }}
- </option>
- </gl-form-select>
- </gl-form-input-group>
+ <project-namespace @select="setNamespace" />
</gl-form-group>
</div>
<div class="gl-flex-basis-half">
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
new file mode 100644
index 00000000000..2b3055ecd66
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue
@@ -0,0 +1,136 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchForkableNamespaces from '../queries/search_forkable_namespaces.query.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlTruncate,
+ },
+ apollo: {
+ project: {
+ query: searchForkableNamespaces,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ search: this.search,
+ };
+ },
+ skip() {
+ const { length } = this.search;
+ return length > 0 && length < MINIMUM_SEARCH_LENGTH;
+ },
+ error(error) {
+ createFlash({
+ message: s__(
+ 'ForkProject|Something went wrong while loading data. Please refresh the page to try again.',
+ ),
+ captureError: true,
+ error,
+ });
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ inject: ['projectFullPath'],
+ data() {
+ return {
+ project: {},
+ search: '',
+ selectedNamespace: null,
+ };
+ },
+ computed: {
+ rootUrl() {
+ return `${gon.gitlab_url}/`;
+ },
+ namespaces() {
+ return this.project.forkTargets?.nodes || [];
+ },
+ hasMatches() {
+ return this.namespaces.length;
+ },
+ dropdownText() {
+ return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace');
+ },
+ },
+ methods: {
+ handleDropdownShown() {
+ this.$refs.search.focusInput();
+ },
+ setNamespace(namespace) {
+ const id = getIdFromGraphQLId(namespace.id);
+
+ this.$emit('select', {
+ id,
+ name: namespace.name,
+ visibility: namespace.visibility,
+ });
+
+ this.selectedNamespace = { id, fullPath: namespace.fullPath };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group class="gl-w-full">
+ <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{
+ rootUrl
+ }}</gl-button>
+
+ <gl-dropdown
+ class="gl-flex-grow-1"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20"
+ data-qa-selector="select_namespace_dropdown"
+ data-testid="select_namespace_dropdown"
+ no-flip
+ @shown="handleDropdownShown"
+ >
+ <template #button-text>
+ <gl-truncate :text="dropdownText" position="start" with-tooltip />
+ </template>
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ :is-loading="$apollo.queries.project.loading"
+ data-qa-selector="select_namespace_dropdown_search_field"
+ data-testid="select_namespace_dropdown_search_field"
+ />
+ <template v-if="!$apollo.queries.project.loading">
+ <template v-if="hasMatches">
+ <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="namespace of namespaces"
+ :key="namespace.id"
+ data-qa-selector="select_namespace_dropdown_item"
+ @click="setNamespace(namespace)"
+ >
+ {{ namespace.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text>
+ </template>
+ </gl-dropdown>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js
index cbf74f755e7..d3a5ce5390f 100644
--- a/app/assets/javascripts/pages/projects/forks/new/index.js
+++ b/app/assets/javascripts/pages/projects/forks/new/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import App from './components/app.vue';
const mountElement = document.getElementById('fork-groups-mount-element');
@@ -17,9 +19,14 @@ const {
restrictedVisibilityLevels,
} = mountElement.dataset;
+Vue.use(VueApollo);
+
// eslint-disable-next-line no-new
new Vue({
el: mountElement,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient(),
+ }),
provide: {
newGroupPath,
visibilityHelpPath,
diff --git a/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql
new file mode 100644
index 00000000000..089b57815bd
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/forks/new/queries/search_forkable_namespaces.query.graphql
@@ -0,0 +1,13 @@
+query searchForkableNamespaces($projectPath: ID!, $search: String) {
+ project(fullPath: $projectPath) {
+ id
+ forkTargets(search: $search) {
+ nodes {
+ id
+ fullPath
+ name
+ visibility
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
deleted file mode 100644
index 5482324f1cd..00000000000
--- a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import init from '~/google_cloud/databases/index';
-
-init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js
new file mode 100644
index 00000000000..e1dc0116707
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/databases/init_index';
+
+init();
diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js
new file mode 100644
index 00000000000..698e788789b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/google_cloud/databases/new/index.js
@@ -0,0 +1,3 @@
+import init from '~/google_cloud/databases/init_new';
+
+init();
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 d7e68484143..08d24344ffc 100644
--- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
+++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue
@@ -180,7 +180,7 @@ export default {
v-for="({ group_name }, index) in dailyCoverageData"
:key="index"
:value="group_name"
- :is-check-item="true"
+ is-check-item
:is-checked="index === selectedCoverageIndex"
@click="setSelectedCoverage(index)"
>
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
index ec21d8c84e0..5179d1b31ab 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js
@@ -1,5 +1,74 @@
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+
+import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown';
+
import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request';
import initCheckFormState from './check_form_state';
+import initFormUpdate from './update_form';
+
+function initTargetBranchSelector() {
+ const targetBranch = document.querySelector('.js-target-branch');
+ const { selected, fieldName, refsUrl } = targetBranch?.dataset ?? {};
+ const formField = document.querySelector(`input[name="${fieldName}"]`);
+
+ if (targetBranch && refsUrl && formField) {
+ /* eslint-disable-next-line no-new */
+ new GitLabDropdown(targetBranch, {
+ selectable: true,
+ filterable: true,
+ filterRemote: Boolean(refsUrl),
+ filterInput: 'input[type="search"]',
+ data(term, callback) {
+ const params = {
+ search: term,
+ };
+
+ axios
+ .get(refsUrl, {
+ params,
+ })
+ .then(({ data }) => {
+ callback(data);
+ })
+ .catch(() =>
+ createFlash({
+ message: __('Error fetching branches'),
+ }),
+ );
+ },
+ renderRow(branch) {
+ const item = document.createElement('li');
+ const link = document.createElement('a');
+
+ link.setAttribute('href', '#');
+ link.dataset.branch = branch;
+ link.classList.toggle('is-active', branch === selected);
+ link.textContent = branch;
+
+ item.appendChild(link);
+
+ return item;
+ },
+ id(obj, $el) {
+ return $el.data('id');
+ },
+ toggleLabel(obj, $el) {
+ return $el.text().trim();
+ },
+ clicked({ $el, e }) {
+ e.preventDefault();
+
+ const branchName = $el[0].dataset.branch;
+
+ formField.setAttribute('value', branchName);
+ },
+ });
+ }
+}
initMergeRequest();
+initFormUpdate();
initCheckFormState();
+initTargetBranchSelector();
diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js b/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js
new file mode 100644
index 00000000000..3bb64f741e7
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/merge_requests/edit/update_form.js
@@ -0,0 +1,23 @@
+const findForm = () => document.querySelector('.merge-request-form');
+
+const removeHiddenCheckbox = (node) => {
+ const checkboxWrapper = node.closest('.form-check');
+ const hiddenCheckbox = checkboxWrapper.querySelector('input[type="hidden"]');
+ hiddenCheckbox.remove();
+};
+
+export default () => {
+ const updateCheckboxes = () => {
+ const checkboxes = document.querySelectorAll('.js-form-update');
+
+ if (!checkboxes.length) return;
+
+ checkboxes.forEach((checkbox) => {
+ if (checkbox.checked) {
+ removeHiddenCheckbox(checkbox);
+ }
+ });
+ };
+
+ findForm().addEventListener('submit', () => updateCheckboxes());
+};
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index 2db804e1ad8..30734f0b698 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { s__ } from '~/locale';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { initPipelineCountListener } from '~/commit/pipelines/utils';
import { initIssuableSidebar } from '~/issuable';
@@ -10,6 +11,7 @@ import ZenMode from '~/zen_mode';
import initAwardsApp from '~/emoji/awards_app';
import MrWidgetHowToMergeModal from '~/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue';
import { initMrExperienceSurvey } from '~/surveys/merge_request_experience';
+import toast from '~/vue_shared/plugins/global_toast';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
@@ -65,4 +67,10 @@ export default function initMergeRequestShow() {
});
},
});
+
+ const copyReferenceButton = document.querySelector('.js-copy-reference');
+
+ copyReferenceButton?.addEventListener('click', () => {
+ toast(s__('MergeRequests|Reference copied'));
+ });
}
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 7f49eb60c5c..cc5c393ff8c 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -1,9 +1,14 @@
+import Vue from 'vue';
+import StickyHeader from '~/merge_requests/components/sticky_header.vue';
import { initReviewBar } from '~/batch_comments';
import { initIssuableHeaderWarnings } from '~/issuable';
import initMrNotes from '~/mr_notes';
import store from '~/mr_notes/stores';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
+import { parseBoolean } from '~/lib/utils/common_utils';
import initShow from '../init_merge_request_show';
+import getStateQuery from '../queries/get_state.query.graphql';
initMrNotes();
initShow();
@@ -12,4 +17,29 @@ requestIdleCallback(() => {
initSidebarBundle(store);
initReviewBar();
initIssuableHeaderWarnings(store);
+
+ const el = document.getElementById('js-merge-sticky-header');
+
+ if (el) {
+ const { data } = el.dataset;
+ const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data);
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store,
+ apolloProvider,
+ provide: {
+ query: getStateQuery,
+ iid,
+ projectPath,
+ title,
+ tabs,
+ isFluidLayout: parseBoolean(isFluidLayout),
+ },
+ render(h) {
+ return h(StickyHeader);
+ },
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 37e8a316ee4..b3ad50f395b 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -29,7 +29,7 @@ export default {
</script>
<template>
<div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout">
- <div class="bordered-box landing content-block" data-testid="innerContent">
+ <div class="bordered-box landing content-block gl-p-5!" data-testid="innerContent">
<gl-button
category="tertiary"
icon="close"
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 5dae812bbcb..eae721771de 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -6,7 +6,7 @@ import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list';
import GlFieldErrors from '~/gl_field_errors';
import Translate from '~/vue_shared/translate';
-import intervalPatternInput from './components/interval_pattern_input.vue';
+import IntervalPatternInput from './components/interval_pattern_input.vue';
import TimezoneDropdown from './components/timezone_dropdown';
Vue.use(Translate);
@@ -19,7 +19,7 @@ function initIntervalPatternInput() {
return new Vue({
el: intervalPatternMount,
components: {
- intervalPatternInput,
+ IntervalPatternInput,
},
render(createElement) {
return createElement('interval-pattern-input', {
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 032e2410233..ccabaad5b2e 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -141,7 +141,7 @@ export default class Project {
if (doesPathContainRef) {
// We are ignoring the url containing the ref portion
// and plucking the thereafter portion to reconstructure the url that is correct
- const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0];
+ const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0].split('?')[0];
selectedUrl.searchParams.set('path', targetPath);
selectedUrl.hash = window.location.hash;
}
diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
new file mode 100644
index 00000000000..739e666644c
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js
@@ -0,0 +1,10 @@
+import groupsSelect from '~/groups_select';
+import UserCallout from '~/user_callout';
+import UsersSelect from '~/users_select';
+
+// eslint-disable-next-line no-new
+new UsersSelect();
+groupsSelect();
+
+// eslint-disable-next-line no-new
+new UserCallout({ className: 'js-mr-approval-callout' });
diff --git a/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js b/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js
new file mode 100644
index 00000000000..acd5d3febff
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/packages_and_registries/cleanup_tags/index.js
@@ -0,0 +1,5 @@
+import registrySettingsCleanupTagsApp from '~/packages_and_registries/settings/project/registry_settings_cleanup_tags_bundle';
+import initSettingsPanels from '~/settings_panels';
+
+registrySettingsCleanupTagsApp();
+initSettingsPanels();
diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
index 1dc238b56b4..6a7c6028c95 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
@@ -1,3 +1 @@
-import initForm from '../form';
-
-initForm();
+import '../show/index';
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 c7c331c7de5..a82f485bf44 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
@@ -5,7 +5,11 @@ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/s
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__ } from '~/locale';
import {
- visibilityOptions,
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
+} from '~/visibility_level/constants';
+import {
visibilityLevelDescriptions,
featureAccessLevelMembers,
featureAccessLevelEveryone,
@@ -14,8 +18,8 @@ import {
featureAccessLevelDescriptions,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
-import projectFeatureSetting from './project_feature_setting.vue';
-import projectSettingRow from './project_setting_row.vue';
+import ProjectFeatureSetting from './project_feature_setting.vue';
+import ProjectSettingRow from './project_setting_row.vue';
const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')];
@@ -33,6 +37,11 @@ export default {
environmentsHelpText: s__(
'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.',
),
+ featureFlagsLabel: s__('ProjectSettings|Feature flags'),
+ featureFlagsHelpText: s__(
+ 'ProjectSettings|Roll out new features without redeploying with feature flags.',
+ ),
+ monitorLabel: s__('ProjectSettings|Monitor'),
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.',
),
@@ -45,6 +54,10 @@ export default {
ciCdLabel: __('CI/CD'),
repositoryLabel: s__('ProjectSettings|Repository'),
requirementsLabel: s__('ProjectSettings|Requirements'),
+ releasesLabel: s__('ProjectSettings|Releases'),
+ releasesHelpText: s__(
+ 'ProjectSettings|Combine git tags with release notes, release evidence, and assets to create a release.',
+ ),
securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'),
snippetsLabel: s__('ProjectSettings|Snippets'),
wikiLabel: s__('ProjectSettings|Wiki'),
@@ -54,10 +67,13 @@ export default {
),
confirmButtonText: __('Save changes'),
},
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
components: {
- projectFeatureSetting,
- projectSettingRow,
+ ProjectFeatureSetting,
+ ProjectSettingRow,
GlButton,
GlIcon,
GlSprintf,
@@ -65,7 +81,7 @@ export default {
GlFormCheckbox,
GlToggle,
ConfirmDanger,
- otherProjectSettings: () =>
+ OtherProjectSettings: () =>
import(
'jh_component/pages/projects/shared/permissions/components/other_project_settings.vue'
),
@@ -96,9 +112,9 @@ export default {
type: Array,
required: false,
default: () => [
- visibilityOptions.PRIVATE,
- visibilityOptions.INTERNAL,
- visibilityOptions.PUBLIC,
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
],
},
lfsAvailable: {
@@ -131,6 +147,21 @@ export default {
required: false,
default: '',
},
+ environmentsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ featureFlagsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ releasesHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
lfsHelpPath: {
type: String,
required: false,
@@ -197,8 +228,7 @@ export default {
},
data() {
const defaults = {
- visibilityOptions,
- visibilityLevel: visibilityOptions.PUBLIC,
+ visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER,
issuesAccessLevel: featureAccessLevel.EVERYONE,
repositoryAccessLevel: featureAccessLevel.EVERYONE,
forkingAccessLevel: featureAccessLevel.EVERYONE,
@@ -214,6 +244,9 @@ export default {
securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
operationsAccessLevel: featureAccessLevel.EVERYONE,
environmentsAccessLevel: featureAccessLevel.EVERYONE,
+ featureFlagsAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ releasesAccessLevel: featureAccessLevel.EVERYONE,
+ monitorAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryAccessLevel: featureAccessLevel.EVERYONE,
warnAboutPotentiallyUnwantedCharacters: true,
lfsEnabled: true,
@@ -234,7 +267,7 @@ export default {
computed: {
featureAccessLevelOptions() {
const options = [featureAccessLevelMembers];
- if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel !== VISIBILITY_LEVEL_PRIVATE_INTEGER) {
options.push(featureAccessLevelEveryone);
}
return options;
@@ -246,18 +279,12 @@ export default {
);
},
- operationsFeatureAccessLevelOptions() {
- return this.featureAccessLevelOptions.filter(
- ([value]) => value <= this.operationsAccessLevel,
- );
- },
-
packageRegistryFeatureAccessLevelOptions() {
const options = [FEATURE_ACCESS_LEVEL_ANONYMOUS];
- if (this.visibilityLevel === visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
options.unshift(featureAccessLevelMembers);
- } else if (this.visibilityLevel === visibilityOptions.INTERNAL) {
+ } else if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) {
options.unshift(featureAccessLevelEveryone);
}
@@ -268,15 +295,15 @@ export default {
const options = [featureAccessLevelMembers];
if (this.pagesAccessControlForced) {
- if (this.visibilityLevel === visibilityOptions.INTERNAL) {
+ if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) {
options.push(featureAccessLevelEveryone);
}
} else {
- if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel !== VISIBILITY_LEVEL_PRIVATE_INTEGER) {
options.push(featureAccessLevelEveryone);
}
- if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
+ if (this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER) {
options.push(FEATURE_ACCESS_LEVEL_ANONYMOUS);
}
}
@@ -290,6 +317,11 @@ export default {
environmentsEnabled() {
return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED;
},
+
+ monitorEnabled() {
+ return this.monitorAccessLevel > featureAccessLevel.NOT_ENABLED;
+ },
+
repositoryEnabled() {
return this.repositoryAccessLevel > featureAccessLevel.NOT_ENABLED;
},
@@ -300,13 +332,13 @@ export default {
showContainerRegistryPublicNote() {
return (
- this.visibilityLevel === visibilityOptions.PUBLIC &&
+ this.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_INTEGER &&
this.containerRegistryAccessLevel === featureAccessLevel.EVERYONE
);
},
repositoryHelpText() {
- if (this.visibilityLevel === visibilityOptions.PRIVATE) {
+ if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
return s__('ProjectSettings|View and edit files in this project.');
}
@@ -315,7 +347,7 @@ export default {
);
},
cveIdRequestIsDisabled() {
- return this.visibilityLevel !== visibilityOptions.PUBLIC;
+ return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER;
},
isVisibilityReduced() {
return (
@@ -329,11 +361,19 @@ export default {
splitOperationsEnabled() {
return this.glFeatures.splitOperationsVisibilityPermissions;
},
+ monitorOperationsFeatureAccessLevelOptions() {
+ if (this.splitOperationsEnabled) {
+ return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel);
+ }
+ return this.featureAccessLevelOptions.filter(
+ ([value]) => value <= this.operationsAccessLevel,
+ );
+ },
},
watch: {
visibilityLevel(value, oldValue) {
- if (value === visibilityOptions.PRIVATE) {
+ if (value === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
// when private, features are restricted to "only team members"
this.issuesAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
@@ -355,7 +395,7 @@ export default {
if (
this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE ||
(this.packageRegistryAccessLevel > featureAccessLevel.EVERYONE &&
- oldValue === visibilityOptions.PUBLIC)
+ oldValue === VISIBILITY_LEVEL_PUBLIC_INTEGER)
) {
this.packageRegistryAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
@@ -389,6 +429,18 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.environmentsAccessLevel,
);
+ this.featureFlagsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.featureFlagsAccessLevel,
+ );
+ this.releasesAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.releasesAccessLevel,
+ );
+ this.monitorAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.monitorAccessLevel,
+ );
this.containerRegistryAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.containerRegistryAccessLevel,
@@ -398,7 +450,7 @@ export default {
this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS;
}
this.highlightChanges();
- } else if (oldValue === visibilityOptions.PRIVATE) {
+ } else if (oldValue === VISIBILITY_LEVEL_PRIVATE_INTEGER) {
// if changing away from private, make enabled features more permissive
if (this.issuesAccessLevel > featureAccessLevel.NOT_ENABLED)
this.issuesAccessLevel = featureAccessLevel.EVERYONE;
@@ -432,19 +484,21 @@ export default {
this.operationsAccessLevel = featureAccessLevel.EVERYONE;
if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.environmentsAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.monitorAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
+ this.monitorAccessLevel = featureAccessLevel.EVERYONE;
if (this.containerRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.containerRegistryAccessLevel = featureAccessLevel.EVERYONE;
this.highlightChanges();
} else if (this.packageRegistryAccessLevelEnabled) {
if (
- value === visibilityOptions.PUBLIC &&
+ value === VISIBILITY_LEVEL_PUBLIC_INTEGER &&
this.packageRegistryAccessLevel === featureAccessLevel.EVERYONE
) {
// eslint-disable-next-line prefer-destructuring
this.packageRegistryAccessLevel = FEATURE_ACCESS_LEVEL_ANONYMOUS[0];
} else if (
- value === visibilityOptions.INTERNAL &&
+ value === VISIBILITY_LEVEL_INTERNAL_INTEGER &&
this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]
) {
this.packageRegistryAccessLevel = featureAccessLevel.EVERYONE;
@@ -467,6 +521,16 @@ export default {
},
operationsAccessLevel(value, oldValue) {
+ this.updateSubFeatureAccessLevel(value, oldValue);
+ },
+
+ monitorAccessLevel(value, oldValue) {
+ this.updateSubFeatureAccessLevel(value, oldValue);
+ },
+ },
+
+ methods: {
+ updateSubFeatureAccessLevel(value, oldValue) {
if (value < oldValue) {
// sub-features cannot have more permissive access level
this.metricsDashboardAccessLevel = Math.min(this.metricsDashboardAccessLevel, value);
@@ -474,9 +538,7 @@ export default {
this.metricsDashboardAccessLevel = value;
}
},
- },
- methods: {
highlightChanges() {
this.highlightChangesClass = true;
this.$nextTick(() => {
@@ -514,20 +576,20 @@ export default {
data-qa-selector="project_visibility_dropdown"
>
<option
- :value="visibilityOptions.PRIVATE"
- :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ :value="$options.VISIBILITY_LEVEL_PRIVATE_INTEGER"
+ :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_PRIVATE_INTEGER)"
>
{{ s__('ProjectSettings|Private') }}
</option>
<option
- :value="visibilityOptions.INTERNAL"
- :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
+ :value="$options.VISIBILITY_LEVEL_INTERNAL_INTEGER"
+ :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_INTERNAL_INTEGER)"
>
{{ s__('ProjectSettings|Internal') }}
</option>
<option
- :value="visibilityOptions.PUBLIC"
- :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ :value="$options.VISIBILITY_LEVEL_PUBLIC_INTEGER"
+ :disabled="!visibilityAllowed($options.VISIBILITY_LEVEL_PUBLIC_INTEGER)"
>
{{ s__('ProjectSettings|Public') }}
</option>
@@ -558,7 +620,7 @@ export default {
<div class="gl-mt-4">
<strong class="gl-display-block">{{ s__('ProjectSettings|Additional options') }}</strong>
<label
- v-if="visibilityLevel !== visibilityOptions.PRIVATE"
+ v-if="visibilityLevel !== $options.VISIBILITY_LEVEL_PRIVATE_INTEGER"
class="gl-line-height-28 gl-font-weight-normal gl-mb-0"
>
<input
@@ -570,7 +632,7 @@ export default {
{{ s__('ProjectSettings|Users can request access') }}
</label>
<label
- v-if="visibilityLevel !== visibilityOptions.PUBLIC"
+ v-if="visibilityLevel !== $options.VISIBILITY_LEVEL_PUBLIC_INTEGER"
class="gl-line-height-28 gl-font-weight-normal gl-display-block gl-mb-0"
>
<input
@@ -847,6 +909,22 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ v-if="splitOperationsEnabled"
+ ref="monitor-settings"
+ :label="$options.i18n.monitorLabel"
+ :help-text="
+ s__('ProjectSettings|Configure your project resources and monitor their health.')
+ "
+ >
+ <project-feature-setting
+ v-model="monitorAccessLevel"
+ :label="$options.i18n.monitorLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][monitor_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
+ v-else
ref="operations-settings"
:label="$options.i18n.operationsLabel"
:help-text="
@@ -869,7 +947,7 @@ export default {
<project-feature-setting
v-model="metricsDashboardAccessLevel"
:show-toggle="false"
- :options="operationsFeatureAccessLevelOptions"
+ :options="monitorOperationsFeatureAccessLevelOptions"
name="project[project_feature_attributes][metrics_dashboard_access_level]"
/>
</project-setting-row>
@@ -879,6 +957,7 @@ export default {
ref="environments-settings"
:label="$options.i18n.environmentsLabel"
:help-text="$options.i18n.environmentsHelpText"
+ :help-path="environmentsHelpPath"
>
<project-feature-setting
v-model="environmentsAccessLevel"
@@ -887,6 +966,32 @@ export default {
name="project[project_feature_attributes][environments_access_level]"
/>
</project-setting-row>
+ <project-setting-row
+ ref="feature-flags-settings"
+ :label="$options.i18n.featureFlagsLabel"
+ :help-text="$options.i18n.featureFlagsHelpText"
+ :help-path="featureFlagsHelpPath"
+ >
+ <project-feature-setting
+ v-model="featureFlagsAccessLevel"
+ :label="$options.i18n.featureFlagsLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][feature_flags_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
+ ref="releases-settings"
+ :label="$options.i18n.releasesLabel"
+ :help-text="$options.i18n.releasesHelpText"
+ :help-path="releasesHelpPath"
+ >
+ <project-feature-setting
+ v-model="releasesAccessLevel"
+ :label="$options.i18n.releasesLabel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][releases_access_level]"
+ />
+ </project-setting-row>
</template>
</div>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index cfca9d400e3..4c687859344 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -1,17 +1,16 @@
import { s__, __ } from '~/locale';
-
-export const visibilityOptions = {
- PRIVATE: 0,
- INTERNAL: 10,
- PUBLIC: 20,
-};
+import {
+ VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ VISIBILITY_LEVEL_PUBLIC_INTEGER,
+} from '~/visibility_level/constants';
export const visibilityLevelDescriptions = {
- [visibilityOptions.PRIVATE]: __(
+ [VISIBILITY_LEVEL_PRIVATE_INTEGER]: __(
`Only accessible by %{membersPageLinkStart}project members%{membersPageLinkEnd}. Membership must be explicitly granted to each user.`,
),
- [visibilityOptions.INTERNAL]: __('Accessible by any user who is logged in.'),
- [visibilityOptions.PUBLIC]: __('Accessible by anyone, regardless of authentication.'),
+ [VISIBILITY_LEVEL_INTERNAL_INTEGER]: __('Accessible by any user who is logged in.'),
+ [VISIBILITY_LEVEL_PUBLIC_INTEGER]: __('Accessible by anyone, regardless of authentication.'),
};
export const featureAccessLevel = {
diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
index e92f386a29e..10b95fd6f3c 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue
@@ -87,7 +87,7 @@ export default {
v-else-if="!loadingContentFailed && !isLoadingContent"
ref="content"
data-qa-selector="wiki_page_content"
- data-testid="wiki_page_content"
+ data-testid="wiki-page-content"
class="js-wiki-page-content md"
v-html="content /* eslint-disable-line vue/no-v-html */"
></div>
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 9d7d9e376cf..9acc1cb62a1 100644
--- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
+++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue
@@ -5,7 +5,6 @@ import {
GlLink,
GlButton,
GlSprintf,
- GlAlert,
GlFormGroup,
GlFormInput,
GlFormSelect,
@@ -59,14 +58,6 @@ export default {
label: s__('WikiPage|Content'),
placeholder: s__('WikiPage|Write your content or drag files here…'),
},
- contentEditor: {
- renderFailed: {
- message: s__(
- 'WikiPage|An error occurred while trying to render the content editor. Please try again later.',
- ),
- primaryAction: s__('WikiPage|Retry'),
- },
- },
linksHelpText: s__(
'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.',
),
@@ -88,7 +79,6 @@ export default {
{ text: s__('Wiki Page|Rich text'), value: 'richText' },
],
components: {
- GlAlert,
GlIcon,
GlForm,
GlFormGroup,
@@ -115,14 +105,12 @@ export default {
content: this.pageInfo.content || '',
commitMessage: '',
isDirty: false,
- contentEditorRenderFailed: false,
contentEditorEmpty: false,
switchEditingControlDisabled: false,
};
},
computed: {
noContent() {
- if (this.isContentEditorActive) return this.contentEditorEmpty;
return !this.content.trim();
},
csrfToken() {
@@ -145,11 +133,6 @@ export default {
linkExample() {
return MARKDOWN_LINK_TEXT[this.format];
},
- toggleEditingModeButtonText() {
- return this.isContentEditorActive
- ? this.$options.i18n.editSourceButtonText
- : this.$options.i18n.editRichTextButtonText;
- },
submitButtonText() {
return this.pageInfo.persisted
? this.$options.i18n.submitButton.existingPage
@@ -177,7 +160,7 @@ export default {
return !this.isContentEditorActive;
},
disableSubmitButton() {
- return this.noContent || !this.title || this.contentEditorRenderFailed;
+ return this.noContent || !this.title;
},
isContentEditorActive() {
return this.isMarkdownFormat && this.useContentEditor;
@@ -201,23 +184,14 @@ export default {
.then(({ data }) => data.body);
},
- toggleEditingMode(editingMode) {
+ setEditingMode(editingMode) {
this.editingMode = editingMode;
- if (!this.useContentEditor && this.contentEditor) {
- this.content = this.contentEditor.getSerializedContent();
- }
- },
-
- setEditingMode(value) {
- this.editingMode = value;
},
async handleFormSubmit(e) {
e.preventDefault();
if (this.useContentEditor) {
- this.content = this.contentEditor.getSerializedContent();
-
this.trackFormSubmit();
}
@@ -235,30 +209,10 @@ export default {
this.isDirty = true;
},
- async loadInitialContent(contentEditor) {
- this.contentEditor = contentEditor;
-
- try {
- await this.contentEditor.setSerializedContent(this.content);
- this.trackContentEditorLoaded();
- } catch (e) {
- this.contentEditorRenderFailed = true;
- }
- },
-
- async retryInitContentEditor() {
- try {
- this.contentEditorRenderFailed = false;
- await this.contentEditor.setSerializedContent(this.content);
- } catch (e) {
- this.contentEditorRenderFailed = true;
- }
- },
-
- handleContentEditorChange({ empty }) {
+ handleContentEditorChange({ empty, markdown, changed }) {
this.contentEditorEmpty = empty;
- // TODO: Implement a precise mechanism to detect changes in the Content
- this.isDirty = true;
+ this.isDirty = changed;
+ this.content = markdown;
},
onPageUnload(event) {
@@ -320,17 +274,6 @@ export default {
class="wiki-form common-note-form gl-mt-3 js-quick-submit"
@submit="handleFormSubmit"
>
- <gl-alert
- v-if="isContentEditorActive && contentEditorRenderFailed"
- class="gl-mb-6"
- :dismissible="false"
- variant="danger"
- :primary-button-text="$options.i18n.contentEditor.renderFailed.primaryAction"
- @primaryAction="retryInitContentEditor"
- >
- {{ $options.i18n.contentEditor.renderFailed.message }}
- </gl-alert>
-
<input :value="csrfToken" type="hidden" name="authenticity_token" />
<input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" />
<input
@@ -350,7 +293,6 @@ export default {
{{ $options.i18n.title.helpText.learnMore }}
</gl-link>
</template>
-
<gl-form-input
id="wiki_title"
v-model="title"
@@ -395,7 +337,7 @@ export default {
:checked="editingMode"
:options="$options.switchEditingControlOptions"
:disabled="switchEditingControlDisabled"
- @input="toggleEditingMode"
+ @input="setEditingMode"
/>
</div>
<local-storage-sync
@@ -436,13 +378,20 @@ export default {
<content-editor
:render-markdown="renderMarkdown"
:uploads-path="pageInfo.uploadsPath"
- @initialized="loadInitialContent"
+ :markdown="content"
+ @initialized="trackContentEditorLoaded"
@change="handleContentEditorChange"
@loading="disableSwitchEditingControl"
@loadingSuccess="enableSwitchEditingControl"
@loadingError="enableSwitchEditingControl"
/>
- <input id="wiki_content" v-model.trim="content" type="hidden" name="wiki[content]" />
+ <input
+ id="wiki_content"
+ v-model.trim="content"
+ type="hidden"
+ name="wiki[content]"
+ data-qa-selector="wiki_hidden_content"
+ />
</div>
<div class="clearfix"></div>
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 94506d33b33..9e0af426f6e 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -1,8 +1,8 @@
import { select } from 'd3-selection';
-import dateFormat from 'dateformat';
import $ from 'jquery';
import { last } from 'lodash';
import createFlash from '~/flash';
+import dateFormat from '~/lib/dateformat';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
diff --git a/app/assets/javascripts/pages/users/user_overview_block.js b/app/assets/javascripts/pages/users/user_overview_block.js
index a7c3c9d104d..8d2d66d812e 100644
--- a/app/assets/javascripts/pages/users/user_overview_block.js
+++ b/app/assets/javascripts/pages/users/user_overview_block.js
@@ -33,6 +33,7 @@ export default class UserOverviewBlock {
const containerEl = document.querySelector(this.container);
const contentList = containerEl.querySelector('.overview-content-list');
+ // eslint-disable-next-line no-unsanitized/property
contentList.innerHTML += html;
const loadingEl = containerEl.querySelector('.loading');
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index 644eccc0232..ddc880db227 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -2,10 +2,10 @@
import pdfjsLib from 'pdfjs-dist/build/pdf';
import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
-import page from './page/index.vue';
+import Page from './page/index.vue';
export default {
- components: { page },
+ components: { Page },
props: {
pdf: {
type: [String, Uint8Array],
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 9cea89f4990..7e331bdd91d 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -7,12 +7,13 @@ const DEFERRED_LINK_CLASS = 'deferred-link';
export default class PersistentUserCallout {
constructor(container, options = container.dataset) {
- const { dismissEndpoint, featureId, groupId, namespaceId, deferLinks } = options;
+ const { dismissEndpoint, featureId, groupId, namespaceId, projectId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
this.groupId = groupId;
this.namespaceId = namespaceId;
+ this.projectId = projectId;
this.deferLinks = parseBoolean(deferLinks);
this.closeButtons = this.container.querySelectorAll('.js-close');
@@ -58,6 +59,7 @@ export default class PersistentUserCallout {
feature_name: this.featureId,
group_id: this.groupId,
namespace_id: this.namespaceId,
+ project_id: this.projectId,
})
.then(() => {
this.container.remove();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index ead512e3574..2580cbcb944 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -17,6 +17,9 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-submit-license-usage-data-banner',
'.js-project-usage-limitations-callout',
'.js-namespace-storage-alert',
+ '.js-web-hook-disabled-callout',
+ '.js-merge-request-settings-callout',
+ '.js-ultimate-feature-removal-banner',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 4398ba67d47..1f8ddae3696 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -237,7 +237,7 @@ export default {
v-for="branch in availableBranches"
:key="branch"
:is-checked="currentBranch === branch"
- :is-check-item="true"
+ is-check-item
data-qa-selector="branch_menu_item_button"
@click="selectBranch(branch)"
>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
index 7beabcfe403..feadc60a22a 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue
@@ -1,6 +1,6 @@
<script>
import { __ } from '~/locale';
-import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
import { PIPELINE_FAILURE } from '../../constants';
@@ -10,8 +10,6 @@ export default {
},
components: {
PipelineMiniGraph,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
inject: ['projectFullPath'],
props: {
@@ -47,9 +45,6 @@ export default {
downstreamPipelines() {
return this.linkedPipelines?.downstream?.nodes || [];
},
- hasDownstreamPipelines() {
- return this.downstreamPipelines.length > 0;
- },
hasPipelineStages() {
return this.pipelineStages.length > 0;
},
@@ -87,23 +82,11 @@ export default {
</script>
<template>
- <div
+ <pipeline-mini-graph
v-if="hasPipelineStages"
- class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell gl-mr-5"
- >
- <linked-pipelines-mini-list
- v-if="upstreamPipeline"
- :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- upstreamPipeline,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- data-testid="pipeline-editor-mini-graph-upstream"
- />
- <pipeline-mini-graph :stages="pipelineStages" />
- <linked-pipelines-mini-list
- v-if="hasDownstreamPipelines"
- :triggered="downstreamPipelines"
- :pipeline-path="pipelinePath"
- data-testid="pipeline-editor-mini-graph-downstream"
- />
- </div>
+ :downstream-pipelines="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ :stages="pipelineStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 4b9c98135ec..137dfca68d6 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -174,7 +174,7 @@ export default {
<div class="gl-display-flex gl-flex-wrap">
<pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" />
<gl-button
- class="gl-mt-2 gl-md-mt-0"
+ class="gl-ml-3"
category="secondary"
variant="confirm"
:href="status.detailsPath"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 3fd31edec2c..548769eb214 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -47,6 +47,7 @@ export default {
currentCiFileContent: '',
failureType: null,
failureReasons: [],
+ hasBranchLoaded: false,
initialCiFileContent: '',
isFetchingCommitSha: false,
isLintUnavailable: false,
@@ -234,7 +235,7 @@ export default {
return this.lastCommittedContent !== this.currentCiFileContent;
},
isBlobContentLoading() {
- return this.$apollo.queries.initialCiFileContent.loading;
+ return !this.hasBranchLoaded || this.$apollo.queries.initialCiFileContent.loading;
},
isCiConfigDataLoading() {
return this.$apollo.queries.ciConfigData.loading;
@@ -243,7 +244,7 @@ export default {
return this.currentCiFileContent === '';
},
shouldSkipBlobContentQuery() {
- return this.isNewCiConfigFile || this.lastCommittedContent || !this.currentBranch;
+ return this.isNewCiConfigFile || this.lastCommittedContent || !this.hasBranchLoaded;
},
shouldSkipCiConfigQuery() {
return !this.currentCiFileContent || !this.commitSha;
@@ -264,6 +265,17 @@ export default {
},
},
watch: {
+ currentBranch: {
+ immediate: true,
+ handler(branch) {
+ // currentBranch is a client query so it starts off undefined. In the index.js,
+ // write to the apollo cache. Once that operation is done, we can safely do operations
+ // that require the branch to have loaded.
+ if (branch) {
+ this.hasBranchLoaded = true;
+ }
+ },
+ },
isEmpty(flag) {
if (flag) {
this.setAppStatus(EDITOR_APP_STATUS_EMPTY);
diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue
new file mode 100644
index 00000000000..529ec4897b4
--- /dev/null
+++ b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue
@@ -0,0 +1,490 @@
+<script>
+import {
+ GlAlert,
+ GlIcon,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+ GlLoadingIcon,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { uniqueId } from 'lodash';
+import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+import { backOff } from '~/lib/utils/common_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { s__, __, n__ } from '~/locale';
+import {
+ VARIABLE_TYPE,
+ FILE_TYPE,
+ CONFIG_VARIABLES_TIMEOUT,
+ CC_VALIDATION_REQUIRED_ERROR,
+} from '../constants';
+import filterVariables from '../utils/filter_variables';
+import RefsDropdown from './refs_dropdown.vue';
+
+const i18n = {
+ variablesDescription: s__(
+ 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.',
+ ),
+ defaultError: __('Something went wrong on our end. Please try again.'),
+ refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'),
+ submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'),
+ warningTitle: __('The form contains the following warning:'),
+ maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'),
+ removeVariableLabel: s__('CiVariables|Remove variable'),
+};
+
+export default {
+ 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
+ // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used
+ textAreaStyle: { height: '32px' },
+ components: {
+ GlAlert,
+ GlIcon,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlForm,
+ GlFormGroup,
+ GlFormInput,
+ GlFormTextarea,
+ GlLink,
+ GlSprintf,
+ GlLoadingIcon,
+ RefsDropdown,
+ CcValidationRequiredAlert: () =>
+ import('ee_component/billings/components/cc_validation_required_alert.vue'),
+ },
+ directives: { SafeHtml },
+ props: {
+ pipelinesPath: {
+ type: String,
+ required: true,
+ },
+ configVariablesPath: {
+ type: String,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ settingsLink: {
+ type: String,
+ required: true,
+ },
+ fileParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ refParam: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ variableParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ maxWarnings: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ refValue: {
+ shortName: this.refParam,
+ },
+ form: {},
+ errorTitle: null,
+ error: null,
+ warnings: [],
+ totalWarnings: 0,
+ isWarningDismissed: false,
+ isLoading: false,
+ submitted: false,
+ ccAlertDismissed: false,
+ };
+ },
+ computed: {
+ overMaxWarningsLimit() {
+ return this.totalWarnings > this.maxWarnings;
+ },
+ warningsSummary() {
+ return n__('%d warning found:', '%d warnings found:', this.warnings.length);
+ },
+ summaryMessage() {
+ return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary;
+ },
+ shouldShowWarning() {
+ return this.warnings.length > 0 && !this.isWarningDismissed;
+ },
+ refShortName() {
+ return this.refValue.shortName;
+ },
+ refFullName() {
+ return this.refValue.fullName;
+ },
+ variables() {
+ return this.form[this.refFullName]?.variables ?? [];
+ },
+ descriptions() {
+ return this.form[this.refFullName]?.descriptions ?? {};
+ },
+ ccRequiredError() {
+ return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed;
+ },
+ },
+ watch: {
+ refValue() {
+ this.loadConfigVariablesForm();
+ },
+ },
+ created() {
+ // this is needed until we add support for ref type in url query strings
+ // ensure default branch is called with full ref on load
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ if (this.refValue.shortName === this.defaultBranch) {
+ this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
+ }
+
+ this.loadConfigVariablesForm();
+ },
+ methods: {
+ addEmptyVariable(refValue) {
+ const { variables } = this.form[refValue];
+
+ const lastVar = variables[variables.length - 1];
+ if (lastVar?.key === '' && lastVar?.value === '') {
+ return;
+ }
+
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
+ variable_type: VARIABLE_TYPE,
+ key: '',
+ value: '',
+ });
+ },
+ setVariable(refValue, type, key, value) {
+ const { variables } = this.form[refValue];
+
+ const variable = variables.find((v) => v.key === key);
+ if (variable) {
+ variable.type = type;
+ variable.value = value;
+ } else {
+ variables.push({
+ uniqueId: uniqueId(`var-${refValue}`),
+ key,
+ value,
+ variable_type: type,
+ });
+ }
+ },
+ 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);
+ });
+ },
+ removeVariable(index) {
+ this.variables.splice(index, 1);
+ },
+ canRemove(index) {
+ return index < this.variables.length - 1;
+ },
+ loadConfigVariablesForm() {
+ // Skip when variables already cached in `form`
+ if (this.form[this.refFullName]) {
+ return;
+ }
+
+ this.fetchConfigVariables(this.refFullName || this.refShortName)
+ .then(({ descriptions, params }) => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions,
+ });
+
+ // Add default variables from yml
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
+ })
+ .catch(() => {
+ Vue.set(this.form, this.refFullName, {
+ variables: [],
+ descriptions: {},
+ });
+ })
+ .finally(() => {
+ // Add/update variables, e.g. from query string
+ if (this.variableParams) {
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
+ }
+ if (this.fileParams) {
+ this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
+ }
+
+ // Adds empty var at the end of the form
+ this.addEmptyVariable(this.refFullName);
+ });
+ },
+ fetchConfigVariables(refValue) {
+ this.isLoading = true;
+
+ return backOff((next, stop) => {
+ axios
+ .get(this.configVariablesPath, {
+ params: {
+ sha: refValue,
+ },
+ })
+ .then(({ data, status }) => {
+ if (status === httpStatusCodes.NO_CONTENT) {
+ next();
+ } else {
+ this.isLoading = false;
+ stop(data);
+ }
+ })
+ .catch((error) => {
+ stop(error);
+ });
+ }, CONFIG_VARIABLES_TIMEOUT)
+ .then((data) => {
+ const params = {};
+ const descriptions = {};
+
+ Object.entries(data).forEach(([key, { value, description }]) => {
+ if (description) {
+ params[key] = value;
+ descriptions[key] = description;
+ }
+ });
+
+ return { params, descriptions };
+ })
+ .catch((error) => {
+ this.isLoading = false;
+
+ Sentry.captureException(error);
+
+ return { params: {}, descriptions: {} };
+ });
+ },
+ createPipeline() {
+ this.submitted = true;
+ this.ccAlertDismissed = false;
+
+ return axios
+ .post(this.pipelinesPath, {
+ // send shortName as fall back for query params
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ ref: this.refValue.fullName || this.refShortName,
+ variables_attributes: filterVariables(this.variables),
+ })
+ .then(({ data }) => {
+ redirectTo(`${this.pipelinesPath}/${data.id}`);
+ })
+ .catch((err) => {
+ // always re-enable submit button
+ this.submitted = false;
+
+ const {
+ errors = [],
+ warnings = [],
+ total_warnings: totalWarnings = 0,
+ } = err.response.data;
+ const [error] = errors;
+
+ this.reportError({
+ title: i18n.submitErrorTitle,
+ error,
+ warnings,
+ totalWarnings,
+ });
+ });
+ },
+ onRefsLoadingError(error) {
+ this.reportError({ title: i18n.refsLoadingErrorTitle });
+
+ Sentry.captureException(error);
+ },
+ reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) {
+ this.errorTitle = title;
+ this.error = error;
+ this.warnings = warnings;
+ this.totalWarnings = totalWarnings;
+ },
+ dismissError() {
+ this.ccAlertDismissed = true;
+ this.error = null;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form @submit.prevent="createPipeline">
+ <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" />
+ <gl-alert
+ v-else-if="error"
+ :title="errorTitle"
+ :dismissible="false"
+ variant="danger"
+ class="gl-mb-4"
+ data-testid="run-pipeline-error-alert"
+ >
+ <span v-safe-html="error"></span>
+ </gl-alert>
+ <gl-alert
+ v-if="shouldShowWarning"
+ :title="$options.i18n.warningTitle"
+ variant="warning"
+ class="gl-mb-4"
+ data-testid="run-pipeline-warning-alert"
+ @dismiss="isWarningDismissed = true"
+ >
+ <details>
+ <summary>
+ <gl-sprintf :message="summaryMessage">
+ <template #total>
+ {{ totalWarnings }}
+ </template>
+ <template #warningsDisplayed>
+ {{ maxWarnings }}
+ </template>
+ </gl-sprintf>
+ </summary>
+ <p
+ v-for="(warning, index) in warnings"
+ :key="`warning-${index}`"
+ data-testid="run-pipeline-warning"
+ >
+ {{ warning }}
+ </p>
+ </details>
+ </gl-alert>
+ <gl-form-group :label="s__('Pipeline|Run for branch name or tag')">
+ <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" />
+ </gl-form-group>
+
+ <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" />
+
+ <gl-form-group v-else :label="s__('Pipeline|Variables')">
+ <div
+ v-for="(variable, index) in variables"
+ :key="variable.uniqueId"
+ class="gl-mb-3 gl-ml-n3 gl-pb-2"
+ data-testid="ci-variable-row"
+ data-qa-selector="ci_variable_row_container"
+ >
+ <div
+ class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row"
+ >
+ <gl-dropdown
+ :text="$options.typeOptions[variable.variable_type]"
+ :class="$options.formElementClasses"
+ 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')"
+ :class="$options.formElementClasses"
+ data-testid="pipeline-form-ci-variable-key"
+ data-qa-selector="ci_variable_key_field"
+ @change="addEmptyVariable(refFullName)"
+ />
+ <gl-form-textarea
+ v-model="variable.value"
+ :placeholder="s__('CiVariables|Input variable value')"
+ class="gl-mb-3"
+ :style="$options.textAreaStyle"
+ :no-resize="false"
+ data-testid="pipeline-form-ci-variable-value"
+ data-qa-selector="ci_variable_value_field"
+ />
+
+ <template v-if="variables.length > 1">
+ <gl-button
+ v-if="canRemove(index)"
+ class="gl-md-ml-3 gl-mb-3"
+ data-testid="remove-ci-variable-row"
+ variant="danger"
+ category="secondary"
+ :aria-label="$options.i18n.removeVariableLabel"
+ @click="removeVariable(index)"
+ >
+ <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" />
+ <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span>
+ </gl-button>
+ <gl-button
+ v-else
+ class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden"
+ icon="clear"
+ :aria-label="$options.i18n.removeVariableLabel"
+ />
+ </template>
+ </div>
+ <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3">
+ {{ descriptions[variable.key] }}
+ </div>
+ </div>
+
+ <template #description
+ ><gl-sprintf :message="$options.i18n.variablesDescription">
+ <template #link="{ content }">
+ <gl-link :href="settingsLink">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf></template
+ >
+ </gl-form-group>
+ <div class="gl-pt-5 gl-display-flex">
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="confirm"
+ class="js-no-auto-disable gl-mr-3"
+ data-qa-selector="run_pipeline_button"
+ data-testid="run_pipeline_button"
+ :disabled="submitted"
+ >{{ s__('Pipeline|Run pipeline') }}</gl-button
+ >
+ <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button>
+ </div>
+ </gl-form>
+</template>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index 9378b67b915..529ec4897b4 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -282,7 +282,7 @@ export default {
const descriptions = {};
Object.entries(data).forEach(([key, { value, description }]) => {
- if (description !== null) {
+ if (description) {
params[key] = value;
descriptions[key] = description;
}
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js
index 927eeb5e144..e3f363f4ada 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/pipeline_new/index.js
@@ -1,27 +1,72 @@
import Vue from 'vue';
+import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
-export default () => {
- const el = document.getElementById('js-new-pipeline');
+const mountLegacyPipelineNewForm = (el) => {
const {
// provide/inject
projectRefsEndpoint,
// props
- projectId,
- pipelinesPath,
configVariablesPath,
defaultBranch,
+ fileParam,
+ maxWarnings,
+ pipelinesPath,
+ projectId,
refParam,
+ settingsLink,
varParam,
+ } = el.dataset;
+
+ const variableParams = JSON.parse(varParam);
+ const fileParams = JSON.parse(fileParam);
+
+ return new Vue({
+ el,
+ provide: {
+ projectRefsEndpoint,
+ },
+ render(createElement) {
+ return createElement(LegacyPipelineNewForm, {
+ props: {
+ configVariablesPath,
+ defaultBranch,
+ fileParams,
+ maxWarnings: Number(maxWarnings),
+ pipelinesPath,
+ projectId,
+ refParam,
+ settingsLink,
+ variableParams,
+ },
+ });
+ },
+ });
+};
+
+const mountPipelineNewForm = (el) => {
+ const {
+ // provide/inject
+ projectRefsEndpoint,
+
+ // props
+ configVariablesPath,
+ defaultBranch,
fileParam,
- settingsLink,
maxWarnings,
+ pipelinesPath,
+ projectId,
+ refParam,
+ settingsLink,
+ varParam,
} = el.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
+ // TODO: add apolloProvider
+
return new Vue({
el,
provide: {
@@ -30,17 +75,27 @@ export default () => {
render(createElement) {
return createElement(PipelineNewForm, {
props: {
- projectId,
- pipelinesPath,
configVariablesPath,
defaultBranch,
- refParam,
- variableParams,
fileParams,
- settingsLink,
maxWarnings: Number(maxWarnings),
+ pipelinesPath,
+ projectId,
+ refParam,
+ settingsLink,
+ variableParams,
},
});
},
});
};
+
+export default () => {
+ const el = document.getElementById('js-new-pipeline');
+
+ if (gon.features?.runPipelineGraphql) {
+ mountPipelineNewForm(el);
+ } else {
+ mountLegacyPipelineNewForm(el);
+ }
+};
diff --git a/app/assets/javascripts/pipeline_wizard/components/editor.vue b/app/assets/javascripts/pipeline_wizard/components/editor.vue
index 41611233f71..0c063241173 100644
--- a/app/assets/javascripts/pipeline_wizard/components/editor.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/editor.vue
@@ -27,7 +27,7 @@ export default {
data() {
return {
editor: null,
- isUpdating: false,
+ isFocused: false,
yamlEditorExtension: null,
};
},
@@ -60,19 +60,23 @@ export default {
this.editor.onDidChangeModelContent(
debounce(() => this.handleChange(), CONTENT_UPDATE_DEBOUNCE),
);
+ this.editor.onDidFocusEditorText(() => {
+ this.isFocused = true;
+ });
+ this.editor.onDidBlurEditorText(() => {
+ this.isFocused = false;
+ });
this.updateEditorContent();
this.emitValue();
},
methods: {
async updateEditorContent() {
- this.isUpdating = true;
this.editor.setDoc(this.doc);
- this.isUpdating = false;
this.requestHighlight(this.highlight);
},
handleChange() {
this.emitValue();
- if (!this.isUpdating) {
+ if (this.isFocused) {
this.handleTouch();
}
},
diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
index 0fe87bcee7b..adeb4ae598b 100644
--- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
+++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue
@@ -5,6 +5,7 @@ import { uniqueId } from 'lodash';
import { merge } from '~/lib/utils/yaml';
import { __ } from '~/locale';
import { isValidStepSeq } from '~/pipeline_wizard/validators';
+import Tracking from '~/tracking';
import YamlEditor from './editor.vue';
import WizardStep from './step.vue';
import CommitStep from './commit.vue';
@@ -16,6 +17,8 @@ export const i18n = {
YAML-file for you to add to your repository`),
};
+const trackingMixin = Tracking.mixin();
+
export default {
name: 'PipelineWizardWrapper',
i18n,
@@ -25,6 +28,7 @@ export default {
WizardStep,
CommitStep,
},
+ mixins: [trackingMixin],
props: {
steps: {
type: Object,
@@ -43,6 +47,11 @@ export default {
type: String,
required: true,
},
+ templateId: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -77,6 +86,11 @@ export default {
template: this.steps.get(i).get('template', true),
}));
},
+ tracking() {
+ return {
+ category: `pipeline_wizard:${this.templateId}`,
+ };
+ },
},
watch: {
isLastStep(value) {
@@ -84,9 +98,6 @@ export default {
},
},
methods: {
- getStep(index) {
- return this.steps.get(index);
- },
resetHighlight() {
this.highlightPath = null;
},
@@ -106,6 +117,43 @@ export default {
});
return doc;
},
+ onBack() {
+ this.currentStepIndex -= 1;
+ this.track('click_button', {
+ property: 'back',
+ label: 'pipeline_wizard_navigation',
+ extra: {
+ fromStep: this.currentStepIndex + 1,
+ toStep: this.currentStepIndex,
+ },
+ });
+ },
+ onNext() {
+ this.currentStepIndex += 1;
+ this.track('click_button', {
+ property: 'next',
+ label: 'pipeline_wizard_navigation',
+ extra: {
+ fromStep: this.currentStepIndex - 1,
+ toStep: this.currentStepIndex,
+ },
+ });
+ },
+ onDone() {
+ this.$emit('done');
+ this.track('click_button', {
+ label: 'pipeline_wizard_commit',
+ property: 'commit',
+ });
+ },
+ onEditorTouched() {
+ this.track('edit', {
+ label: 'pipeline_wizard_editor_interaction',
+ extra: {
+ currentStep: this.currentStepIndex,
+ },
+ });
+ },
},
};
</script>
@@ -127,8 +175,8 @@ export default {
:file-content="pipelineBlob"
:filename="filename"
:project-path="projectPath"
- @back="currentStepIndex--"
- @done="$emit('done')"
+ @back="onBack"
+ @done="onDone"
/>
<wizard-step
v-for="(step, i) in stepList"
@@ -141,8 +189,8 @@ export default {
:highlight.sync="highlightPath"
:inputs="step.inputs"
:template="step.template"
- @back="currentStepIndex--"
- @next="currentStepIndex++"
+ @back="onBack"
+ @next="onNext"
@update:compiled="onUpdate"
/>
</section>
@@ -162,6 +210,7 @@ export default {
:highlight="highlightPath"
class="gl-w-full"
@update:yaml="onEditorUpdate"
+ @touch.once="onEditorTouched"
/>
<div
v-if="showPlaceholder"
diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
index 79b1507ad0e..5a93de3b1be 100644
--- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
+++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue
@@ -42,6 +42,9 @@ export default {
steps() {
return this.parsedTemplate?.get('steps');
},
+ templateId() {
+ return this.parsedTemplate?.get('id');
+ },
},
};
</script>
@@ -60,6 +63,7 @@ export default {
:filename="filename"
:project-path="projectPath"
:steps="steps"
+ :template-id="templateId"
@done="$emit('done')"
/>
</div>
diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
index cd2242b1ba7..9d7936f2f5a 100644
--- a/app/assets/javascripts/pipeline_wizard/templates/pages.yml
+++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml
@@ -1,3 +1,4 @@
+id: gitlab/pages
title: Get started with Pages
description: "GitLab Pages lets you deploy static websites in minutes. All you
need is a .gitlab-ci.yml file. Follow the below steps to
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 31a34ab4fb5..1a05710a13e 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -170,7 +170,7 @@ export default {
ref="mainPipelineContainer"
class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap"
:class="{
- 'gl-pipeline-min-h gl-py-5 gl-overflow-auto gl-border-t-solid gl-border-t-1 gl-border-gray-100': !isLinkedPipeline,
+ 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline,
}"
>
<linked-graph-wrapper>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 14872c34afb..f822e2c0874 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -281,7 +281,6 @@ export default {
:type="graphViewType"
:show-links="showLinks"
:tip-previously-dismissed="hoverTipPreviouslyDismissed"
- :is-pipeline-complete="pipeline.complete"
@dismissHoverTip="handleTipDismissal"
@updateViewType="updateViewType"
@updateShowLinksState="updateShowLinksState"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
index a8c5d85f4ed..6d8c35f4482 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -1,33 +1,19 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlButtonGroup,
- GlLoadingIcon,
- GlToggle,
- GlModalDirective,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui';
import { __, s__ } from '~/locale';
-import Tracking from '~/tracking';
-import PerformanceInsightsModal from '../performance_insights_modal.vue';
-import { performanceModalId } from '../../constants';
import { STAGE_VIEW, LAYER_VIEW } from './constants';
export default {
name: 'GraphViewSelector',
- performanceModalId,
+
components: {
GlAlert,
GlButton,
GlButtonGroup,
GlLoadingIcon,
GlToggle,
- PerformanceInsightsModal,
- },
- directives: {
- GlModal: GlModalDirective,
},
- mixins: [Tracking.mixin()],
+
props: {
showLinks: {
type: Boolean,
@@ -41,10 +27,6 @@ export default {
type: String,
required: true,
},
- isPipelineComplete: {
- type: Boolean,
- required: true,
- },
},
data() {
return {
@@ -59,7 +41,6 @@ export default {
hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'),
linksLabelText: s__('GraphViewType|Show dependencies'),
viewLabelText: __('Group jobs by'),
- performanceBtnText: __('Performance insights'),
},
views: {
[STAGE_VIEW]: {
@@ -150,9 +131,6 @@ export default {
this.$emit('updateShowLinksState', val);
});
},
- trackInsightsClick() {
- this.track('click_insights_button', { label: 'performance_insights' });
- },
},
};
</script>
@@ -178,15 +156,6 @@ export default {
</gl-button>
</gl-button-group>
- <gl-button
- v-if="isPipelineComplete"
- v-gl-modal="$options.performanceModalId"
- data-testid="pipeline-insights-btn"
- @click="trackInsightsClick"
- >
- {{ $options.i18n.performanceBtnText }}
- </gl-button>
-
<div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center">
<gl-toggle
v-model="showLinksActive"
@@ -202,7 +171,5 @@ export default {
<gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip">
{{ $options.i18n.hoverTipText }}
</gl-alert>
-
- <performance-insights-modal />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 8d764fad0c5..02d0c07ea54 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -82,7 +82,9 @@ export default {
:stage-name="stageName"
/>
- <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
+ <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4 gl-align-self-center">
+ {{ group.size }}
+ </div>
</div>
</button>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 6ab4eb58977..4aec28295bd 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,5 +1,5 @@
<script>
-import { capitalize, escape, isEmpty } from 'lodash';
+import { escape, isEmpty } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { reportToSentry } from '../../utils';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
@@ -64,8 +64,7 @@ export default {
},
},
jobClasses: [
- 'gl-py-3',
- 'gl-px-4',
+ 'gl-p-3',
'gl-border-gray-100',
'gl-border-solid',
'gl-border-1',
@@ -92,9 +91,6 @@ export default {
columnSpacingClass() {
return this.isStageView ? 'gl-px-6' : 'gl-px-9';
},
- formattedTitle() {
- return capitalize(escape(this.name));
- },
hasAction() {
return !isEmpty(this.action);
},
@@ -141,8 +137,8 @@ export default {
class="gl-display-flex gl-justify-content-space-between gl-relative"
:class="$options.titleClasses"
>
- <span :title="formattedTitle" class="gl-text-truncate gl-pr-3 gl-w-85p">
- {{ formattedTitle }}
+ <span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p">
+ {{ name }}
</span>
<action-component
v-if="hasAction && canUpdatePipeline"
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index fabae62fc45..a36d5d9b58f 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -9,7 +9,7 @@ import {
} from '@gitlab/ui';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
-import ciHeader from '~/vue_shared/components/header_ci_component.vue';
+import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import {
LOAD_FAILURE,
POST_FAILURE,
@@ -33,7 +33,7 @@ export default {
pipelineRetry: 'pipelineRetry',
finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'],
components: {
- ciHeader,
+ CiHeader,
GlAlert,
GlButton,
GlLoadingIcon,
diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
index 70d1a5c08cc..f4fc6893520 100644
--- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
@@ -1,5 +1,5 @@
<script>
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
/**
* Component that renders both the CI icon status and the job name.
@@ -9,7 +9,7 @@ import ciIcon from '~/vue_shared/components/ci_icon.vue';
*/
export default {
components: {
- ciIcon,
+ CiIcon,
},
props: {
name: {
diff --git a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue b/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
deleted file mode 100644
index fdbf0ca19bc..00000000000
--- a/app/assets/javascripts/pipelines/components/performance_insights_modal.vue
+++ /dev/null
@@ -1,171 +0,0 @@
-<script>
-import { GlAlert, GlCard, GlLink, GlLoadingIcon, GlModal } from '@gitlab/ui';
-import { __, s__ } from '~/locale';
-import { humanizeTimeInterval } from '~/lib/utils/datetime_utility';
-import HelpPopover from '~/vue_shared/components/help_popover.vue';
-import getPerformanceInsightsQuery from '../graphql/queries/get_performance_insights.query.graphql';
-import { performanceModalId } from '../constants';
-import { calculateJobStats, calculateSlowestFiveJobs } from '../utils';
-
-export default {
- name: 'PerformanceInsightsModal',
- i18n: {
- queuedCardHeader: s__('Pipeline|Longest queued job'),
- queuedCardHelp: s__(
- 'Pipeline|The longest queued job is the job that spent the longest time in the pending state, waiting to be picked up by a Runner',
- ),
- executedCardHeader: s__('Pipeline|Last executed job'),
- executedCardHelp: s__(
- 'Pipeline|The last executed job is the last job to start in the pipeline.',
- ),
- viewDependency: s__('Pipeline|View dependency'),
- slowJobsTitle: s__('Pipeline|Five slowest jobs'),
- feeback: __('Feedback issue'),
- insightsLimit: s__('Pipeline|Only able to show first 100 results'),
- },
- modal: {
- title: s__('Pipeline|Performance insights'),
- actionCancel: {
- text: __('Close'),
- attributes: {
- variant: 'confirm',
- },
- },
- },
- performanceModalId,
- components: {
- GlAlert,
- GlCard,
- GlLink,
- GlModal,
- GlLoadingIcon,
- HelpPopover,
- },
- inject: {
- pipelineIid: {
- default: '',
- },
- pipelineProjectPath: {
- default: '',
- },
- },
- apollo: {
- jobs: {
- query: getPerformanceInsightsQuery,
- variables() {
- return {
- fullPath: this.pipelineProjectPath,
- iid: this.pipelineIid,
- };
- },
- update(data) {
- return data.project?.pipeline?.jobs;
- },
- },
- },
- data() {
- return {
- jobs: null,
- };
- },
- computed: {
- longestQueuedJob() {
- return calculateJobStats(this.jobs, 'queuedDuration');
- },
- lastExecutedJob() {
- return calculateJobStats(this.jobs, 'startedAt');
- },
- slowestFiveJobs() {
- return calculateSlowestFiveJobs(this.jobs);
- },
- queuedDurationDisplay() {
- return humanizeTimeInterval(this.longestQueuedJob.queuedDuration);
- },
- showLimitMessage() {
- return this.jobs.pageInfo.hasNextPage;
- },
- },
-};
-</script>
-
-<template>
- <gl-modal
- :modal-id="$options.performanceModalId"
- :title="$options.modal.title"
- :action-cancel="$options.modal.actionCancel"
- >
- <gl-loading-icon v-if="$apollo.queries.jobs.loading" size="lg" />
-
- <template v-else>
- <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-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>
- <help-popover>
- {{ $options.i18n.queuedCardHelp }}
- </help-popover>
- </template>
- <div class="gl-display-flex gl-flex-direction-column">
- <span
- class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
- data-testid="insights-queued-card-data"
- >
- {{ queuedDurationDisplay }}
- </span>
- <gl-link
- :href="longestQueuedJob.detailedStatus.detailsPath"
- data-testid="insights-queued-card-link"
- >
- {{ longestQueuedJob.name }}
- </gl-link>
- </div>
- </gl-card>
- <gl-card class="gl-w-half gl-text-center" data-testid="insights-executed-card">
- <template #header>
- <span class="gl-font-weight-bold">{{ $options.i18n.executedCardHeader }}</span>
- <help-popover>
- {{ $options.i18n.executedCardHelp }}
- </help-popover>
- </template>
- <div class="gl-display-flex gl-flex-direction-column">
- <span
- class="gl-font-weight-bold gl-font-size-h2 gl-mb-2"
- data-testid="insights-executed-card-data"
- >
- {{ lastExecutedJob.name }}
- </span>
- <gl-link
- :href="lastExecutedJob.detailedStatus.detailsPath"
- data-testid="insights-executed-card-link"
- >
- {{ $options.i18n.viewDependency }}
- </gl-link>
- </div>
- </gl-card>
- </div>
-
- <div class="gl-mt-7">
- <span class="gl-font-weight-bold">{{ $options.i18n.slowJobsTitle }}</span>
- <div
- v-for="job in slowestFiveJobs"
- :key="job.name"
- class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-3 gl-p-4 gl-border-t-1 gl-border-t-solid gl-border-b-0 gl-border-b-solid gl-border-gray-100"
- >
- <span data-testid="insights-slow-job-stage">{{ job.stage.name }}</span>
- <gl-link :href="job.detailedStatus.detailsPath" data-testid="insights-slow-job-link">{{
- job.name
- }}</gl-link>
- </div>
- </div>
- </template>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 793e343a02a..3f1d7255a2b 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -1,9 +1,9 @@
<script>
-import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
- tooltipOnTruncate,
+ TooltipOnTruncate,
},
props: {
jobName: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
index e485b38ce11..600832b7633 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_name.vue
@@ -1,10 +1,9 @@
<script>
-import { capitalize, escape } from 'lodash';
-import tooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
- tooltipOnTruncate,
+ TooltipOnTruncate,
},
props: {
stageName: {
@@ -12,17 +11,12 @@ export default {
required: true,
},
},
- computed: {
- formattedTitle() {
- return capitalize(escape(this.stageName));
- },
- },
};
</script>
<template>
<tooltip-on-truncate :title="stageName" truncate-target="child" placement="top">
<div class="gl-py-2 gl-text-truncate gl-font-weight-bold gl-w-20">
- {{ formattedTitle }}
+ {{ stageName }}
</div>
</tooltip-on-truncate>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js
new file mode 100644
index 00000000000..1ca9e35c008
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/accessors/linked_pipelines_accessors.js
@@ -0,0 +1,14 @@
+import { get } from 'lodash';
+
+export const accessors = {
+ rest: {
+ detailedStatus: ['details', 'status'],
+ },
+ graphql: {
+ detailedStatus: 'detailedStatus',
+ },
+};
+
+export const accessValue = (pipeline, dataMethod, path) => {
+ return get(pipeline, accessors[dataMethod][path]);
+};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
index 670fa398536..211c5f117c7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue
@@ -158,7 +158,7 @@ export default {
:href="detailsPath"
:title="tooltipText"
:class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
+ class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
data-testid="job-with-link"
@click.stop="hideTooltips"
@mouseout="hideTooltips"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue
new file mode 100644
index 00000000000..a5c6dc98694
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/linked_pipelines_mini_list.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlTooltipDirective } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { accessValue } from './accessors/linked_pipelines_accessors';
+/**
+ * Renders the upstream/downstream portions of the pipeline mini graph.
+ */
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CiIcon,
+ },
+ inject: {
+ dataMethod: {
+ default: 'rest',
+ },
+ },
+ props: {
+ triggeredBy: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ triggered: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ pipelinePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ maxRenderedPipelines: 3,
+ };
+ },
+ computed: {
+ // Exactly one of these (triggeredBy and triggered) must be truthy. Never both. Never neither.
+ isUpstream() {
+ return Boolean(this.triggeredBy.length) && !this.triggered.length;
+ },
+ isDownstream() {
+ return !this.triggeredBy.length && Boolean(this.triggered.length);
+ },
+ linkedPipelines() {
+ return this.isUpstream ? this.triggeredBy : this.triggered;
+ },
+ totalPipelineCount() {
+ return this.linkedPipelines.length;
+ },
+ linkedPipelinesTrimmed() {
+ return this.totalPipelineCount > this.maxRenderedPipelines
+ ? this.linkedPipelines.slice(0, this.maxRenderedPipelines)
+ : this.linkedPipelines;
+ },
+ shouldRenderCounter() {
+ return this.isDownstream && this.linkedPipelines.length > this.maxRenderedPipelines;
+ },
+ counterLabel() {
+ return `+${this.linkedPipelines.length - this.maxRenderedPipelines}`;
+ },
+ counterTooltipText() {
+ return sprintf(s__('LinkedPipelines|%{counterLabel} more downstream pipelines'), {
+ counterLabel: this.counterLabel,
+ });
+ },
+ },
+ methods: {
+ pipelineTooltipText(pipeline) {
+ const { label } = accessValue(pipeline, this.dataMethod, 'detailedStatus');
+
+ return `${pipeline.project.name} - ${label}`;
+ },
+ pipelineStatus(pipeline) {
+ // detailedStatus is graphQL, details.status is REST
+ return pipeline?.detailedStatus || pipeline?.details?.status;
+ },
+ triggerButtonClass(pipeline) {
+ const { group } = accessValue(pipeline, this.dataMethod, 'detailedStatus');
+
+ return `ci-status-icon-${group}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ v-if="linkedPipelines"
+ :class="{
+ 'is-upstream': isUpstream,
+ 'is-downstream': isDownstream,
+ }"
+ class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle"
+ >
+ <a
+ v-for="pipeline in linkedPipelinesTrimmed"
+ :key="pipeline.id"
+ v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }"
+ :href="pipeline.path"
+ :class="triggerButtonClass(pipeline)"
+ class="linked-pipeline-mini-item gl-display-inline-block gl-h-6 gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle"
+ data-testid="linked-pipeline-mini-item"
+ >
+ <ci-icon
+ is-borderless
+ is-interactive
+ css-classes="gl-rounded-full"
+ :size="24"
+ :status="pipelineStatus(pipeline)"
+ class="gl-align-items-center gl-border gl-display-inline-flex"
+ />
+ </a>
+
+ <a
+ v-if="shouldRenderCounter"
+ v-gl-tooltip="{ title: counterTooltipText }"
+ :title="counterTooltipText"
+ :href="pipelinePath"
+ class="gl-align-items-center gl-bg-gray-50 gl-display-inline-flex gl-font-sm gl-h-6 gl-justify-content-center gl-rounded-pill gl-text-decoration-none gl-text-gray-500 gl-w-7 linked-pipelines-counter linked-pipeline-mini-item"
+ data-testid="linked-pipeline-counter"
+ >
+ {{ counterLabel }}
+ </a>
+ </span>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue
new file mode 100644
index 00000000000..993fa121d89
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+import PipelineStages from './pipeline_stages.vue';
+import LinkedPipelinesMiniList from './linked_pipelines_mini_list.vue';
+/**
+ * Renders the pipeline mini graph.
+ */
+export default {
+ components: {
+ GlIcon,
+ LinkedPipelinesMiniList,
+ PipelineStages,
+ },
+ arrowStyles: [
+ 'arrow-icon gl-display-inline-block gl-mx-1 gl-text-gray-500 gl-vertical-align-middle!',
+ ],
+ props: {
+ downstreamPipelines: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isMergeTrain: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pipelinePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stages: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ stagesClass: {
+ type: [Array, Object, String],
+ required: false,
+ default: '',
+ },
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ upstreamPipeline: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+ computed: {
+ hasDownstreamPipelines() {
+ return Boolean(this.downstreamPipelines.length);
+ },
+ },
+ methods: {
+ onPipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div class="stage-cell" data-testid="pipeline-mini-graph">
+ <linked-pipelines-mini-list
+ v-if="upstreamPipeline"
+ :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
+ upstreamPipeline,
+ ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
+ data-testid="pipeline-mini-graph-upstream"
+ />
+ <gl-icon
+ v-if="upstreamPipeline"
+ :class="$options.arrowStyles"
+ name="long-arrow"
+ data-testid="upstream-arrow-icon"
+ />
+ <pipeline-stages
+ :is-merge-train="isMergeTrain"
+ :stages="stages"
+ :update-dropdown="updateDropdown"
+ :stages-class="stagesClass"
+ data-testid="pipeline-stages"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ @miniGraphStageClick="$emit('miniGraphStageClick')"
+ />
+ <gl-icon
+ v-if="hasDownstreamPipelines"
+ :class="$options.arrowStyles"
+ name="long-arrow"
+ data-testid="downstream-arrow-icon"
+ />
+ <linked-pipelines-mini-list
+ v-if="hasDownstreamPipelines"
+ :triggered="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ data-testid="pipeline-mini-graph-downstream"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
index d7e55d36ff6..a68797a7235 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue
@@ -77,6 +77,10 @@ export default {
this.isDropdownOpen = true;
this.isLoading = true;
this.fetchJobs();
+
+ // used for tracking and is separate from event hub
+ // to avoid complexity with mixin
+ this.$emit('miniGraphStageClick');
},
fetchJobs() {
axios
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
index 05cb2ebb769..e965dc5e6b0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue
@@ -1,7 +1,7 @@
<script>
-import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue';
+import PipelineStage from './pipeline_stage.vue';
/**
- * Renders the pipeline mini graph.
+ * Renders the pipeline stages portion of the pipeline mini graph.
*/
export default {
components: {
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle">
+ <div data-testid="pipeline-stages" class="gl-display-inline gl-vertical-align-middle">
<div
v-for="stage in stages"
:key="stage.name"
@@ -48,6 +48,7 @@ export default {
:update-dropdown="updateDropdown"
:is-merge-train="isMergeTrain"
@pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ @miniGraphStageClick="$emit('miniGraphStageClick')"
/>
</div>
</div>
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 05a1ceface3..2d2f649f651 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
@@ -10,6 +10,8 @@ import {
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
+import { TRACKING_CATEGORIES } from '../../constants';
export const i18n = {
downloadArtifacts: __('Download artifacts'),
@@ -29,6 +31,7 @@ export default {
GlSearchBoxByType,
GlLoadingIcon,
},
+ mixins: [Tracking.mixin()],
inject: {
artifactsEndpoint: {
default: '',
@@ -60,6 +63,10 @@ export default {
},
methods: {
fetchArtifacts() {
+ // refactor tracking based on action once this dropdown supports
+ // actions other than artifacts
+ this.track('click_artifacts_dropdown', { label: TRACKING_CATEGORIES.table });
+
this.isLoading = true;
// Replace the placeholder with the ID of the pipeline we are viewing
const endpoint = this.artifactsEndpoint.replace(
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
index 7a08dacb824..dd62ffb27f7 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue
@@ -1,7 +1,8 @@
<script>
import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
+import Tracking from '~/tracking';
import eventHub from '../../event_hub';
-import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '../../constants';
+import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '../../constants';
import PipelineMultiActions from './pipeline_multi_actions.vue';
import PipelinesManualActions from './pipelines_manual_actions.vue';
@@ -17,6 +18,7 @@ export default {
PipelineMultiActions,
PipelinesManualActions,
},
+ mixins: [Tracking.mixin()],
props: {
pipeline: {
type: Object,
@@ -52,6 +54,7 @@ export default {
},
methods: {
handleCancelClick() {
+ this.trackClick('click_cancel_button');
eventHub.$emit('openConfirmationModal', {
pipeline: this.pipeline,
endpoint: this.pipeline.cancel_path,
@@ -59,8 +62,12 @@ export default {
},
handleRetryClick() {
this.isRetrying = true;
+ this.trackClick('click_retry_button');
eventHub.$emit('retryPipeline', this.pipeline.retry_path);
},
+ trackClick(action) {
+ this.track(action, { label: TRACKING_CATEGORIES.table });
+ },
},
};
</script>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index ef21673115e..eb70b5fbb7a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -83,9 +83,7 @@ export default {
<span class="font-weight-bold">{{ __('Pipeline') }}</span>
- <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
- >#{{ pipeline.id }}</a
- >
+ <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a>
<template v-if="hasRef">
{{ __('from') }}
<a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 09d588aaafd..39d41415456 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -1,9 +1,10 @@
<script>
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
+import Tracking from '~/tracking';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { ICONS } from '../../constants';
+import { ICONS, TRACKING_CATEGORIES } from '../../constants';
import PipelineLabels from './pipeline_labels.vue';
export default {
@@ -17,6 +18,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
props: {
pipeline: {
type: Object,
@@ -114,6 +116,11 @@ export default {
return this.pipeline?.commit?.title;
},
},
+ methods: {
+ trackClick(action) {
+ this.track(action, { label: TRACKING_CATEGORIES.table });
+ },
+ },
};
</script>
<template>
@@ -125,6 +132,7 @@ export default {
:href="commitUrl"
class="commit-row-message gl-text-gray-900"
data-testid="commit-title"
+ @click="trackClick('click_commit_title')"
>{{ commitTitle }}</gl-link
>
</tooltip-on-truncate>
@@ -137,6 +145,7 @@ export default {
class="gl-text-decoration-underline gl-text-blue-600! gl-mr-3"
data-testid="pipeline-url-link"
data-qa-selector="pipeline_url_link"
+ @click="trackClick('click_pipeline_id')"
>#{{ pipeline[pipelineKey] }}</gl-link
>
<!--Commit row-->
@@ -154,11 +163,17 @@ export default {
:href="mergeRequestRef.path"
class="ref-name gl-mr-3"
data-testid="merge-request-ref"
+ @click="trackClick('click_mr_ref')"
>{{ mergeRequestRef.iid }}</gl-link
>
- <gl-link v-else :href="refUrl" class="ref-name gl-mr-3" data-testid="commit-ref-name">{{
- commitRef.name
- }}</gl-link>
+ <gl-link
+ v-else
+ :href="refUrl"
+ class="ref-name gl-mr-3"
+ data-testid="commit-ref-name"
+ @click="trackClick('click_commit_name')"
+ >{{ commitRef.name }}</gl-link
+ >
</tooltip-on-truncate>
<gl-icon
v-gl-tooltip
@@ -167,9 +182,13 @@ export default {
:title="__('Commit')"
data-testid="commit-icon"
/>
- <gl-link :href="commitUrl" class="commit-sha mr-0" data-testid="commit-short-sha">{{
- commitShortSha
- }}</gl-link>
+ <gl-link
+ :href="commitUrl"
+ class="commit-sha mr-0"
+ data-testid="commit-short-sha"
+ @click="trackClick('click_commit_sha')"
+ >{{ commitShortSha }}</gl-link
+ >
<user-avatar-link
v-if="commitAuthor"
:link-href="commitAuthor.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 485e338f639..f9022be888a 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -4,6 +4,7 @@ import { isEqual } from 'lodash';
import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
+import Tracking from '~/tracking';
import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import {
@@ -11,6 +12,7 @@ import {
RAW_TEXT_WARNING,
FILTER_TAG_IDENTIFIER,
PipelineKeyOptions,
+ TRACKING_CATEGORIES,
} from '../../constants';
import PipelinesMixin from '../../mixins/pipelines_mixin';
import PipelinesService from '../../services/pipelines_service';
@@ -35,7 +37,7 @@ export default {
PipelinesTableComponent,
TablePagination,
},
- mixins: [PipelinesMixin],
+ mixins: [PipelinesMixin, Tracking.mixin()],
props: {
store: {
type: Object,
@@ -246,6 +248,8 @@ export default {
params = this.onChangeWithFilter(params);
this.updateContent(params);
+
+ this.track('click_filter_tabs', { label: TRACKING_CATEGORIES.tabs });
},
successCallback(resp) {
// Because we are polling & the user is interacting verify if the response received
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
index 4d28545a035..af089aebbbe 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
@@ -2,7 +2,9 @@
import { GlFilteredSearch } from '@gitlab/ui';
import { map } from 'lodash';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { TRACKING_CATEGORIES } from '../../constants';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
import PipelineSourceToken from './tokens/pipeline_source_token.vue';
import PipelineStatusToken from './tokens/pipeline_status_token.vue';
@@ -19,6 +21,7 @@ export default {
components: {
GlFilteredSearch,
},
+ mixins: [Tracking.mixin()],
props: {
projectId: {
type: String,
@@ -110,6 +113,7 @@ export default {
},
methods: {
onSubmit(filters) {
+ this.track('click_filtered_search', { label: TRACKING_CATEGORIES.search });
this.$emit('filterPipelines', filters);
},
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
index 47fffa8a6b2..16a747f6165 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue
@@ -4,8 +4,10 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { s__, __, sprintf } from '~/locale';
+import Tracking from '~/tracking';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
import eventHub from '../../event_hub';
+import { TRACKING_CATEGORIES } from '../../constants';
export default {
directives: {
@@ -17,6 +19,7 @@ export default {
GlDropdownItem,
GlIcon,
},
+ mixins: [Tracking.mixin()],
props: {
actions: {
type: Array,
@@ -66,7 +69,6 @@ export default {
createFlash({ message: __('An error occurred while making the request.') });
});
},
-
isActionDisabled(action) {
if (action.playable === undefined) {
return false;
@@ -74,6 +76,9 @@ export default {
return !action.playable;
},
+ trackClick() {
+ this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table });
+ },
},
};
</script>
@@ -86,6 +91,7 @@ export default {
right
lazy
icon="play"
+ @shown="trackClick"
>
<gl-dropdown-item
v-for="action in actions"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
index e765a8cd86c..936ae4da1ec 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue
@@ -1,6 +1,7 @@
<script>
-import { CHILD_VIEW } from '~/pipelines/constants';
+import { CHILD_VIEW, TRACKING_CATEGORIES } from '~/pipelines/constants';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import Tracking from '~/tracking';
import PipelinesTimeago from './time_ago.vue';
export default {
@@ -8,6 +9,7 @@ export default {
CiBadge,
PipelinesTimeago,
},
+ mixins: [Tracking.mixin()],
props: {
pipeline: {
type: Object,
@@ -26,6 +28,11 @@ export default {
return this.viewType === CHILD_VIEW;
},
},
+ methods: {
+ trackClick() {
+ this.track('click_ci_status_badge', { label: TRACKING_CATEGORIES.table });
+ },
+ },
};
</script>
@@ -37,6 +44,7 @@ export default {
:show-text="!isChildView"
:icon-classes="'gl-vertical-align-middle!'"
data-qa-selector="pipeline_commit_status"
+ @ciStatusBadgeClick="trackClick"
/>
<pipelines-timeago class="gl-mt-3" :pipeline="pipeline" />
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 53da98434b0..f6e46c090d3 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,8 +1,10 @@
<script>
import { GlTableLite, GlTooltipDirective } from '@gitlab/ui';
import { s__, __ } from '~/locale';
+import Tracking from '~/tracking';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import eventHub from '../../event_hub';
-import PipelineMiniGraph from './pipeline_mini_graph.vue';
+import { TRACKING_CATEGORIES } from '../../constants';
import PipelineOperations from './pipeline_operations.vue';
import PipelineStopModal from './pipeline_stop_modal.vue';
import PipelineTriggerer from './pipeline_triggerer.vue';
@@ -17,8 +19,6 @@ const DEFAULT_TH_CLASSES =
export default {
components: {
GlTableLite,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
PipelineMiniGraph,
PipelineOperations,
PipelinesStatusBadge,
@@ -70,6 +70,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [Tracking.mixin()],
props: {
pipelines: {
type: Array,
@@ -126,6 +127,9 @@ export default {
onPipelineActionRequestComplete() {
eventHub.$emit('refreshPipelinesTable');
},
+ trackPipelineMiniGraph() {
+ this.track('click_minigraph', { label: TRACKING_CATEGORIES.table });
+ },
},
TBODY_TR_ATTR: {
'data-testid': 'pipeline-table-row',
@@ -169,29 +173,15 @@ export default {
</template>
<template #cell(stages)="{ item }">
- <div class="stage-cell">
- <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 -->
- <div></div>
- <linked-pipelines-mini-list
- v-if="item.triggered_by"
- :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- item.triggered_by,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- data-testid="mini-graph-upstream"
- />
- <pipeline-mini-graph
- v-if="item.details && item.details.stages && item.details.stages.length > 0"
- :stages="item.details.stages"
- :update-dropdown="updateGraphDropdown"
- @pipelineActionRequestComplete="onPipelineActionRequestComplete"
- />
- <linked-pipelines-mini-list
- v-if="item.triggered.length"
- :triggered="item.triggered"
- :pipeline-path="item.path"
- data-testid="mini-graph-downstream"
- />
- </div>
+ <pipeline-mini-graph
+ :downstream-pipelines="item.triggered"
+ :pipeline-path="item.path"
+ :stages="item.details.stages"
+ :update-dropdown="updateGraphDropdown"
+ :upstream-pipeline="item.triggered_by"
+ @pipelineActionRequestComplete="onPipelineActionRequestComplete"
+ @miniGraphStageClick="trackPipelineMiniGraph"
+ />
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 7b38f870cb6..327633dcb1a 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -110,4 +110,8 @@ export const DEFAULT_FIELDS = [
},
];
-export const performanceModalId = 'performanceInsightsModal';
+export const TRACKING_CATEGORIES = {
+ table: 'pipelines_table_component',
+ tabs: 'pipelines_filter_tabs',
+ search: 'pipelines_filtered_search',
+};
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
deleted file mode 100644
index 25e990c8934..00000000000
--- a/app/assets/javascripts/pipelines/graphql/queries/get_performance_insights.query.graphql
+++ /dev/null
@@ -1,28 +0,0 @@
-query getPerformanceInsightsData($fullPath: ID!, $iid: ID!) {
- project(fullPath: $fullPath) {
- id
- pipeline(iid: $iid) {
- id
- jobs {
- pageInfo {
- hasNextPage
- }
- nodes {
- id
- duration
- detailedStatus {
- id
- detailsPath
- }
- name
- stage {
- id
- name
- }
- startedAt
- queuedDuration
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
index 641ec7a3cf6..b0f875160d4 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_jobs.query.graphql
@@ -11,6 +11,7 @@ query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) {
}
nodes {
artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
downloadPath
fileType
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index 2fedd7e7a98..c9e60756407 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import pipelineHeader from './components/header_component.vue';
+import PipelineHeader from './components/header_component.vue';
Vue.use(VueApollo);
@@ -16,7 +16,7 @@ export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResou
new Vue({
el,
components: {
- pipelineHeader,
+ PipelineHeader,
},
apolloProvider,
provide: {
diff --git a/app/assets/javascripts/pipelines/pipeline_tabs.js b/app/assets/javascripts/pipelines/pipeline_tabs.js
index 7051d356089..508f188c229 100644
--- a/app/assets/javascripts/pipelines/pipeline_tabs.js
+++ b/app/assets/javascripts/pipelines/pipeline_tabs.js
@@ -20,6 +20,8 @@ export const createAppOptions = (selector, apolloProvider) => {
const {
canGenerateCodequalityReports,
codequalityReportDownloadPath,
+ codequalityBlobPath,
+ codequalityProjectPath,
downloadablePathForReportType,
exposeSecurityDashboard,
exposeLicenseScanningData,
@@ -40,9 +42,12 @@ export const createAppOptions = (selector, apolloProvider) => {
hasTestReport,
emptyStateImagePath,
artifactsExpiredImagePath,
+ isFullCodequalityReportAvailable,
testsCount,
} = dataset;
+ // TODO remove projectPath variable once https://gitlab.com/gitlab-org/gitlab/-/issues/371641 is resolved
+ const projectPath = fullPath;
const defaultTabValue = getPipelineDefaultTab(window.location.href);
return {
@@ -63,6 +68,10 @@ export const createAppOptions = (selector, apolloProvider) => {
provide: {
canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports),
codequalityReportDownloadPath,
+ codequalityBlobPath,
+ codequalityProjectPath,
+ isFullCodequalityReportAvailable: parseBoolean(isFullCodequalityReportAvailable),
+ projectPath,
defaultTabValue,
downloadablePathForReportType,
exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard),
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 83e00b80426..588d15495ab 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -153,24 +153,3 @@ export const getPipelineDefaultTab = (url) => {
return null;
};
-
-export const calculateJobStats = (jobs, sortField) => {
- const jobNodes = [...jobs.nodes];
-
- const sorted = jobNodes.sort((a, b) => {
- return b[sortField] - a[sortField];
- });
-
- return sorted[0];
-};
-
-export const calculateSlowestFiveJobs = (jobs) => {
- const jobNodes = [...jobs.nodes];
- const limit = 5;
-
- return jobNodes
- .sort((a, b) => {
- return b.duration - a.duration;
- })
- .slice(0, limit);
-};
diff --git a/app/assets/javascripts/profile/account/index.js b/app/assets/javascripts/profile/account/index.js
index f208280af27..2d31cf772e3 100644
--- a/app/assets/javascripts/profile/account/index.js
+++ b/app/assets/javascripts/profile/account/index.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import Translate from '~/vue_shared/translate';
-import deleteAccountModal from './components/delete_account_modal.vue';
+import DeleteAccountModal from './components/delete_account_modal.vue';
import UpdateUsername from './components/update_username.vue';
export default () => {
@@ -27,7 +27,7 @@ export default () => {
new Vue({
el: deleteAccountModalEl,
components: {
- deleteAccountModal,
+ DeleteAccountModal,
},
mounted() {
deleteAccountButton.disabled = false;
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index 064bcf8e4c4..af5beeb686c 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -1,11 +1,14 @@
import $ from 'jquery';
+import Vue from 'vue';
import { VARIANT_DANGER, VARIANT_INFO, createAlert } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseRailsFormFields } from '~/lib/utils/forms';
import { Rails } from '~/lib/utils/rails_ujs';
import TimezoneDropdown, {
formatTimezone,
} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
+import UserProfileSetStatusWrapper from '~/set_status_modal/user_profile_set_status_wrapper.vue';
export default class Profile {
constructor({ form } = {}) {
@@ -116,3 +119,24 @@ export default class Profile {
}
}
}
+
+export const initSetStatusForm = () => {
+ const el = document.getElementById('js-user-profile-set-status-form');
+
+ if (!el) {
+ return null;
+ }
+
+ const fields = parseRailsFormFields(el);
+
+ return new Vue({
+ el,
+ name: 'UserProfileStatusForm',
+ provide: {
+ fields,
+ },
+ render(h) {
+ return h(UserProfileSetStatusWrapper);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index 1cdf26b76b7..4505dd1f85c 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -2,11 +2,11 @@
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { __ } from '~/locale';
-import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
import {
getQueryHeaders,
toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import { formatStages } from '../utils';
import getLinkedPipelinesQuery from '../graphql/queries/get_linked_pipelines.query.graphql';
import getPipelineStagesQuery from '../graphql/queries/get_pipeline_stages.query.graphql';
@@ -21,8 +21,6 @@ export default {
components: {
GlLoadingIcon,
PipelineMiniGraph,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
inject: {
fullPath: {
@@ -92,12 +90,12 @@ export default {
};
},
computed: {
- hasDownstream() {
- return this.pipeline?.downstream?.nodes.length > 0;
- },
downstreamPipelines() {
return this.pipeline?.downstream?.nodes;
},
+ pipelinePath() {
+ return this.pipeline?.path ?? '';
+ },
upstreamPipeline() {
return this.pipeline?.upstream;
},
@@ -128,23 +126,13 @@ export default {
<template>
<div class="gl-pt-2">
<gl-loading-icon v-if="$apollo.queries.pipeline.loading" />
- <div v-else class="gl-align-items-center gl-display-flex">
- <linked-pipelines-mini-list
- v-if="upstreamPipeline"
- :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [
- upstreamPipeline,
- ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
- data-testid="commit-box-mini-graph-upstream"
- />
-
- <pipeline-mini-graph :stages="formattedStages" data-testid="commit-box-mini-graph" />
-
- <linked-pipelines-mini-list
- v-if="hasDownstream"
- :triggered="downstreamPipelines"
- :pipeline-path="pipeline.path"
- data-testid="commit-box-mini-graph-downstream"
- />
- </div>
+ <pipeline-mini-graph
+ v-else
+ data-testid="commit-box-pipeline-mini-graph"
+ :downstream-pipelines="downstreamPipelines"
+ :pipeline-path="pipelinePath"
+ :stages="formattedStages"
+ :upstream-pipeline="upstreamPipeline"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index ecd2288eb2f..06d96ef7bef 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -1,7 +1,7 @@
<script>
import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { __, s__, sprintf } from '~/locale';
import CiCdAnalyticsCharts from '~/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue';
diff --git a/app/assets/javascripts/projects/project_visibility.js b/app/assets/javascripts/projects/project_visibility.js
index b8ac17a01f2..84b8936c17f 100644
--- a/app/assets/javascripts/projects/project_visibility.js
+++ b/app/assets/javascripts/projects/project_visibility.js
@@ -1,13 +1,7 @@
import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import eventHub from '~/projects/new/event_hub';
-
-// Values are from lib/gitlab/visibility_level.rb
-const visibilityLevel = {
- private: 0,
- internal: 10,
- public: 20,
-};
+import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
function setVisibilityOptions({ name, visibility, showPath, editPath }) {
document.querySelectorAll('.visibility-level-setting .gl-form-radio').forEach((option) => {
@@ -19,13 +13,14 @@ function setVisibilityOptions({ name, visibility, showPath, editPath }) {
const optionInput = option.querySelector('input[type=radio]');
const optionValue = optionInput ? parseInt(optionInput.value, 10) : 0;
- if (visibilityLevel[visibility] < optionValue) {
+ if (VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility] < optionValue) {
option.classList.add('disabled');
optionInput.disabled = true;
const reason = option.querySelector('.option-disabled-reason');
if (reason) {
const optionTitle = option.querySelector('.js-visibility-level-radio span');
const optionName = optionTitle ? optionTitle.innerText.toLowerCase() : '';
+ // eslint-disable-next-line no-unsanitized/property
reason.innerHTML = sprintf(
__(
'This project cannot be %{visibilityLevel} because the visibility of %{openShowLink}%{name}%{closeShowLink} is %{visibility}. To make this project %{visibilityLevel}, you must first %{openEditLink}change the visibility%{closeEditLink} of the parent group.',
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
index ada951f6867..e8eaf0a70b2 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue
@@ -1,16 +1,58 @@
<script>
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import branchRulesQuery from './graphql/queries/branch_rules.query.graphql';
+import BranchRule from './components/branch_rule.vue';
+
+export const i18n = {
+ queryError: s__(
+ 'ProtectedBranch|An error occurred while loading branch rules. Please try again.',
+ ),
+ emptyState: s__(
+ 'ProtectedBranch|Protected branches, merge request approvals, and status checks will appear here once configured.',
+ ),
+};
export default {
name: 'BranchRules',
- i18n: { heading: __('Branch') },
+ i18n,
+ components: {
+ BranchRule,
+ },
+ apollo: {
+ branchRules: {
+ query: branchRulesQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data.project?.branchRules?.nodes || [];
+ },
+ error() {
+ createFlash({ message: this.$options.i18n.queryError });
+ },
+ },
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ branchRules: [],
+ };
+ },
};
</script>
<template>
- <div>
- <strong>{{ $options.i18n.heading }}</strong>
+ <div class="settings-content">
+ <branch-rule v-for="rule in branchRules" :key="rule.name" :name="rule.name" />
- <!-- TODO - List branch rules (https://gitlab.com/gitlab-org/gitlab/-/issues/362217) -->
+ <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span>
</div>
</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
new file mode 100644
index 00000000000..68750318029
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue
@@ -0,0 +1,61 @@
+<script>
+import { GlBadge } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export const i18n = {
+ defaultLabel: s__('BranchRules|default'),
+ protectedLabel: s__('BranchRules|protected'),
+};
+
+export default {
+ name: 'BranchRule',
+ i18n,
+ components: {
+ GlBadge,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ isDefault: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isProtected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ approvalDetails: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ hasApprovalDetails() {
+ return this.approvalDetails && this.approvalDetails.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-border-b gl-pt-5 gl-pb-5">
+ <strong class="gl-font-monospace">{{ name }}</strong>
+
+ <gl-badge v-if="isDefault" variant="info" size="sm" class="gl-ml-2">{{
+ $options.i18n.defaultLabel
+ }}</gl-badge>
+
+ <gl-badge v-if="isProtected" variant="success" size="sm" class="gl-ml-2">{{
+ $options.i18n.protectedLabel
+ }}</gl-badge>
+
+ <ul v-if="hasApprovalDetails" class="gl-pl-6 gl-mt-2 gl-mb-0 gl-text-gray-500">
+ <li v-for="(detail, index) in approvalDetails" :key="index">{{ detail }}</li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
new file mode 100644
index 00000000000..104a0c25a80
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql
@@ -0,0 +1,10 @@
+query getBranchRules($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ branchRules {
+ nodes {
+ name
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
index abe0b93081e..35322e2e466 100644
--- a/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
+++ b/app/assets/javascripts/projects/settings/repository/branch_rules/mount_branch_rules.js
@@ -1,13 +1,28 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import BranchRulesApp from '~/projects/settings/repository/branch_rules/app.vue';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
export default function mountBranchRules(el) {
if (!el) return null;
+ const { projectPath } = el.dataset;
+
return new Vue({
el,
+ apolloProvider,
render(createElement) {
- return createElement(BranchRulesApp);
+ return createElement(BranchRulesApp, {
+ props: {
+ projectPath,
+ },
+ });
},
});
}
diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
index 9c8de9bef2d..3d553e71f71 100644
--- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -2,7 +2,7 @@
import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import searchProjectTopics from '../queries/project_topics_search.query.graphql';
+import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index 14c8c53dd19..71ff3e892b1 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -1,12 +1,18 @@
<script>
-import { GlAlert, GlSafeHtmlDirective } from '@gitlab/ui';
+import { GlAlert, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { __, sprintf } from '~/locale';
import ServiceDeskSetting from './service_desk_setting.vue';
export default {
+ customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
+ anchor: 'using-a-custom-email-address',
+ }),
components: {
GlAlert,
+ GlSprintf,
+ GlLink,
ServiceDeskSetting,
},
directives: {
@@ -43,6 +49,9 @@ export default {
templates: {
default: [],
},
+ publicProject: {
+ default: false,
+ },
},
data() {
return {
@@ -127,6 +136,27 @@ export default {
<template>
<div>
+ <gl-alert
+ v-if="publicProject && isEnabled"
+ class="mb-3"
+ variant="warning"
+ data-testid="public-project-alert"
+ :dismissible="false"
+ >
+ <gl-sprintf
+ :message="
+ __(
+ 'This project is public. Non-members can guess the Service Desk email address, because it contains the group and project name. %{linkStart}How do I create a custom email address?%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link :href="$options.customEmailHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss">
<span v-safe-html="alertMessage"></span>
</gl-alert>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 8a9a0b541f3..452e7a4fd21 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -176,7 +176,7 @@ export default {
</template>
</gl-form-input-group>
<template v-if="email && hasCustomEmail" #description>
- <span class="gl-mt-2 d-inline-block">
+ <span class="gl-mt-2 gl-display-inline-block">
<gl-sprintf :message="__('Emails sent to %{email} are also supported.')">
<template #email>
<code>{{ incomingEmail }}</code>
@@ -190,7 +190,11 @@ export default {
</template>
</gl-form-group>
- <gl-form-group :label="__('Email address suffix')" :state="!projectKeyError">
+ <gl-form-group
+ :label="__('Email address suffix')"
+ :state="!projectKeyError"
+ data-testid="suffix-form-group"
+ >
<gl-form-input
v-if="hasProjectKeySupport"
id="service-desk-project-suffix"
@@ -216,22 +220,24 @@ export default {
</gl-sprintf>
</template>
<template v-else #description>
- <gl-sprintf
- :message="
- __(
- 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
- )
- "
- >
- <template #link="{ content }">
- <gl-link
- :href="customEmailAddressHelpUrl"
- target="_blank"
- class="gl-text-blue-600 font-size-inherit"
- >{{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
+ <span class="gl-text-gray-900">
+ <gl-sprintf
+ :message="
+ __(
+ 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ :href="customEmailAddressHelpUrl"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
</template>
<template v-if="hasProjectKeySupport && projectKeyError" #invalid-feedback>
@@ -266,7 +272,27 @@ export default {
/>
<template v-if="hasProjectKeySupport" #description>
- {{ __('Emails sent from Service Desk have this name.') }}
+ {{ __('Name to be used as the sender for emails from Service Desk.') }}
+ </template>
+ <template v-else #description>
+ <span class="gl-text-gray-900">
+ <gl-sprintf
+ :message="
+ __(
+ 'To add display name, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ :href="customEmailAddressHelpUrl"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
</template>
</gl-form-group>
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
index bdd9f940d79..315f0743b53 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue
@@ -100,7 +100,7 @@ export default {
<gl-dropdown-item
v-for="template in item"
:key="template.key"
- :is-check-item="true"
+ is-check-item
:is-checked="
template.project_id === selectedFileTemplateProjectId &&
template.name === selectedTemplate
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index e14cdee17ce..26435a5fac9 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -20,6 +20,7 @@ export default () => {
selectedTemplate,
selectedFileTemplateProjectId,
templates,
+ publicProject,
} = el.dataset;
return new Vue({
@@ -35,6 +36,7 @@ export default () => {
selectedTemplate,
selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null,
templates: JSON.parse(templates),
+ publicProject: parseBoolean(publicProject),
},
render: (createElement) => createElement(ServiceDeskRoot),
});
diff --git a/app/assets/javascripts/projects/star.js b/app/assets/javascripts/projects/star.js
index 5bbace11b15..e063064663b 100644
--- a/app/assets/javascripts/projects/star.js
+++ b/app/assets/javascripts/projects/star.js
@@ -22,11 +22,14 @@ export default class Star {
starSpan.classList.remove('starred');
starSpan.textContent = s__('StarProject|Star');
starIcon.remove();
+ // eslint-disable-next-line no-unsanitized/method
starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star-o', iconClasses));
} else {
starSpan.classList.add('starred');
starSpan.textContent = __('Unstar');
starIcon.remove();
+
+ // eslint-disable-next-line no-unsanitized/method
starSpan.insertAdjacentHTML('beforebegin', spriteIcon('star', iconClasses));
}
})
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index a79da00de43..6b14ebadacc 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -4,7 +4,7 @@ import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
import { __, s__, sprintf } from '~/locale';
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import CommitPipelineService from '../services/commit_pipeline_service';
export default {
@@ -12,7 +12,7 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- ciIcon,
+ CiIcon,
GlLoadingIcon,
},
props: {
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 270d4632a54..09ecad2d90e 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -7,14 +7,14 @@ import {
inputPlaceholderTextMap,
issuableTypesMap,
} from '../constants';
-import issueToken from './issue_token.vue';
+import IssueToken from './issue_token.vue';
const SPACE_FACTOR = 1;
export default {
name: 'RelatedIssuableInput',
components: {
- issueToken,
+ IssueToken,
},
props: {
inputId: {
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 5b4a6d1fe0d..53f2dbbbbd7 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_block.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue
@@ -3,7 +3,6 @@ import { GlLink, GlIcon, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import {
issuableIconMap,
- issuableQaClassMap,
linkedIssueTypesMap,
linkedIssueTypesTextMap,
issuablesBlockHeaderTextMap,
@@ -142,9 +141,6 @@ export default {
issuableTypeIcon() {
return issuableIconMap[this.issuableType];
},
- qaClass() {
- return issuableQaClassMap[this.issuableType];
- },
toggleIcon() {
return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
},
@@ -166,11 +162,15 @@ export default {
</script>
<template>
- <div id="related-issues" class="related-issues-block gl-mt-5">
- <div class="card card-slim gl-overflow-hidden">
+ <div id="related-issues" class="related-issues-block">
+ <div class="card card-slim gl-overflow-hidden gl-mt-5 gl-mb-0">
<div
- :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"
+ :class="{
+ 'panel-empty-heading border-bottom-0': !hasBody,
+ 'gl-border-b-1': isOpen,
+ '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-solid gl-border-b-gray-100"
>
<h3 class="card-title h5 gl-my-0 gl-display-flex gl-align-items-center gl-flex-grow-1">
<gl-link
@@ -205,7 +205,6 @@ export default {
data-qa-selector="related_issues_plus_button"
data-testid="related-issues-plus-button"
:aria-label="addIssuableButtonText"
- :class="qaClass"
class="gl-ml-3"
@click="addButtonClick"
>
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 cad5037d7e4..ae40232df6f 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -40,7 +40,7 @@ import RelatedIssuesBlock from './related_issues_block.vue';
export default {
name: 'RelatedIssuesRoot',
components: {
- relatedIssuesBlock: RelatedIssuesBlock,
+ RelatedIssuesBlock,
},
props: {
endpoint: {
diff --git a/app/assets/javascripts/related_issues/constants.js b/app/assets/javascripts/related_issues/constants.js
index 23ea93cd258..4eb054ccb5c 100644
--- a/app/assets/javascripts/related_issues/constants.js
+++ b/app/assets/javascripts/related_issues/constants.js
@@ -99,15 +99,6 @@ export const issuableIconMap = {
[issuableTypesMap.EPIC]: 'epic',
};
-/**
- * These are used to map issuableType to the correct QA class.
- * Since these are never used for any display purposes, don't wrap
- * them inside i18n functions.
- */
-export const issuableQaClassMap = {
- [issuableTypesMap.EPIC]: 'qa-add-epics-button',
-};
-
export const PathIdSeparator = {
Epic: '&',
Issue: '#',
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 78831ceefe9..6d415471b14 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -1,6 +1,6 @@
<script>
import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import dateFormat from 'dateformat';
+import dateFormat from '~/lib/dateformat';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
index a71a8125d65..669e5928143 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js
@@ -16,6 +16,8 @@ import {
import * as types from './mutation_types';
+class GraphQLError extends Error {}
+
export const initializeRelease = ({ commit, dispatch, state }) => {
if (state.isExistingRelease) {
// When editing an existing release,
@@ -110,35 +112,35 @@ export const saveRelease = ({ commit, dispatch, state }) => {
*
* @param {Object} gqlResponse The response object returned by the GraphQL client
* @param {String} mutationName The name of the mutation that was executed
- * @param {String} messageIfError An message to build into the error object if something went wrong
*/
-const checkForErrorsAsData = (gqlResponse, mutationName, messageIfError) => {
+const checkForErrorsAsData = (gqlResponse, mutationName) => {
const allErrors = gqlResponse.data[mutationName].errors;
if (allErrors.length > 0) {
- const allErrorMessages = JSON.stringify(allErrors);
- throw new Error(`${messageIfError}: ${allErrorMessages}`);
+ throw new GraphQLError(allErrors[0]);
}
};
-export const createRelease = async ({ commit, dispatch, state, getters }) => {
+export const createRelease = async ({ commit, dispatch, getters }) => {
try {
const response = await gqClient.mutate({
mutation: createReleaseMutation,
variables: getters.releaseCreateMutatationVariables,
});
- checkForErrorsAsData(
- response,
- 'releaseCreate',
- `Something went wrong while creating a new release with projectPath "${state.projectPath}" and tagName "${state.release.tagName}"`,
- );
+ checkForErrorsAsData(response, 'releaseCreate');
dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl);
} catch (error) {
commit(types.RECEIVE_SAVE_RELEASE_ERROR, error);
- createFlash({
- message: s__('Release|Something went wrong while creating a new release.'),
- });
+ if (error instanceof GraphQLError) {
+ createFlash({
+ message: error.message,
+ });
+ } else {
+ createFlash({
+ message: s__('Release|Something went wrong while creating a new release.'),
+ });
+ }
}
};
@@ -146,7 +148,7 @@ export const createRelease = async ({ commit, dispatch, state, getters }) => {
* Deletes a single release link.
* Throws an error if any network or validation errors occur.
*/
-const deleteReleaseLinks = async ({ state, id }) => {
+const deleteReleaseLinks = async ({ id }) => {
const deleteResponse = await gqClient.mutate({
mutation: deleteReleaseAssetLinkMutation,
variables: {
@@ -154,11 +156,7 @@ const deleteReleaseLinks = async ({ state, id }) => {
},
});
- checkForErrorsAsData(
- deleteResponse,
- 'releaseAssetLinkDelete',
- `Something went wrong while deleting release asset link for release with projectPath "${state.projectPath}", tagName "${state.tagName}", and link id "${id}"`,
- );
+ checkForErrorsAsData(deleteResponse, 'releaseAssetLinkDelete');
};
/**
@@ -180,11 +178,7 @@ const createReleaseLink = async ({ state, link }) => {
},
});
- checkForErrorsAsData(
- createResponse,
- 'releaseAssetLinkCreate',
- `Something went wrong while creating a release asset link for release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
- );
+ checkForErrorsAsData(createResponse, 'releaseAssetLinkCreate');
};
export const updateRelease = async ({ commit, dispatch, state, getters }) => {
@@ -210,11 +204,7 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => {
variables: getters.releaseUpdateMutatationVariables,
});
- checkForErrorsAsData(
- updateReleaseResponse,
- 'releaseUpdate',
- `Something went wrong while updating release with projectPath "${state.projectPath}" and tagName "${state.tagName}"`,
- );
+ checkForErrorsAsData(updateReleaseResponse, 'releaseUpdate');
// Delete all links currently associated with this Release
await Promise.all(
@@ -266,7 +256,7 @@ export const deleteRelease = ({ commit, getters, dispatch, state }) => {
mutation: deleteReleaseMutation,
variables: getters.releaseDeleteMutationVariables,
})
- .then((response) => checkForErrorsAsData(response, 'releaseDelete', ''))
+ .then((response) => checkForErrorsAsData(response, 'releaseDelete'))
.then(() => {
window.sessionStorage.setItem(
deleteReleaseSessionKey(state.projectPath),
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
index 62d6bd42d51..ccca9ca8250 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js
@@ -130,7 +130,7 @@ export const releaseUpdateMutatationVariables = (state, getters) => {
projectPath: state.projectPath,
tagName: state.release.tagName,
name,
- releasedAt: state.release.releasedAt,
+ releasedAt: getters.releasedAtChanged ? state.release.releasedAt : null,
description: state.includeTagNotes
? getters.formattedReleaseNotes
: state.release.description,
@@ -167,3 +167,6 @@ export const formattedReleaseNotes = ({ includeTagNotes, release: { description
includeTagNotes && tagNotes
? `${description}\n\n### ${s__('Releases|Tag message')}\n\n${tagNotes}\n`
: description;
+
+export const releasedAtChanged = ({ originalReleasedAt, release }) =>
+ originalReleasedAt !== release.releasedAt;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
index ea794f91f66..34361f84a5a 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js
@@ -14,7 +14,7 @@ export default {
description: '',
milestones: [],
groupMilestones: [],
- releasedAt: new Date(),
+ releasedAt: state.originalReleasedAt,
assets: {
links: [],
},
@@ -29,6 +29,7 @@ export default {
state.isFetchingRelease = false;
state.release = data;
state.originalRelease = Object.freeze(cloneDeep(state.release));
+ state.originalReleasedAt = state.originalRelease.releasedAt;
},
[types.RECEIVE_RELEASE_ERROR](state, error) {
state.fetchError = error;
diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
index cb447cf9aaf..11a2f9df59b 100644
--- a/app/assets/javascripts/releases/stores/modules/edit_new/state.js
+++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js
@@ -61,4 +61,5 @@ export default ({
tagNotes: '',
includeTagNotes: false,
existingRelease: null,
+ originalReleasedAt: new Date(),
});
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 78572f11f6f..902077ba3e4 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -13,9 +13,10 @@ 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';
import LineHighlighter from '~/blob/line_highlighter';
+import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
import addBlameLink from '~/blob/blob_blame_link';
+import projectInfoQuery from '../queries/project_info.query.graphql';
import getRefMixin from '../mixins/get_ref';
-import blobInfoQuery from '../queries/blob_info.query.graphql';
import userInfoQuery from '../queries/user_info.query.graphql';
import applicationInfoQuery from '../queries/application_info.query.graphql';
import { DEFAULT_BLOB_INFO, TEXT_FILE_TYPE, LFS_STORAGE, LEGACY_FILE_TYPES } from '../constants';
@@ -41,6 +42,21 @@ export default {
},
},
apollo: {
+ projectInfo: {
+ query: projectInfoQuery,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ error() {
+ this.displayError();
+ },
+ update({ project }) {
+ this.pathLocks = project.pathLocks || DEFAULT_BLOB_INFO.pathLocks;
+ this.userPermissions = project.userPermissions;
+ },
+ },
gitpodEnabled: {
query: applicationInfoQuery,
error() {
@@ -121,6 +137,8 @@ export default {
gitpodEnabled: DEFAULT_BLOB_INFO.gitpodEnabled,
currentUser: DEFAULT_BLOB_INFO.currentUser,
useFallback: false,
+ pathLocks: DEFAULT_BLOB_INFO.pathLocks,
+ userPermissions: DEFAULT_BLOB_INFO.userPermissions,
};
},
computed: {
@@ -163,7 +181,7 @@ export default {
);
},
canLock() {
- const { pushCode, downloadCode } = this.project.userPermissions;
+ const { pushCode, downloadCode } = this.userPermissions;
const currentUsername = window.gon?.current_username;
if (this.pathLockedByUser && this.pathLockedByUser.username !== currentUsername) {
@@ -173,12 +191,12 @@ export default {
return pushCode && downloadCode;
},
pathLockedByUser() {
- const pathLock = this.project?.pathLocks?.nodes.find((node) => node.path === this.path);
+ const pathLock = this.pathLocks?.nodes.find((node) => node.path === this.path);
return pathLock ? pathLock.user : null;
},
showForkSuggestion() {
- const { createMergeRequestIn, forkProject } = this.project.userPermissions;
+ const { createMergeRequestIn, forkProject } = this.userPermissions;
const { canModifyBlob } = this.blobInfo;
return this.isLoggedIn && !canModifyBlob && createMergeRequestIn && forkProject;
@@ -338,7 +356,7 @@ export default {
:name="blobInfo.name"
:replace-path="blobInfo.replacePath"
:delete-path="blobInfo.webPath"
- :can-push-code="project.userPermissions.pushCode"
+ :can-push-code="userPermissions.pushCode"
:can-push-to-branch="blobInfo.canCurrentUserPushToBranch"
:empty-repo="project.repository.empty"
:project-path="projectPath"
diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/blob_controls.vue
index 3223ed92fe2..fb1227f0df9 100644
--- a/app/assets/javascripts/repository/components/blob_controls.vue
+++ b/app/assets/javascripts/repository/components/blob_controls.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <div v-if="showBlobControls">
+ <div v-if="showBlobControls" class="gl-display-flex gl-gap-3">
<gl-button data-testid="find" :href="blobInfo.findFilePath" :class="$options.buttonClassList">
{{ $options.i18n.findFile }}
</gl-button>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 20888db80a9..46dee9db69a 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -191,7 +191,7 @@ export default {
href: `${this.newBlobPath}/${
this.currentPath ? encodeURIComponent(this.currentPath) : ''
}`,
- class: 'qa-new-file-option',
+ 'data-qa-selector': 'new_file_menu_item',
},
text: __('New file'),
},
@@ -300,7 +300,11 @@ export default {
</router-link>
</li>
<li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
- <gl-dropdown toggle-class="add-to-tree qa-add-to-tree gl-ml-2">
+ <gl-dropdown
+ toggle-class="add-to-tree gl-ml-2"
+ data-testid="add-to-tree"
+ data-qa-selector="add_to_tree_dropdown"
+ >
<template #button-content>
<span class="sr-only">{{ __('Add to tree') }}</span>
<gl-icon name="plus" :size="16" class="float-left" />
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 7f408485326..22fe3fe440e 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -135,7 +135,7 @@ export default {
<div
class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-flex-grow-1 gl-min-w-0"
>
- <div class="commit-content qa-commit-content">
+ <div class="commit-content" data-qa-selector="commit_content">
<gl-link
v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml"
:href="commit.webPath"
@@ -148,6 +148,7 @@ export default {
:class="{ open: showDescription }"
:title="__('Toggle commit description')"
:aria-label="__('Toggle commit description')"
+ :selected="showDescription"
class="text-expander gl-vertical-align-bottom!"
icon="ellipsis_h"
@click="toggleShowDescription"
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 1f6b5e98122..99eb167172b 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -2,6 +2,7 @@
import { GlSkeletonLoader, GlButton } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { sprintf, __ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
import getRefMixin from '../../mixins/get_ref';
import projectPathQuery from '../../queries/project_path.query.graphql';
import TableHeader from './header.vue';
@@ -108,7 +109,9 @@ export default {
return {};
}
- return this.commits.find((commitEntry) => commitEntry.fileName === fileName);
+ return this.commits.find(
+ (commitEntry) => commitEntry.filePath === joinPaths(this.path, fileName),
+ );
},
},
};
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 99b7395d6e7..c8cd64b5311 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -17,8 +17,8 @@ import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql';
import getRefMixin from '../../mixins/get_ref';
-import blobInfoQuery from '../../queries/blob_info.query.graphql';
import commitQuery from '../../queries/commit.query.graphql';
export default {
@@ -244,7 +244,7 @@ export default {
/><span class="position-relative">{{ fullPath }}</span>
</component>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
- <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-qa-selector="label-lfs"
+ <gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-testid="label-lfs"
>LFS</gl-badge
>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 9345a8406e3..a5bcd9e6b5e 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -1,6 +1,7 @@
import produce from 'immer';
import { normalizeData } from 'ee_else_ce/repository/utils/commit';
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
import commitsQuery from './queries/commits.query.graphql';
import projectPathQuery from './queries/project_path.query.graphql';
import refQuery from './queries/ref.query.graphql';
@@ -16,7 +17,7 @@ function setNextOffset(offset) {
}
export function resolveCommit(commits, path, { resolve, entry }) {
- const commit = commits.find((c) => c.filePath === `${path}/${entry.name}`);
+ const commit = commits.find((c) => c.filePath === joinPaths(path, entry.name));
if (commit) {
resolve(commit);
diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql
deleted file mode 100644
index 45a7793e559..00000000000
--- a/app/assets/javascripts/repository/queries/blob_info.query.graphql
+++ /dev/null
@@ -1,66 +0,0 @@
-#import "ee_else_ce/repository/queries/path_locks.fragment.graphql"
-
-query getBlobInfo(
- $projectPath: ID!
- $filePath: String!
- $ref: String!
- $shouldFetchRawText: Boolean!
-) {
- project(fullPath: $projectPath) {
- userPermissions {
- pushCode
- downloadCode
- createMergeRequestIn
- forkProject
- }
- ...ProjectPathLocksFragment
- repository {
- empty
- blobs(paths: [$filePath], ref: $ref) {
- nodes {
- id
- webPath
- name
- size
- rawSize
- rawTextBlob @include(if: $shouldFetchRawText)
- fileType
- language
- path
- blamePath
- editBlobPath
- gitpodBlobUrl
- ideEditPath
- forkAndEditPath
- ideForkAndEditPath
- codeNavigationPath
- projectBlobPathRoot
- forkAndViewPath
- environmentFormattedExternalUrl
- environmentExternalUrlForRouteMap
- canModifyBlob
- canCurrentUserPushToBranch
- archived
- storedExternally
- externalStorage
- externalStorageUrl
- rawPath
- replacePath
- pipelineEditorPath
- simpleViewer {
- fileType
- tooLarge
- type
- renderError
- }
- richViewer {
- fileType
- tooLarge
- type
- renderError
- }
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/repository/queries/project_info.query.graphql b/app/assets/javascripts/repository/queries/project_info.query.graphql
new file mode 100644
index 00000000000..7a380d25bb1
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/project_info.query.graphql
@@ -0,0 +1,14 @@
+#import "ee_else_ce/repository/queries/path_locks.fragment.graphql"
+
+query getProjectInfo($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ id
+ userPermissions {
+ pushCode
+ downloadCode
+ createMergeRequestIn
+ forkProject
+ }
+ ...ProjectPathLocksFragment
+ }
+}
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index 878b4fdd71a..247e30d20fc 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -1,3 +1,5 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
export function normalizeData(data, path, extra = () => {}) {
return data.map((d) => ({
sha: d.commit.id,
@@ -6,7 +8,7 @@ export function normalizeData(data, path, extra = () => {}) {
committedDate: d.commit.committed_date,
commitPath: d.commit_path,
fileName: d.file_name,
- filePath: `${path}/${d.file_name}`,
+ filePath: joinPaths(path, d.file_name),
__typename: 'LogTreeCommit',
...extra(d),
}));
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 0b6c5063129..7b5babdd3a6 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -6,6 +6,7 @@ export * from './api/bulk_imports_api';
export * from './api/namespaces_api';
export * from './api/tags_api';
export * from './api/alert_management_alerts_api';
+export * from './api/harbor_registry';
// Note: It's not possible to spy on methods imported from this file in
// Jest tests.
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 777a332333d..f5620876783 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -15,6 +15,7 @@ import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.gr
import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
+import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.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';
@@ -37,6 +38,7 @@ export default {
components: {
GlLink,
RegistrationDropdown,
+ RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerBulkDelete,
RunnerBulkDeleteCheckbox,
@@ -169,6 +171,8 @@ export default {
</script>
<template>
<div>
+ <runner-stacked-layout-banner />
+
<div
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"
>
diff --git a/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
new file mode 100644
index 00000000000..e5d49eb7c8e
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_stacked_summary_cell.vue
@@ -0,0 +1,112 @@
+<script>
+import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
+
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import RunnerName from '../runner_name.vue';
+import RunnerTags from '../runner_tags.vue';
+import RunnerTypeBadge from '../runner_type_badge.vue';
+
+import { formatJobCount } from '../../utils';
+import {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_VERSION_LABEL,
+ I18N_LAST_CONTACT_LABEL,
+ I18N_CREATED_AT_LABEL,
+} from '../../constants';
+import RunnerSummaryField from './runner_summary_field.vue';
+
+export default {
+ components: {
+ GlIcon,
+ GlSprintf,
+ TimeAgo,
+ RunnerSummaryField,
+ RunnerName,
+ RunnerTags,
+ RunnerTypeBadge,
+ RunnerUpgradeStatusIcon: () =>
+ import('ee_component/runner/components/runner_upgrade_status_icon.vue'),
+ TooltipOnTruncate,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ runner: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ jobCount() {
+ return formatJobCount(this.runner.jobCount);
+ },
+ },
+ i18n: {
+ I18N_LOCKED_RUNNER_DESCRIPTION,
+ I18N_VERSION_LABEL,
+ I18N_LAST_CONTACT_LABEL,
+ I18N_CREATED_AT_LABEL,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div>
+ <slot :runner="runner" name="runner-name">
+ <runner-name :runner="runner" />
+ </slot>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip
+ :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ />
+ <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" />
+ </div>
+
+ <div class="gl-ml-auto gl-display-inline-flex gl-max-w-full gl-py-2">
+ <div class="gl-flex-shrink-0">
+ <runner-upgrade-status-icon :runner="runner" />
+ <gl-sprintf v-if="runner.version" :message="$options.i18n.I18N_VERSION_LABEL">
+ <template #version>{{ runner.version }}</template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div>
+ <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description">
+ {{ runner.description }}
+ </tooltip-on-truncate>
+ </div>
+
+ <div>
+ <runner-summary-field icon="clock">
+ <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" />
+ <template v-else>{{ __('Never') }}</template>
+ </template>
+ </gl-sprintf>
+ </runner-summary-field>
+
+ <runner-summary-field v-if="runner.ipAddress" icon="disk" :tooltip="__('IP Address')">
+ {{ runner.ipAddress }}
+ </runner-summary-field>
+
+ <runner-summary-field icon="pipeline" data-testid="job-count" :tooltip="__('Jobs')">
+ {{ jobCount }}
+ </runner-summary-field>
+
+ <runner-summary-field icon="calendar">
+ <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL">
+ <template #timeAgo>
+ <time-ago v-if="runner.createdAt" :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </runner-summary-field>
+ </div>
+
+ <runner-tags class="gl-display-block gl-pt-2" :tag-list="runner.tagList" size="sm" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
index a48db9f8ac8..eb98d4ae2fb 100644
--- a/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_status_cell.vue
@@ -32,17 +32,14 @@ export default {
<div>
<runner-status-badge
:runner="runner"
- size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-upgrade-status-badge
:runner="runner"
- size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
<runner-paused-badge
v-if="paused"
- size="sm"
class="gl-display-inline-block gl-max-w-full gl-text-truncate"
/>
</div>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
deleted file mode 100644
index 1cd098d6713..00000000000
--- a/app/assets/javascripts/runner/components/cells/runner_summary_cell.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
-
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
-import RunnerName from '../runner_name.vue';
-import RunnerTypeBadge from '../runner_type_badge.vue';
-
-import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../../constants';
-
-export default {
- components: {
- GlIcon,
- TooltipOnTruncate,
- RunnerName,
- RunnerTypeBadge,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- runner: {
- type: Object,
- required: true,
- },
- },
- computed: {
- runnerType() {
- return this.runner.runnerType;
- },
- locked() {
- return this.runner.locked;
- },
- description() {
- return this.runner.description;
- },
- ipAddress() {
- return this.runner.ipAddress;
- },
- },
- i18n: {
- I18N_LOCKED_RUNNER_DESCRIPTION,
- },
-};
-</script>
-
-<template>
- <div>
- <slot :runner="runner" name="runner-name">
- <runner-name :runner="runner" />
- </slot>
-
- <runner-type-badge :type="runnerType" size="sm" />
- <gl-icon
- v-if="locked"
- v-gl-tooltip
- :title="$options.i18n.I18N_LOCKED_RUNNER_DESCRIPTION"
- name="lock"
- />
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="description">
- {{ description }}
- </tooltip-on-truncate>
- <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>
- </div>
-</template>
diff --git a/app/assets/javascripts/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
new file mode 100644
index 00000000000..1bbbd55089a
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/runner_summary_field.vue
@@ -0,0 +1,33 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-my-2 gl-mr-2">
+ <gl-icon v-if="icon" :name="icon" />
+ <!-- display tooltip as a label for screen readers -->
+ <span class="gl-sr-only">{{ tooltip }}</span>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_detail.vue b/app/assets/javascripts/runner/components/runner_detail.vue
index 584f77b7648..c260670b517 100644
--- a/app/assets/javascripts/runner/components/runner_detail.vue
+++ b/app/assets/javascripts/runner/components/runner_detail.vue
@@ -21,7 +21,8 @@ export default {
props: {
label: {
type: String,
- required: true,
+ default: null,
+ required: false,
},
value: {
type: String,
@@ -39,7 +40,11 @@ export default {
<template>
<div class="gl-display-contents">
- <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">{{ label }}</dt>
+ <dt class="gl-mb-5 gl-mr-6 gl-max-w-26">
+ <template v-if="label || $scopedSlots.label">
+ <slot name="label">{{ label }}</slot>
+ </template>
+ </dt>
<dd class="gl-mb-5">
<template v-if="value || $scopedSlots.value">
<slot name="value">{{ value }}</slot>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index d5222f39b81..79f934764c6 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -1,7 +1,10 @@
<script>
-import { GlIntersperse } from '@gitlab/ui';
+import { GlIntersperse, GlLink } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerDetail from './runner_detail.vue';
@@ -12,6 +15,8 @@ import RunnerTags from './runner_tags.vue';
export default {
components: {
GlIntersperse,
+ GlLink,
+ HelpPopover,
RunnerDetail,
RunnerMaintenanceNoteDetail: () =>
import('ee_component/runner/components/runner_maintenance_note_detail.vue'),
@@ -24,6 +29,7 @@ export default {
RunnerTags,
TimeAgo,
},
+ mixins: [glFeatureFlagMixin()],
props: {
runner: {
type: Object,
@@ -60,6 +66,16 @@ export default {
isProjectRunner() {
return this.runner?.runnerType === PROJECT_TYPE;
},
+ tokenExpirationHelpPopoverOptions() {
+ return {
+ title: s__('Runners|Runner authentication token expiration'),
+ };
+ },
+ tokenExpirationHelpUrl() {
+ return helpPagePath('ci/runners/configure_runners', {
+ anchor: 'authentication-token-security',
+ });
+ },
},
ACCESS_LEVEL_REF_PROTECTED,
};
@@ -101,6 +117,34 @@ export default {
</template>
</runner-detail>
<runner-detail :label="s__('Runners|Maximum job timeout')" :value="maximumTimeout" />
+ <runner-detail
+ v-if="glFeatures.enforceRunnerTokenExpiresAt"
+ :empty-value="s__('Runners|Never expires')"
+ >
+ <template #label>
+ {{ s__('Runners|Token expiry') }}
+ <help-popover :options="tokenExpirationHelpPopoverOptions">
+ <p>
+ {{
+ s__(
+ 'Runners|Runner authentication tokens will expire based on a set interval. They will automatically rotate once expired.',
+ )
+ }}
+ </p>
+ <p class="gl-mb-0">
+ <gl-link
+ :href="tokenExpirationHelpUrl"
+ target="_blank"
+ class="gl-reset-font-size"
+ >{{ __('Learn more') }}</gl-link
+ >
+ </p>
+ </help-popover>
+ </template>
+ <template v-if="runner.tokenExpiresAt" #value>
+ <time-ago :time="runner.tokenExpiresAt" />
+ </template>
+ </runner-detail>
<runner-detail :label="s__('Runners|Tags')">
<template v-if="tagList.length" #value>
<runner-tags class="gl-vertical-align-middle" :tag-list="tagList" size="sm" />
diff --git a/app/assets/javascripts/runner/components/runner_header.vue b/app/assets/javascripts/runner/components/runner_header.vue
index abc07cec1ad..874c234ca4c 100644
--- a/app/assets/javascripts/runner/components/runner_header.vue
+++ b/app/assets/javascripts/runner/components/runner_header.vue
@@ -38,31 +38,33 @@ export default {
</script>
<template>
<div
- class="gl-display-flex gl-align-items-center gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-gap-3 gl-flex-wrap gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
- <div>
+ <div class="gl-display-flex gl-align-items-flex-start gl-gap-3 gl-flex-wrap">
<runner-status-badge :runner="runner" />
<runner-type-badge v-if="runner" :type="runner.runnerType" />
- <template v-if="runner.createdAt">
- <gl-sprintf :message="__('%{runner} created %{timeago}')">
- <template #runner>
- <strong>{{ heading }}</strong>
- <gl-icon
- v-if="runner.locked"
- v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
- name="lock"
- :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
- />
- </template>
- <template #timeago>
- <time-ago :time="runner.createdAt" />
- </template>
- </gl-sprintf>
- </template>
- <template v-else>
- <strong>{{ heading }}</strong>
- </template>
+ <span>
+ <template v-if="runner.createdAt">
+ <gl-sprintf :message="__('%{runner} created %{timeago}')">
+ <template #runner>
+ <strong>{{ heading }}</strong>
+ <gl-icon
+ v-if="runner.locked"
+ v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ name="lock"
+ :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION"
+ />
+ </template>
+ <template #timeago>
+ <time-ago :time="runner.createdAt" />
+ </template>
+ </gl-sprintf>
+ </template>
+ <template v-else>
+ <strong>{{ heading }}</strong>
+ </template>
+ </span>
</div>
- <div class="gl-ml-auto gl-flex-shrink-0"><slot name="actions"></slot></div>
+ <div class="gl-display-flex gl-gap-3 gl-flex-wrap"><slot name="actions"></slot></div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_list.vue b/app/assets/javascripts/runner/components/runner_list.vue
index 2e406f71792..26f1f3ce08c 100644
--- a/app/assets/javascripts/runner/components/runner_list.vue
+++ b/app/assets/javascripts/runner/components/runner_list.vue
@@ -1,24 +1,17 @@
<script>
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';
-import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { s__ } from '~/locale';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
-import RunnerSummaryCell from './cells/runner_summary_cell.vue';
+import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue';
import RunnerStatusPopover from './runner_status_popover.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
-import RunnerTags from './runner_tags.vue';
const defaultFields = [
tableField({ key: 'status', label: s__('Runners|Status'), thClasses: ['gl-w-15p'] }),
- tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
- tableField({ key: 'version', label: __('Version') }),
- tableField({ key: 'jobCount', label: __('Jobs') }),
- tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
- tableField({ key: 'contactedAt', label: __('Last contact') }),
- tableField({ key: 'actions', label: '' }),
+ tableField({ key: 'summary', label: s__('Runners|Runner') }),
+ tableField({ key: 'actions', label: '', thClasses: ['gl-w-15p'] }),
];
export default {
@@ -26,11 +19,8 @@ export default {
GlFormCheckbox,
GlTableLite,
GlSkeletonLoader,
- TooltipOnTruncate,
- TimeAgo,
RunnerStatusPopover,
- RunnerSummaryCell,
- RunnerTags,
+ RunnerStackedSummaryCell,
RunnerStatusCell,
},
directives: {
@@ -74,6 +64,8 @@ export default {
};
},
fields() {
+ const fields = defaultFields;
+
if (this.checkable) {
const checkboxField = tableField({
key: 'checkbox',
@@ -81,9 +73,9 @@ export default {
thClasses: ['gl-w-9'],
tdClass: ['gl-text-center'],
});
- return [checkboxField, ...defaultFields];
+ return [checkboxField, ...fields];
}
- return defaultFields;
+ return fields;
},
},
methods: {
@@ -141,30 +133,11 @@ export default {
</template>
<template #cell(summary)="{ item, index }">
- <runner-summary-cell :runner="item">
+ <runner-stacked-summary-cell :runner="item">
<template #runner-name="{ runner }">
<slot name="runner-name" :runner="runner" :index="index"></slot>
</template>
- </runner-summary-cell>
- </template>
-
- <template #cell(version)="{ item: { version } }">
- <tooltip-on-truncate class="gl-display-block gl-text-truncate" :title="version">
- {{ version }}
- </tooltip-on-truncate>
- </template>
-
- <template #cell(jobCount)="{ item: { jobCount } }">
- {{ formatJobCount(jobCount) }}
- </template>
-
- <template #cell(tagList)="{ item: { tagList } }">
- <runner-tags :tag-list="tagList" size="sm" />
- </template>
-
- <template #cell(contactedAt)="{ item: { contactedAt } }">
- <time-ago v-if="contactedAt" :time="contactedAt" />
- <template v-else>{{ __('Never') }}</template>
+ </runner-stacked-summary-cell>
</template>
<template #cell(actions)="{ item }">
diff --git a/app/assets/javascripts/runner/components/runner_name.vue b/app/assets/javascripts/runner/components/runner_name.vue
index 8e495125e03..d4ecfd2d776 100644
--- a/app/assets/javascripts/runner/components/runner_name.vue
+++ b/app/assets/javascripts/runner/components/runner_name.vue
@@ -14,5 +14,7 @@ export default {
};
</script>
<template>
- <span>#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span>
+ <span class="gl-font-weight-bold gl-vertical-align-middle"
+ >#{{ getIdFromGraphQLId(runner.id) }} ({{ runner.shortSha }})</span
+ >
</template>
diff --git a/app/assets/javascripts/runner/components/runner_paused_badge.vue b/app/assets/javascripts/runner/components/runner_paused_badge.vue
index 27618290ce6..00fd84a48d8 100644
--- a/app/assets/javascripts/runner/components/runner_paused_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_paused_badge.vue
@@ -1,6 +1,6 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { I18N_PAUSED_DESCRIPTION } from '../constants';
+import { I18N_PAUSED, I18N_PAUSED_DESCRIPTION } from '../constants';
export default {
components: {
@@ -9,11 +9,17 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
+ I18N_PAUSED,
I18N_PAUSED_DESCRIPTION,
};
</script>
<template>
- <gl-badge v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION" variant="danger" v-bind="$attrs">
- {{ s__('Runners|paused') }}
+ <gl-badge
+ v-gl-tooltip="$options.I18N_PAUSED_DESCRIPTION"
+ variant="warning"
+ icon="status-paused"
+ v-bind="$attrs"
+ >
+ {{ $options.I18N_PAUSED }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_projects.vue b/app/assets/javascripts/runner/components/runner_projects.vue
index 2c1d2fc2b10..84008e8eee8 100644
--- a/app/assets/javascripts/runner/components/runner_projects.vue
+++ b/app/assets/javascripts/runner/components/runner_projects.vue
@@ -1,11 +1,13 @@
<script>
-import { GlSkeletonLoader } from '@gitlab/ui';
+import { GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
import runnerProjectsQuery from '../graphql/show/runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
- I18N_NONE,
+ I18N_CLEAR_FILTER_PROJECTS,
+ I18N_FILTER_PROJECTS,
+ I18N_NO_PROJECTS_FOUND,
I18N_FETCH_ERROR,
RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
} from '../constants';
@@ -14,9 +16,12 @@ import { captureException } from '../sentry_utils';
import RunnerAssignedItem from './runner_assigned_item.vue';
import RunnerPagination from './runner_pagination.vue';
+const SHORT_SEARCH_LENGTH = 3;
+
export default {
name: 'RunnerProjects',
components: {
+ GlSearchBoxByType,
GlSkeletonLoader,
RunnerAssignedItem,
RunnerPagination,
@@ -35,6 +40,7 @@ export default {
pageInfo: {},
count: 0,
},
+ search: '',
pagination: {},
};
},
@@ -61,9 +67,10 @@ export default {
},
computed: {
variables() {
- const { id } = this.runner;
+ const { search, runner } = this;
return {
- id,
+ id: runner.id,
+ search: search.length >= SHORT_SEARCH_LENGTH ? search : '',
...getPaginationVariables(this.pagination, RUNNER_DETAILS_PROJECTS_PAGE_SIZE),
};
},
@@ -80,22 +87,38 @@ export default {
isOwner(projectId) {
return projectId === this.projects.ownerProjectId;
},
+ onSearchInput(search) {
+ this.search = search;
+ this.pagination = {};
+ },
onPaginationInput(value) {
this.pagination = value;
},
},
- I18N_NONE,
+ RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ I18N_CLEAR_FILTER_PROJECTS,
+ I18N_FILTER_PROJECTS,
+ I18N_NO_PROJECTS_FOUND,
};
</script>
<template>
<div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
- <h3 class="gl-font-lg gl-mt-5 gl-mb-0">
+ <h3 class="gl-font-lg gl-mt-5">
{{ heading }}
</h3>
+ <gl-search-box-by-type
+ :is-loading="loading"
+ :clear-button-title="$options.I18N_CLEAR_FILTER_PROJECTS"
+ :placeholder="$options.I18N_FILTER_PROJECTS"
+ debounce="500"
+ class="gl-w-28"
+ :value="search"
+ @input="onSearchInput"
+ />
- <div v-if="loading" class="gl-py-5">
- <gl-skeleton-loader />
+ <div v-if="!projects.items.length && loading" class="gl-py-5">
+ <gl-skeleton-loader v-for="i in $options.RUNNER_DETAILS_PROJECTS_PAGE_SIZE" :key="i" />
</div>
<template v-else-if="projects.items.length">
<runner-assigned-item
@@ -110,7 +133,7 @@ export default {
:is-owner="isOwner(project.id)"
/>
</template>
- <span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
+ <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div>
<runner-pagination
:disabled="loading"
diff --git a/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue
new file mode 100644
index 00000000000..e3a9a9fd8a4
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_stacked_layout_banner.vue
@@ -0,0 +1,58 @@
+<script>
+import allChangesCommittedSvg from '@gitlab/svgs/dist/illustrations/multi-editor_all_changes_committed_empty.svg';
+import { GlBanner } from '@gitlab/ui';
+
+import { s__ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+
+const I18N_TITLE = s__("Runners|We've made some changes and want your feedback");
+const I18N_DESCRIPTION = s__(
+ "Runners|We want you to be able to manage your runners easily and efficiently from this page, and we are making changes to get there. Give us feedback on how we're doing!",
+);
+const I18N_LINK = s__('Runners|Add your feedback in the issue');
+
+// use a data url instead getting it from via HTML data-* attributes to simplify removal of this feature flag
+const ILLUSTRATION_URL = `data:image/svg+xml;utf8,${encodeURIComponent(allChangesCommittedSvg)}`;
+const ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/371621';
+const STORAGE_KEY = 'runner_list_stacked_layout_feedback_dismissed';
+
+export default {
+ components: {
+ GlBanner,
+ LocalStorageSync,
+ },
+ data() {
+ return {
+ isDismissed: false,
+ };
+ },
+ methods: {
+ onClose() {
+ this.isDismissed = true;
+ },
+ },
+ I18N_TITLE,
+ I18N_DESCRIPTION,
+ I18N_LINK,
+ ILLUSTRATION_URL,
+ ISSUE_URL,
+ STORAGE_KEY,
+};
+</script>
+
+<template>
+ <div>
+ <local-storage-sync v-model="isDismissed" :storage-key="$options.STORAGE_KEY" />
+ <gl-banner
+ v-if="!isDismissed"
+ :svg-path="$options.ILLUSTRATION_URL"
+ :title="$options.I18N_TITLE"
+ :button-text="$options.I18N_LINK"
+ :button-link="$options.ISSUE_URL"
+ class="gl-my-5"
+ @close="onClose"
+ >
+ <p>{{ $options.I18N_DESCRIPTION }}</p>
+ </gl-banner>
+ </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_status_badge.vue b/app/assets/javascripts/runner/components/runner_status_badge.vue
index 073d0a49f59..d084408781e 100644
--- a/app/assets/javascripts/runner/components/runner_status_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_status_badge.vue
@@ -1,8 +1,12 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import {
+ I18N_STATUS_ONLINE,
+ I18N_STATUS_NEVER_CONTACTED,
+ I18N_STATUS_OFFLINE,
+ I18N_STATUS_STALE,
I18N_ONLINE_TIMEAGO_TOOLTIP,
I18N_NEVER_CONTACTED_TOOLTIP,
I18N_OFFLINE_TIMEAGO_TOOLTIP,
@@ -39,26 +43,30 @@ export default {
switch (this.runner?.status) {
case STATUS_ONLINE:
return {
+ icon: 'status-active',
variant: 'success',
- label: s__('Runners|online'),
+ label: I18N_STATUS_ONLINE,
tooltip: this.timeAgoTooltip(I18N_ONLINE_TIMEAGO_TOOLTIP),
};
case STATUS_NEVER_CONTACTED:
return {
+ icon: 'time-out',
variant: 'muted',
- label: s__('Runners|never contacted'),
+ label: I18N_STATUS_NEVER_CONTACTED,
tooltip: I18N_NEVER_CONTACTED_TOOLTIP,
};
case STATUS_OFFLINE:
return {
+ icon: 'time-out',
variant: 'muted',
- label: s__('Runners|offline'),
+ label: I18N_STATUS_OFFLINE,
tooltip: this.timeAgoTooltip(I18N_OFFLINE_TIMEAGO_TOOLTIP),
};
case STATUS_STALE:
return {
+ icon: 'time-out',
variant: 'warning',
- label: s__('Runners|stale'),
+ label: I18N_STATUS_STALE,
// runner may have contacted (or not) and be stale: consider both cases.
tooltip: this.runner.contactedAt
? this.timeAgoTooltip(I18N_STALE_TIMEAGO_TOOLTIP)
@@ -77,7 +85,13 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" :variant="badge.variant" v-bind="$attrs">
+ <gl-badge
+ v-if="badge"
+ v-gl-tooltip="badge.tooltip"
+ :variant="badge.variant"
+ :icon="badge.icon"
+ v-bind="$attrs"
+ >
{{ badge.label }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_tags.vue b/app/assets/javascripts/runner/components/runner_tags.vue
index 797d2a35b2c..38e566f9f53 100644
--- a/app/assets/javascripts/runner/components/runner_tags.vue
+++ b/app/assets/javascripts/runner/components/runner_tags.vue
@@ -20,7 +20,7 @@ export default {
};
</script>
<template>
- <span>
+ <span v-if="tagList && tagList.length">
<runner-tag
v-for="tag in tagList"
:key="tag"
diff --git a/app/assets/javascripts/runner/components/runner_type_badge.vue b/app/assets/javascripts/runner/components/runner_type_badge.vue
index b885dcefdcb..f568f914004 100644
--- a/app/assets/javascripts/runner/components/runner_type_badge.vue
+++ b/app/assets/javascripts/runner/components/runner_type_badge.vue
@@ -1,26 +1,31 @@
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
-import { s__ } from '~/locale';
import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
+ I18N_INSTANCE_TYPE,
I18N_INSTANCE_RUNNER_DESCRIPTION,
+ I18N_GROUP_TYPE,
I18N_GROUP_RUNNER_DESCRIPTION,
+ I18N_PROJECT_TYPE,
I18N_PROJECT_RUNNER_DESCRIPTION,
} from '../constants';
const BADGE_DATA = {
[INSTANCE_TYPE]: {
- text: s__('Runners|shared'),
+ icon: 'users',
+ text: I18N_INSTANCE_TYPE,
tooltip: I18N_INSTANCE_RUNNER_DESCRIPTION,
},
[GROUP_TYPE]: {
- text: s__('Runners|group'),
+ icon: 'group',
+ text: I18N_GROUP_TYPE,
tooltip: I18N_GROUP_RUNNER_DESCRIPTION,
},
[PROJECT_TYPE]: {
- text: s__('Runners|specific'),
+ icon: 'project',
+ text: I18N_PROJECT_TYPE,
tooltip: I18N_PROJECT_RUNNER_DESCRIPTION,
},
};
@@ -50,7 +55,13 @@ export default {
};
</script>
<template>
- <gl-badge v-if="badge" v-gl-tooltip="badge.tooltip" variant="info" v-bind="$attrs">
+ <gl-badge
+ v-if="badge"
+ v-gl-tooltip="badge.tooltip"
+ variant="muted"
+ :icon="badge.icon"
+ v-bind="$attrs"
+ >
{{ badge.text }}
</gl-badge>
</template>
diff --git a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
index c1ad5da3ab9..97ee8ec3eef 100644
--- a/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/paused_token_config.js
@@ -1,7 +1,7 @@
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import { PARAM_KEY_PAUSED } from '../../constants';
+import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants';
const options = [
{ value: 'true', title: __('Yes') },
@@ -10,7 +10,7 @@ const options = [
export const pausedTokenConfig = {
icon: 'pause',
- title: s__('Runners|Paused'),
+ title: I18N_PAUSED,
type: PARAM_KEY_PAUSED,
token: BaseToken,
unique: true,
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
index 9e6f63d3f7c..f5c42d120fb 100644
--- a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
+++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
@@ -1,7 +1,11 @@
-import { __, s__ } from '~/locale';
+import { __ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
+ I18N_STATUS_ONLINE,
+ I18N_STATUS_NEVER_CONTACTED,
+ I18N_STATUS_OFFLINE,
+ I18N_STATUS_STALE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NEVER_CONTACTED,
@@ -10,10 +14,10 @@ import {
} from '../../constants';
const options = [
- { value: STATUS_ONLINE, title: s__('Runners|Online') },
- { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
- { value: STATUS_NEVER_CONTACTED, title: s__('Runners|Never contacted') },
- { value: STATUS_STALE, title: s__('Runners|Stale') },
+ { value: STATUS_ONLINE, title: I18N_STATUS_ONLINE },
+ { value: STATUS_OFFLINE, title: I18N_STATUS_OFFLINE },
+ { value: STATUS_NEVER_CONTACTED, title: I18N_STATUS_NEVER_CONTACTED },
+ { value: STATUS_STALE, title: I18N_STATUS_STALE },
];
export const statusTokenConfig = {
diff --git a/app/assets/javascripts/runner/components/stat/runner_stats.vue b/app/assets/javascripts/runner/components/stat/runner_stats.vue
index 93e54ebe7f4..4df59f5a0c9 100644
--- a/app/assets/javascripts/runner/components/stat/runner_stats.vue
+++ b/app/assets/javascripts/runner/components/stat/runner_stats.vue
@@ -1,7 +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 {
+ I18N_STATUS_ONLINE,
+ I18N_STATUS_OFFLINE,
+ I18N_STATUS_STALE,
+ STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_STALE,
+} from '../../constants';
export default {
components: {
@@ -29,8 +35,8 @@ export default {
skip: this.statusCountSkip(STATUS_ONLINE),
variables: { ...this.variables, status: STATUS_ONLINE },
variant: 'success',
- title: s__('Runners|Online runners'),
- metaText: s__('Runners|online'),
+ title: I18N_STATUS_ONLINE,
+ metaIcon: 'status-active',
},
},
{
@@ -39,8 +45,8 @@ export default {
skip: this.statusCountSkip(STATUS_OFFLINE),
variables: { ...this.variables, status: STATUS_OFFLINE },
variant: 'muted',
- title: s__('Runners|Offline runners'),
- metaText: s__('Runners|offline'),
+ title: I18N_STATUS_OFFLINE,
+ metaIcon: 'status-waiting',
},
},
{
@@ -49,8 +55,8 @@ export default {
skip: this.statusCountSkip(STATUS_STALE),
variables: { ...this.variables, status: STATUS_STALE },
variant: 'warning',
- title: s__('Runners|Stale runners'),
- metaText: s__('Runners|stale'),
+ title: I18N_STATUS_STALE,
+ metaIcon: 'time-out',
},
},
];
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index ed1afcbf691..3009577599f 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -23,6 +23,12 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
);
export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
+// Status
+export const I18N_STATUS_ONLINE = s__('Runners|Online');
+export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted');
+export const I18N_STATUS_OFFLINE = s__('Runners|Offline');
+export const I18N_STATUS_STALE = s__('Runners|Stale');
+
// Status help popover
export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses');
@@ -62,6 +68,7 @@ export const I18N_STALE_NEVER_CONTACTED_TOOLTIP = s__(
export const I18N_EDIT = __('Edit');
export const I18N_PAUSE = __('Pause');
+export const I18N_PAUSED = s__('Runners|Paused');
export const I18N_PAUSE_TOOLTIP = s__('Runners|Pause from accepting jobs');
export const I18N_PAUSED_DESCRIPTION = s__('Runners|Not accepting jobs');
@@ -77,20 +84,27 @@ export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__(
);
export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
+// List
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__(
'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.',
);
+export const I18N_VERSION_LABEL = s__('Runners|Version %{version}');
+export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}');
+export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}');
// Runner details
export const I18N_DETAILS = s__('Runners|Details');
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
+export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects');
+export const I18N_CLEAR_FILTER_PROJECTS = __('Clear');
export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runners|This runner has not run any jobs.');
+export const I18N_NO_PROJECTS_FOUND = __('No projects found');
// Styles
-export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
+export const RUNNER_TAG_BADGE_VARIANT = 'info';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
// Filtered search parameter names
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 ce23bddb898..a12ba7a751a 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
@@ -9,6 +9,7 @@ fragment ListItemShared on CiRunner {
locked
jobCount
tagList
+ createdAt
contactedAt
status(legacyMode: null)
userPermissions {
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 499c0156770..b5689ff7687 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
@@ -17,6 +17,7 @@ fragment RunnerDetailsShared on CiRunner {
createdAt
status(legacyMode: null)
contactedAt
+ tokenExpiresAt
version
editAdminUrl
userPermissions {
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 acc4a641565..e42648b3079 100644
--- a/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
+++ b/app/assets/javascripts/runner/graphql/show/runner_projects.query.graphql
@@ -2,6 +2,7 @@
query getRunnerProjects(
$id: CiRunnerID!
+ $search: String
$first: Int
$last: Int
$before: String
@@ -13,7 +14,7 @@ query getRunnerProjects(
id
}
projectCount
- projects(first: $first, last: $last, before: $before, after: $after) {
+ projects(search: $search, first: $first, last: $last, before: $before, after: $after) {
nodes {
id
avatarUrl
diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js
index 62a0dab9211..e75f337b38e 100644
--- a/app/assets/javascripts/runner/group_runner_show/index.js
+++ b/app/assets/javascripts/runner/group_runner_show/index.js
@@ -1,11 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage';
import GroupRunnerShowApp from './group_runner_show_app.vue';
Vue.use(VueApollo);
export const initGroupRunnerShow = (selector = '#js-group-runner-show') => {
+ showAlertFromLocalStorage();
+
const el = document.querySelector(selector);
if (!el) {
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 a82411a2120..70826a6bfa1 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -13,6 +13,7 @@ import {
import groupRunnersQuery from 'ee_else_ce/runner/graphql/list/group_runners.query.graphql';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
+import RunnerStackedLayoutBanner from '../components/runner_stacked_layout_banner.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerListEmptyState from '../components/runner_list_empty_state.vue';
@@ -37,6 +38,7 @@ export default {
components: {
GlLink,
RegistrationDropdown,
+ RunnerStackedLayoutBanner,
RunnerFilteredSearchBar,
RunnerList,
RunnerListEmptyState,
@@ -50,7 +52,8 @@ export default {
props: {
registrationToken: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
groupFullPath: {
type: String,
@@ -178,6 +181,8 @@ export default {
<template>
<div>
+ <runner-stacked-layout-banner />
+
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
ref="runner-type-tabs"
@@ -191,6 +196,7 @@ export default {
/>
<registration-dropdown
+ v-if="registrationToken"
class="gl-ml-auto"
:registration-token="registrationToken"
:type="$options.GROUP_TYPE"
diff --git a/app/assets/javascripts/runner/admin_runner_edit/index.js b/app/assets/javascripts/runner/runner_edit/index.js
index a2ac5731a62..5b2ddb8f68e 100644
--- a/app/assets/javascripts/runner/admin_runner_edit/index.js
+++ b/app/assets/javascripts/runner/runner_edit/index.js
@@ -1,11 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import AdminRunnerEditApp from './admin_runner_edit_app.vue';
+import RunnerEditApp from './runner_edit_app.vue';
Vue.use(VueApollo);
-export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => {
+export const initRunnerEdit = (selector) => {
const el = document.querySelector(selector);
if (!el) {
@@ -22,7 +22,7 @@ export const initAdminRunnerEdit = (selector = '#js-admin-runner-edit') => {
el,
apolloProvider,
render(h) {
- return h(AdminRunnerEditApp, {
+ return h(RunnerEditApp, {
props: {
runnerId,
runnerPath,
diff --git a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue b/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue
index 40787cf72da..879162916a9 100644
--- a/app/assets/javascripts/runner/admin_runner_edit/admin_runner_edit_app.vue
+++ b/app/assets/javascripts/runner/runner_edit/runner_edit_app.vue
@@ -9,7 +9,7 @@ import runnerFormQuery from '../graphql/edit/runner_form.query.graphql';
import { captureException } from '../sentry_utils';
export default {
- name: 'AdminRunnerEditApp',
+ name: 'RunnerEditApp',
components: {
RunnerHeader,
RunnerUpdateForm,
diff --git a/app/assets/javascripts/runner/utils.js b/app/assets/javascripts/runner/utils.js
index cb2917a92fd..1ca0a9e86b5 100644
--- a/app/assets/javascripts/runner/utils.js
+++ b/app/assets/javascripts/runner/utils.js
@@ -70,3 +70,14 @@ export const getPaginationVariables = (pagination, pageSize = 10) => {
// Get the first N items
return { first: pageSize };
};
+
+/**
+ * Turns a server-provided interval integer represented as a string into an
+ * integer that the frontend can use.
+ *
+ * @param {String} interval - String to convert
+ * @returns Parsed integer
+ */
+export const parseInterval = (interval) => {
+ return typeof interval === 'string' ? parseInt(interval, 10) : null;
+};
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index d9d4056466a..446ab7f433c 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,11 +1,11 @@
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import { queryToObject } from '~/lib/utils/url_utility';
-import Project from '~/pages/projects/project';
import refreshCounts from '~/pages/search/show/refresh_counts';
import { initSidebar } from './sidebar';
import { initSearchSort } from './sort';
import createStore from './store';
import { initTopbar } from './topbar';
+import { initBlobRefSwitcher } from './under_topbar';
export const initSearchApp = () => {
const query = queryToObject(window.location.search);
@@ -18,5 +18,5 @@ export const initSearchApp = () => {
setHighlightClass(query.search); // Code Highlighting
refreshCounts(); // Other Scope Tab Counts
- Project.initRefSwitcher(); // Code Search Branch Picker
+ initBlobRefSwitcher(); // Code Search Branch Picker
};
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
index 5653cddda60..ff639d538b3 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -144,9 +144,9 @@ export default {
/>
<gl-dropdown-item
class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
- :is-check-item="true"
+ is-check-item
:is-checked="isSelected($options.ANY_OPTION)"
- :is-check-centered="true"
+ is-check-centered
@click="updateDropdown($options.ANY_OPTION)"
>
<span data-testid="item-title">{{ $options.ANY_OPTION.name }}</span>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
index a4254a355a2..70156142365 100644
--- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue
@@ -53,9 +53,9 @@ export default {
<template>
<gl-dropdown-item
- :is-check-item="true"
+ is-check-item
:is-checked="isSelected"
- :is-check-centered="true"
+ is-check-centered
@click="$emit('change', item)"
>
<div class="gl-display-flex gl-align-items-center">
diff --git a/app/assets/javascripts/search/under_topbar/index.js b/app/assets/javascripts/search/under_topbar/index.js
new file mode 100644
index 00000000000..8e50c6655dd
--- /dev/null
+++ b/app/assets/javascripts/search/under_topbar/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
+
+Vue.use(Translate);
+
+export const initBlobRefSwitcher = () => {
+ const el = document.getElementById('js-blob-ref-switcher');
+
+ if (!el) return false;
+
+ const { projectId, ref, fieldName } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(RefSelector, {
+ props: {
+ projectId,
+ value: ref,
+ },
+ on: {
+ input(selected) {
+ visitUrl(setUrlParams({ [fieldName]: selected }));
+ },
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index 5a9ef832e05..77216408c39 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -157,7 +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'),
+ GENERIC: s__('ciReport|Manually added'),
};
export const securityFeatures = [
diff --git a/app/assets/javascripts/set_status_modal/constants.js b/app/assets/javascripts/set_status_modal/constants.js
new file mode 100644
index 00000000000..53e64db1497
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/constants.js
@@ -0,0 +1,14 @@
+import { timeRanges } from '~/vue_shared/constants';
+import { __ } from '~/locale';
+
+export const NEVER_TIME_RANGE = {
+ label: __('Never'),
+ name: 'never',
+};
+
+export const TIME_RANGES_WITH_NEVER = [NEVER_TIME_RANGE, ...timeRanges];
+
+export const AVAILABILITY_STATUS = {
+ BUSY: 'busy',
+ NOT_SET: 'not_set',
+};
diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue
new file mode 100644
index 00000000000..7f9a30b7ff1
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/set_status_form.vue
@@ -0,0 +1,231 @@
+<script>
+import {
+ GlButton,
+ GlTooltipDirective,
+ GlIcon,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlFormGroup,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+import $ from 'jquery';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import * as Emoji from '~/emoji';
+import { s__ } from '~/locale';
+import { TIME_RANGES_WITH_NEVER, AVAILABILITY_STATUS } from './constants';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormInputGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlFormGroup,
+ EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ defaultEmoji: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ emoji: {
+ type: String,
+ required: true,
+ },
+ message: {
+ type: String,
+ required: true,
+ },
+ availability: {
+ type: Boolean,
+ required: true,
+ },
+ clearStatusAfter: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ currentClearStatusAfter: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ defaultEmojiTag: '',
+ emojiTag: '',
+ };
+ },
+ computed: {
+ isCustomEmoji() {
+ return this.emoji !== this.defaultEmoji;
+ },
+ isDirty() {
+ return Boolean(this.message.length || this.isCustomEmoji);
+ },
+ noEmoji() {
+ return this.emojiTag === '';
+ },
+ },
+ mounted() {
+ this.setupEmojiListAndAutocomplete();
+ },
+ methods: {
+ async setupEmojiListAndAutocomplete() {
+ const emojiAutocomplete = new GfmAutoComplete();
+ emojiAutocomplete.setup($(this.$refs.statusMessageField.$el), { emojis: true });
+
+ if (this.emoji) {
+ this.emojiTag = Emoji.glEmojiTag(this.emoji);
+ }
+ this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
+
+ this.setDefaultEmoji();
+ },
+ setDefaultEmoji() {
+ const { emojiTag } = this;
+ const hasStatusMessage = Boolean(this.message.length);
+ if (hasStatusMessage && emojiTag) {
+ return;
+ }
+
+ if (hasStatusMessage) {
+ this.emojiTag = this.defaultEmojiTag;
+ } else if (emojiTag === this.defaultEmojiTag) {
+ this.clearEmoji();
+ }
+ },
+ handleEmojiClick(emoji) {
+ this.$emit('emoji-click', emoji);
+
+ this.emojiTag = Emoji.glEmojiTag(emoji);
+ },
+ clearEmoji() {
+ if (this.emojiTag) {
+ this.emojiTag = '';
+ }
+ },
+ clearStatusInputs() {
+ this.$emit('emoji-click', '');
+ this.$emit('message-input', '');
+ this.clearEmoji();
+ },
+ },
+ TIME_RANGES_WITH_NEVER,
+ AVAILABILITY_STATUS,
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
+ i18n: {
+ statusMessagePlaceholder: s__(`SetStatusModal|What's your status?`),
+ clearStatusButtonLabel: s__('SetStatusModal|Clear status'),
+ availabilityCheckboxLabel: s__('SetStatusModal|Busy'),
+ availabilityCheckboxHelpText: s__(
+ 'SetStatusModal|An indicator appears next to your name and avatar',
+ ),
+ clearStatusAfterDropdownLabel: s__('SetStatusModal|Clear status after'),
+ clearStatusAfterMessage: s__('SetStatusModal|Your status resets on %{date}.'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-input-group class="gl-mb-5">
+ <gl-form-input
+ ref="statusMessageField"
+ :value="message"
+ :placeholder="$options.i18n.statusMessagePlaceholder"
+ @keyup="setDefaultEmoji"
+ @input="$emit('message-input', $event)"
+ @keyup.enter.prevent
+ />
+ <template #prepend>
+ <emoji-picker
+ dropdown-class="gl-h-full"
+ toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
+ boundary="viewport"
+ :right="false"
+ @click="handleEmojiClick"
+ >
+ <template #button-content>
+ <span
+ v-if="noEmoji"
+ class="no-emoji-placeholder position-relative"
+ data-testid="no-emoji-placeholder"
+ >
+ <gl-icon name="slight-smile" class="award-control-icon-neutral" />
+ <gl-icon name="smiley" class="award-control-icon-positive" />
+ <gl-icon name="smile" class="award-control-icon-super-positive" />
+ </span>
+ <span v-else>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="emojiTag"
+ data-testid="selected-emoji"
+ ></span>
+ </span>
+ </template>
+ </emoji-picker>
+ </template>
+ <template v-if="isDirty" #append>
+ <gl-button
+ v-gl-tooltip.bottom
+ :title="$options.i18n.clearStatusButtonLabel"
+ :aria-label="$options.i18n.clearStatusButtonLabel"
+ icon="close"
+ class="js-clear-user-status-button"
+ @click="clearStatusInputs"
+ />
+ </template>
+ </gl-form-input-group>
+
+ <gl-form-checkbox
+ :checked="availability"
+ class="gl-mb-5"
+ data-testid="user-availability-checkbox"
+ @input="$emit('availability-input', $event)"
+ >
+ {{ $options.i18n.availabilityCheckboxLabel }}
+ <template #help>
+ {{ $options.i18n.availabilityCheckboxHelpText }}
+ </template>
+ </gl-form-checkbox>
+
+ <gl-form-group :label="$options.i18n.clearStatusAfterDropdownLabel" class="gl-mb-0">
+ <gl-dropdown
+ block
+ :text="clearStatusAfter.label"
+ data-testid="clear-status-at-dropdown"
+ toggle-class="gl-mb-0 gl-form-input-md"
+ >
+ <gl-dropdown-item
+ v-for="after in $options.TIME_RANGES_WITH_NEVER"
+ :key="after.name"
+ :data-testid="after.name"
+ @click="$emit('clear-status-after-click', after)"
+ >{{ after.label }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+
+ <template v-if="currentClearStatusAfter.length" #description>
+ <span data-testid="clear-status-at-message">
+ <gl-sprintf :message="$options.i18n.clearStatusAfterMessage">
+ <template #date>{{ currentClearStatusAfter }}</template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-form-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index 2cdec8fc481..80b1cb8c4d5 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -1,55 +1,21 @@
<script>
-import {
- GlButton,
- GlToast,
- GlModal,
- GlTooltipDirective,
- GlIcon,
- GlFormCheckbox,
- GlFormInput,
- GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
- GlSafeHtmlDirective,
-} from '@gitlab/ui';
-import $ from 'jquery';
+import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui';
import Vue from 'vue';
-import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import * as Emoji from '~/emoji';
import createFlash from '~/flash';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
-import { __, s__, sprintf } from '~/locale';
+import { s__ } from '~/locale';
import { updateUserStatus } from '~/rest_api';
-import { timeRanges } from '~/vue_shared/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isUserBusy } from './utils';
-
-export const AVAILABILITY_STATUS = {
- BUSY: 'busy',
- NOT_SET: 'not_set',
-};
+import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
+import SetStatusForm from './set_status_form.vue';
Vue.use(GlToast);
-const statusTimeRanges = [
- {
- label: __('Never'),
- name: 'never',
- },
- ...timeRanges,
-];
-
export default {
components: {
- GlButton,
- GlIcon,
GlModal,
- GlFormCheckbox,
- GlFormInput,
- GlFormInputGroup,
- GlDropdown,
- GlDropdownItem,
- EmojiPicker: () => import('~/emoji/components/picker.vue'),
+ SetStatusForm,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -85,26 +51,12 @@ export default {
return {
defaultEmojiTag: '',
emoji: this.currentEmoji,
- emojiMenu: null,
- emojiTag: '',
message: this.currentMessage,
modalId: 'set-user-status-modal',
- noEmoji: true,
availability: isUserBusy(this.currentAvailability),
- clearStatusAfter: statusTimeRanges[0],
- clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), {
- date: this.currentClearStatusAfter,
- }),
+ clearStatusAfter: NEVER_TIME_RANGE,
};
},
- computed: {
- isCustomEmoji() {
- return this.emoji !== this.defaultEmoji;
- },
- isDirty() {
- return Boolean(this.message.length || this.isCustomEmoji);
- },
- },
mounted() {
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -112,62 +64,10 @@ export default {
closeModal() {
this.$root.$emit(BV_HIDE_MODAL, this.modalId);
},
- setupEmojiListAndAutocomplete() {
- const emojiAutocomplete = new GfmAutoComplete();
- emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
-
- Emoji.initEmojiMap()
- .then(() => {
- if (this.emoji) {
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- }
- this.noEmoji = this.emoji === '';
- this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji);
-
- this.setDefaultEmoji();
- })
- .catch(() =>
- createFlash({
- message: __('Failed to load emoji list.'),
- }),
- );
- },
- setDefaultEmoji() {
- const { emojiTag } = this;
- const hasStatusMessage = Boolean(this.message.length);
- if (hasStatusMessage && emojiTag) {
- return;
- }
-
- if (hasStatusMessage) {
- this.noEmoji = false;
- this.emojiTag = this.defaultEmojiTag;
- } else if (emojiTag === this.defaultEmojiTag) {
- this.noEmoji = true;
- this.clearEmoji();
- }
- },
- setEmoji(emoji) {
- this.emoji = emoji;
- this.noEmoji = false;
- this.clearEmoji();
-
- this.emojiTag = Emoji.glEmojiTag(this.emoji);
- },
- clearEmoji() {
- if (this.emojiTag) {
- this.emojiTag = '';
- }
- },
- clearStatusInputs() {
- this.emoji = '';
- this.message = '';
- this.noEmoji = true;
- this.clearEmoji();
- },
removeStatus() {
this.availability = false;
- this.clearStatusInputs();
+ this.emoji = '';
+ this.message = '';
this.setStatus();
},
setStatus() {
@@ -178,7 +78,7 @@ export default {
message,
availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET,
clearStatusAfter:
- clearStatusAfter.label === statusTimeRanges[0].label ? null : clearStatusAfter.shortcut,
+ clearStatusAfter.label === NEVER_TIME_RANGE.label ? null : clearStatusAfter.shortcut,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
@@ -197,11 +97,19 @@ export default {
this.closeModal();
},
- setClearStatusAfter(after) {
+ handleMessageInput(value) {
+ this.message = value;
+ },
+ handleEmojiClick(emoji) {
+ this.emoji = emoji;
+ },
+ handleClearStatusAfterClick(after) {
this.clearStatusAfter = after;
},
+ handleAvailabilityInput(value) {
+ this.availability = value;
+ },
},
- statusTimeRanges,
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
actionPrimary: { text: s__('SetStatusModal|Set status') },
actionSecondary: { text: s__('SetStatusModal|Remove status') },
@@ -215,85 +123,20 @@ export default {
:action-primary="$options.actionPrimary"
:action-secondary="$options.actionSecondary"
modal-class="set-user-status-modal"
- @shown="setupEmojiListAndAutocomplete"
@primary="setStatus"
@secondary="removeStatus"
>
- <input v-model="emoji" class="js-status-emoji-field" type="hidden" name="user[status][emoji]" />
- <gl-form-input-group class="gl-mb-5">
- <gl-form-input
- ref="statusMessageField"
- v-model="message"
- :placeholder="s__(`SetStatusModal|What's your status?`)"
- class="js-status-message-field"
- name="user[status][message]"
- @keyup="setDefaultEmoji"
- @keyup.enter.prevent
- />
- <template #prepend>
- <emoji-picker
- dropdown-class="gl-h-full"
- toggle-class="btn emoji-menu-toggle-button gl-px-4! gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
- boundary="viewport"
- :right="false"
- @click="setEmoji"
- >
- <template #button-content>
- <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
- <span
- v-show="noEmoji"
- class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
- >
- <gl-icon name="slight-smile" class="award-control-icon-neutral" />
- <gl-icon name="smiley" class="award-control-icon-positive" />
- <gl-icon name="smile" class="award-control-icon-super-positive" />
- </span>
- </template>
- </emoji-picker>
- </template>
- <template v-if="isDirty" #append>
- <gl-button
- v-gl-tooltip.bottom
- :title="s__('SetStatusModal|Clear status')"
- :aria-label="s__('SetStatusModal|Clear status')"
- icon="close"
- class="js-clear-user-status-button"
- @click="clearStatusInputs"
- />
- </template>
- </gl-form-input-group>
-
- <gl-form-checkbox
- v-model="availability"
- class="gl-mb-5"
- data-testid="user-availability-checkbox"
- >
- {{ s__('SetStatusModal|Busy') }}
- <template #help>
- {{ s__('SetStatusModal|An indicator appears next to your name and avatar') }}
- </template>
- </gl-form-checkbox>
-
- <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.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)"
- >{{ after.label }}</gl-dropdown-item
- >
- </gl-dropdown>
- </div>
- <div
- v-if="currentClearStatusAfter.length"
- class="gl-mt-3 gl-text-gray-400 gl-font-sm"
- data-testid="clear-status-at-message"
- >
- {{ clearStatusAfterMessage }}
- </div>
- </div>
+ <set-status-form
+ :default-emoji="defaultEmoji"
+ :emoji="emoji"
+ :message="message"
+ :availability="availability"
+ :clear-status-after="clearStatusAfter"
+ :current-clear-status-after="currentClearStatusAfter"
+ @message-input="handleMessageInput"
+ @emoji-click="handleEmojiClick"
+ @clear-status-after-click="handleClearStatusAfterClick"
+ @availability-input="handleAvailabilityInput"
+ />
</gl-modal>
</template>
diff --git a/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
new file mode 100644
index 00000000000..c709611e13d
--- /dev/null
+++ b/app/assets/javascripts/set_status_modal/user_profile_set_status_wrapper.vue
@@ -0,0 +1,100 @@
+<script>
+import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+import dateFormat from '~/lib/dateformat';
+import SetStatusForm from './set_status_form.vue';
+import { isUserBusy } from './utils';
+import { NEVER_TIME_RANGE, AVAILABILITY_STATUS } from './constants';
+
+export default {
+ components: { SetStatusForm },
+ inject: ['fields'],
+ data() {
+ return {
+ emoji: this.fields.emoji.value,
+ message: this.fields.message.value,
+ availability: isUserBusy(this.fields.availability.value),
+ clearStatusAfter: NEVER_TIME_RANGE,
+ currentClearStatusAfter: this.fields.clearStatusAfter.value,
+ };
+ },
+ computed: {
+ clearStatusAfterInputValue() {
+ return this.clearStatusAfter.label === NEVER_TIME_RANGE.label
+ ? null
+ : this.clearStatusAfter.shortcut;
+ },
+ availabilityInputValue() {
+ return this.availability
+ ? this.$options.AVAILABILITY_STATUS.BUSY
+ : this.$options.AVAILABILITY_STATUS.NOT_SET;
+ },
+ },
+ mounted() {
+ this.$options.formEl = document.querySelector('form.js-edit-user');
+
+ if (!this.$options.formEl) return;
+
+ this.$options.formEl.addEventListener('ajax:success', this.handleFormSuccess);
+ },
+ beforeDestroy() {
+ if (!this.$options.formEl) return;
+
+ this.$options.formEl.removeEventListener('ajax:success', this.handleFormSuccess);
+ },
+ methods: {
+ handleMessageInput(value) {
+ this.message = value;
+ },
+ handleEmojiClick(emoji) {
+ this.emoji = emoji;
+ },
+ handleClearStatusAfterClick(after) {
+ this.clearStatusAfter = after;
+ },
+ handleAvailabilityInput(value) {
+ this.availability = value;
+ },
+ handleFormSuccess() {
+ if (!this.clearStatusAfter?.duration?.seconds) {
+ this.currentClearStatusAfter = '';
+
+ return;
+ }
+
+ const now = new Date();
+ const currentClearStatusAfterDate = new Date(
+ now.getTime() + secondsToMilliseconds(this.clearStatusAfter.duration.seconds),
+ );
+
+ this.currentClearStatusAfter = dateFormat(
+ currentClearStatusAfterDate,
+ "UTC:yyyy-mm-dd HH:MM:ss 'UTC'",
+ );
+ this.clearStatusAfter = NEVER_TIME_RANGE;
+ },
+ },
+ AVAILABILITY_STATUS,
+ formEl: null,
+};
+</script>
+
+<template>
+ <div>
+ <input :value="emoji" type="hidden" :name="fields.emoji.name" />
+ <input :value="message" type="hidden" :name="fields.message.name" />
+ <input :value="availabilityInputValue" type="hidden" :name="fields.availability.name" />
+ <input :value="clearStatusAfterInputValue" type="hidden" :name="fields.clearStatusAfter.name" />
+ <set-status-form
+ default-emoji="speech_balloon"
+ :emoji="emoji"
+ :message="message"
+ :availability="availability"
+ :clear-status-after="clearStatusAfter"
+ :current-clear-status-after="currentClearStatusAfter"
+ @message-input="handleMessageInput"
+ @emoji-click="handleEmojiClick"
+ @clear-status-after-click="handleClearStatusAfterClick"
+ @availability-input="handleAvailabilityInput"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js
index e17d95adb25..950091195d2 100644
--- a/app/assets/javascripts/set_status_modal/utils.js
+++ b/app/assets/javascripts/set_status_modal/utils.js
@@ -1,7 +1,4 @@
-export const AVAILABILITY_STATUS = {
- BUSY: 'busy',
- NOT_SET: 'not_set',
-};
+import { AVAILABILITY_STATUS } from './constants';
export const isUserBusy = (status = '') =>
Boolean(status.length && status.toLowerCase().trim() === AVAILABILITY_STATUS.BUSY);
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index a94dd128a1a..4408ebb881b 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -23,6 +23,10 @@ export default {
required: false,
default: false,
},
+ editable: {
+ type: Boolean,
+ required: true,
+ },
},
computed: {
assigneesText() {
@@ -43,7 +47,7 @@ export default {
data-testid="none"
>
<span> {{ __('None') }}</span>
- <template v-if="signedIn">
+ <template v-if="signedIn && editable">
<span class="gl-ml-2">-</span>
<gl-button
data-testid="assign-yourself"
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 5c432ca0e03..26fda2a823c 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -72,6 +72,10 @@ export default {
type: Boolean,
required: true,
},
+ editable: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
@@ -252,6 +256,7 @@ export default {
:users="assignees"
:issuable-type="issuableType"
:signed-in="signedIn"
+ :editable="editable"
@assign-self="assignSelf"
@expand-widget="expandWidget"
/>
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 336c291d4f1..c44ce8b0057 100644
--- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue
@@ -88,10 +88,7 @@ export default {
.then(
({
data: {
- issuableSetConfidential: {
- issuable: { confidential },
- errors,
- },
+ issuableSetConfidential: { errors },
},
}) => {
if (errors.length) {
@@ -99,7 +96,7 @@ export default {
message: errors[0],
});
} else {
- this.$emit('closeForm', { confidential });
+ this.$emit('closeForm');
}
},
)
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 eec083f23f3..f234c5ea3c9 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({ confidential } = {}) {
+ closeForm() {
this.$refs.editable.collapse();
this.$el.dispatchEvent(hideDropdownEvent);
- this.$emit('closeForm', { confidential });
+ this.$emit('closeForm');
},
// 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/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
index 8528ad56ddb..fd652583f76 100644
--- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
+++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue
@@ -16,7 +16,7 @@ export default {
<template>
<copyable-field
- data-qa-selector="copy-forward-email"
+ data-testid="copy-forward-email"
:name="s__('RightSidebar|Issue email')"
:clipboard-tooltip-text="s__('RightSidebar|Copy email address')"
:value="issueEmailAddress"
diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
index aeaac76cff4..9c41db98c63 100644
--- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
+++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue
@@ -62,7 +62,7 @@ export default {
v-for="status in $options.STATUS_LIST"
:key="status"
data-testid="status-dropdown-item"
- :is-check-item="true"
+ is-check-item
:is-checked="status === value"
@click="$emit('input', status)"
>
diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
index 65b51169420..c9e651370f9 100644
--- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue
@@ -1,10 +1,10 @@
<script>
import { GlSprintf } from '@gitlab/ui';
-import editFormButtons from './edit_form_buttons.vue';
+import EditFormButtons from './edit_form_buttons.vue';
export default {
components: {
- editFormButtons,
+ EditFormButtons,
GlSprintf,
},
props: {
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 5f1808ff4da..286bd50f6dd 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -6,7 +6,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createFlash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import toast from '~/vue_shared/plugins/global_toast';
-import editForm from './edit_form.vue';
+import EditForm from './edit_form.vue';
export default {
issue: 'issue',
@@ -23,7 +23,7 @@ export default {
displayText: __('Unlocked'),
},
components: {
- editForm,
+ EditForm,
GlIcon,
},
directives: {
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index b8804de653f..2f25c2fd4b0 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -1,7 +1,7 @@
<script>
import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, n__, sprintf } from '~/locale';
-import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
+import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
export default {
directives: {
@@ -11,7 +11,7 @@ export default {
GlButton,
GlIcon,
GlLoadingIcon,
- userAvatarImage,
+ UserAvatarImage,
},
props: {
loading: {
@@ -27,7 +27,7 @@ export default {
numberOfLessParticipants: {
type: Number,
required: false,
- default: 7,
+ default: 8,
},
showParticipantLabel: {
type: Boolean,
@@ -123,7 +123,7 @@ export default {
:size="24"
:tooltip-text="participant.name"
:img-alt="participant.name"
- css-classes="avatar-inline"
+ css-classes="gl-mr-0!"
tooltip-placement="bottom"
/>
</a>
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
index a09138a708b..46a04725a49 100644
--- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
@@ -49,6 +49,9 @@ export default {
error,
});
},
+ context: {
+ isSingleRequest: true,
+ },
},
},
computed: {
@@ -68,7 +71,7 @@ export default {
<participants
:loading="isLoading"
:participants="participants"
- :number-of-less-participants="7"
+ :number-of-less-participants="8"
:lazy="false"
class="block participants"
@toggleSidebar="toggleSidebar"
diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
index bf4ba715f85..a562df4ecd6 100644
--- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
+++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue
@@ -179,7 +179,7 @@ export default {
v-for="option in severitiesList"
:key="option.value"
data-testid="severityDropdownItem"
- :is-check-item="true"
+ is-check-item
:is-checked="option.value === severity"
@click="updateSeverity(option.value)"
>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 3d8a2cd847c..6c615109bb8 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -9,6 +9,8 @@ import {
GlLoadingIcon,
GlIcon,
GlTooltipDirective,
+ GlPopover,
+ GlButton,
} from '@gitlab/ui';
import { kebabCase, snakeCase } from 'lodash';
import createFlash from '~/flash';
@@ -17,6 +19,7 @@ import { IssuableType } from '~/issues/constants';
import { timeFor } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
dropdowni18nText,
Tracking,
@@ -47,7 +50,10 @@ export default {
GlSearchBoxByType,
GlIcon,
GlLoadingIcon,
+ GlPopover,
+ GlButton,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
isClassicSidebar: {
default: false,
@@ -66,6 +72,7 @@ export default {
},
},
},
+
props: {
issuableAttribute: {
type: String,
@@ -111,6 +118,10 @@ export default {
};
},
update(data) {
+ if (this.glFeatures?.epicWidgetEditConfirmation && this.isEpic) {
+ this.hasCurrentAttribute = data?.workspace?.issuable.hasEpic;
+ }
+
return data?.workspace?.issuable.attribute;
},
error(error) {
@@ -179,6 +190,8 @@ export default {
updating: false,
selectedTitle: null,
currentAttribute: null,
+ hasCurrentAttribute: false,
+ editConfirmation: false,
attributesList: [],
tracking: {
event: Tracking.editEvent,
@@ -228,6 +241,15 @@ export default {
snake: snakeCase(this.issuableAttribute),
};
},
+ shouldShowConfirmationPopover() {
+ if (!this.glFeatures?.epicWidgetEditConfirmation) {
+ return false;
+ }
+
+ return this.isEpic && this.currentAttribute === null && this.hasCurrentAttribute
+ ? !this.editConfirmation
+ : false;
+ },
},
methods: {
updateAttribute(attributeId) {
@@ -299,6 +321,17 @@ export default {
setFocus() {
this.$refs.search.focusInput();
},
+ handlePopoverClose() {
+ this.$refs.popover.$emit('close');
+ },
+ handlePopoverConfirm(cb) {
+ this.editConfirmation = true;
+ this.handlePopoverClose();
+ setTimeout(cb, 0);
+ },
+ handleEditConfirmation() {
+ this.$refs.popover.$emit('open');
+ },
},
};
</script>
@@ -308,10 +341,13 @@ export default {
ref="editable"
:title="attributeTypeTitle"
:data-testid="`${formatIssuableAttribute.kebab}-edit`"
+ :button-id="`${formatIssuableAttribute.kebab}-edit`"
:tracking="tracking"
+ :should-show-confirmation-popover="shouldShowConfirmationPopover"
:loading="updating || loading"
@open="handleOpen"
@close="handleClose"
+ @edit-confirm="handleEditConfirmation"
>
<template #collapsed>
<slot name="value-collapsed" :current-attribute="currentAttribute">
@@ -332,6 +368,10 @@ export default {
:class="isClassicSidebar ? 'hide-collapsed' : 'gl-mt-3'"
>
<span v-if="updating">{{ selectedTitle }}</span>
+ <template v-else-if="!currentAttribute && hasCurrentAttribute">
+ <gl-icon name="warning" class="gl-text-orange-500" />
+ <span class="gl-text-gray-500">{{ i18n.noPermissionToView }}</span>
+ </template>
<span v-else-if="!currentAttribute" class="gl-text-gray-500">
{{ $options.i18n.none }}
</span>
@@ -344,6 +384,7 @@ export default {
>
<gl-link
v-gl-tooltip="tooltipText"
+ class="gl-reset-color gl-hover-text-blue-800"
:href="attributeUrl"
:data-qa-selector="`${formatIssuableAttribute.snake}_link`"
>
@@ -353,7 +394,40 @@ export default {
</slot>
</div>
</template>
- <template #default>
+ <template v-if="shouldShowConfirmationPopover" #default="{ toggle }">
+ <gl-popover
+ ref="popover"
+ :target="`${formatIssuableAttribute.kebab}-edit`"
+ placement="bottomleft"
+ boundary="viewport"
+ triggers="click"
+ >
+ <div class="gl-mb-4 gl-font-base">
+ {{ i18n.editConfirmation }}
+ </div>
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-button
+ size="small"
+ variant="confirm"
+ category="primary"
+ data-testid="confirm-edit-cta"
+ @click.prevent="() => handlePopoverConfirm(toggle)"
+ >{{ i18n.editConfirmationCta }}</gl-button
+ >
+ <gl-button
+ class="gl-ml-auto"
+ size="small"
+ name="cancel"
+ variant="default"
+ category="primary"
+ data-testid="confirm-edit-cancel"
+ @click.prevent="handlePopoverClose"
+ >{{ i18n.editConfirmationCancel }}</gl-button
+ >
+ </div>
+ </gl-popover>
+ </template>
+ <template v-else #default>
<gl-dropdown
ref="newDropdown"
lazy
@@ -368,7 +442,7 @@ export default {
<gl-search-box-by-type ref="search" v-model="searchTerm" />
<gl-dropdown-item
:data-testid="`no-${formatIssuableAttribute.kebab}-item`"
- :is-check-item="true"
+ is-check-item
:is-checked="isAttributeChecked($options.noAttributeId)"
@click="updateAttribute($options.noAttributeId)"
>
@@ -395,7 +469,7 @@ export default {
<gl-dropdown-item
v-for="attrItem in attributesList"
:key="attrItem.id"
- :is-check-item="true"
+ is-check-item
:is-checked="isAttributeChecked(attrItem.id)"
:data-testid="`${formatIssuableAttribute.kebab}-items`"
@click="updateAttribute(attrItem.id)"
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 7551b181a58..cc88812c7b0 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -14,6 +14,11 @@ export default {
},
},
props: {
+ buttonId: {
+ type: String,
+ required: false,
+ default: '',
+ },
title: {
type: String,
required: false,
@@ -48,6 +53,11 @@ export default {
required: false,
default: true,
},
+ shouldShowConfirmationPopover: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -97,6 +107,11 @@ export default {
window.removeEventListener('keyup', this.collapseOnEscape);
},
toggle({ emitEvent = true } = {}) {
+ if (this.shouldShowConfirmationPopover) {
+ this.$emit('edit-confirm');
+ return;
+ }
+
if (this.edit) {
this.collapse({ emitEvent });
} else {
@@ -132,6 +147,7 @@ export default {
<slot name="collapsed-right"></slot>
<gl-button
v-if="canUpdate && !initialLoading && canEdit"
+ :id="buttonId"
category="tertiary"
size="small"
class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2 shortcut-sidebar-dropdown-toggle"
@@ -151,7 +167,7 @@ export default {
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }">
- <slot :edit="edit"></slot>
+ <slot :edit="edit" :toggle="toggle"></slot>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
index 7662d645dd9..e5bee4df9b8 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue
@@ -181,18 +181,18 @@ export default {
</script>
<template>
- <li v-if="isMergeRequest" class="gl-new-dropdown-item">
- <button type="button" class="dropdown-item" @click="toggleSubscribed">
- <span class="gl-new-dropdown-item-text-wrapper">
- <template v-if="subscribed">
- {{ __('Turn off notifications') }}
- </template>
- <template v-else>
- {{ __('Turn on notifications') }}
- </template>
- </span>
- </button>
- </li>
+ <div v-if="isMergeRequest" class="gl-new-dropdown-item">
+ <div class="gl-px-5 gl-pb-2 gl-pt-1">
+ <gl-toggle
+ :value="subscribed"
+ :label="__('Notifications')"
+ class="merge-request-notification-toggle"
+ label-position="left"
+ data-testid="notifications-toggle"
+ @change="toggleSubscribed"
+ />
+ </div>
+ </div>
<sidebar-editable-item
v-else
ref="editable"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js b/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js
deleted file mode 100644
index 70177d84b1b..00000000000
--- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/cache_update.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import produce from 'immer';
-
-export function removeTimelogFromStore(store, deletedTimelogId, query, variables) {
- const sourceData = store.readQuery({
- query,
- variables,
- });
-
- const data = produce(sourceData, (draftData) => {
- draftData.issuable.timelogs.nodes = draftData.issuable.timelogs.nodes.filter(
- ({ id }) => id !== deletedTimelogId,
- );
- });
-
- store.writeQuery({
- query,
- variables,
- data,
- });
-}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
index 17bbad1acb1..6e916893b5a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
+++ b/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql
@@ -1,5 +1,17 @@
+#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql"
+#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql"
+
mutation deleteTimelog($input: TimelogDeleteInput!) {
timelogDelete(input: $input) {
errors
+ timelog {
+ id
+ issue {
+ ...IssueTimeTrackingFragment
+ }
+ mergeRequest {
+ ...MergeRequestTimeTrackingFragment
+ }
+ }
}
}
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
index 79ef5a32474..d751816bd94 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue
@@ -7,7 +7,6 @@ import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_ut
import { __, s__ } from '~/locale';
import { timelogQueries } from '~/sidebar/constants';
import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql';
-import { removeTimelogFromStore } from './graphql/cache_update';
const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)';
@@ -99,14 +98,6 @@ export default {
.mutate({
mutation: deleteTimelogMutation,
variables: { input: { id: timelogId } },
- update: (store) => {
- removeTimelogFromStore(
- store,
- timelogId,
- timelogQueries[this.issuableType].query,
- this.getQueryVariables(),
- );
- },
})
.then(({ data }) => {
if (data.timelogDelete?.errors?.length) {
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index e39d9f9fb49..13981c477c6 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -1,8 +1,16 @@
<script>
-import { GlIcon, GlLink, GlModal, GlButton, GlModalDirective, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlIcon,
+ GlLink,
+ GlModal,
+ GlButton,
+ GlModalDirective,
+ GlLoadingIcon,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import { IssuableType } from '~/issues/constants';
import { s__, __ } from '~/locale';
-import { timeTrackingQueries } from '~/sidebar/constants';
+import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '~/sidebar/constants';
import eventHub from '../../event_hub';
import TimeTrackingCollapsedState from './collapsed_state.vue';
@@ -31,6 +39,7 @@ export default {
},
directives: {
GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
inject: {
issuableType: {
@@ -162,6 +171,12 @@ export default {
this.issuableId
);
},
+ timeTrackingIconTitle() {
+ return this.showHelpState ? '' : HOW_TO_TRACK_TIME;
+ },
+ timeTrackingIconName() {
+ return this.showHelpState ? 'close' : 'question-o';
+ },
},
watch: {
/**
@@ -188,11 +203,7 @@ export default {
</script>
<template>
- <div
- v-cloak
- class="time-tracker time-tracking-component-wrap sidebar-help-wrap"
- data-testid="time-tracker"
- >
+ <div v-cloak class="time-tracker sidebar-help-wrap" data-testid="time-tracker">
<time-tracking-collapsed-state
v-if="showCollapsed"
:show-comparison-state="showComparisonState"
@@ -216,7 +227,12 @@ export default {
class="gl-ml-auto"
@click="toggleHelpState(!showHelpState)"
>
- <gl-icon :name="showHelpState ? 'close' : 'question-o'" class="gl-text-gray-900!" />
+ <gl-icon
+ v-gl-tooltip.left
+ :title="timeTrackingIconTitle"
+ :name="timeTrackingIconName"
+ class="gl-text-gray-900!"
+ />
</gl-button>
</div>
<div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed">
@@ -252,7 +268,6 @@ export default {
size="lg"
:title="__('Time tracking report')"
:hide-footer="true"
- @hide="refresh"
>
<time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" />
</gl-modal>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
index 482b9343e70..42e16aae312 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue
@@ -6,6 +6,7 @@ import { __, sprintf } from '~/locale';
import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants';
import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils';
import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
const trackingMixin = Tracking.mixin();
@@ -19,7 +20,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [trackingMixin],
+ mixins: [glFeatureFlagsMixin(), trackingMixin],
inject: {
isClassicSidebar: {
default: false,
@@ -81,6 +82,9 @@ export default {
},
},
computed: {
+ isMergeRequest() {
+ return this.glFeatures.movedMrSidebar && this.issuableType === 'merge_request';
+ },
todoIdQuery() {
return todoQueries[this.issuableType].query;
},
@@ -183,12 +187,12 @@ export default {
:issuable-id="issuableId"
:is-todo="hasTodo"
:loading="isLoading"
- size="small"
+ :size="isMergeRequest ? 'medium' : 'small'"
class="hide-collapsed"
@click.stop.prevent="toggleTodo"
/>
<gl-button
- v-if="isClassicSidebar"
+ v-if="isClassicSidebar && !isMergeRequest"
v-gl-tooltip.left.viewport
:title="tootltipTitle"
category="tertiary"
diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js
index 989dc574bc3..60cb4cff727 100644
--- a/app/assets/javascripts/sidebar/constants.js
+++ b/app/assets/javascripts/sidebar/constants.js
@@ -1,4 +1,4 @@
-import { s__, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql';
@@ -313,8 +313,26 @@ export function dropdowni18nText(issuableAttribute, issuableType) {
),
{ issuableAttribute, issuableType },
),
+ noPermissionToView: sprintf(
+ s__("DropdownWidget|You don't have permission to view this %{issuableAttribute}."),
+ { issuableAttribute },
+ ),
+ editConfirmation: sprintf(
+ s__(
+ 'DropdownWidget|You do not have permission to view the currently assigned %{issuableAttribute} and will not be able to choose it again if you reassign it.',
+ ),
+ {
+ issuableAttribute,
+ },
+ ),
+ editConfirmationCta: sprintf(s__('DropdownWidget|Edit %{issuableAttribute}'), {
+ issuableAttribute,
+ }),
+ editConfirmationCancel: s__('DropdownWidget|Cancel'),
};
}
export const escalationStatusQuery = getEscalationStatusQuery;
export const escalationStatusMutation = updateEscalationStatusMutation;
+
+export const HOW_TO_TRACK_TIME = __('How to track time');
diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js
deleted file mode 100644
index 127e3a3c610..00000000000
--- a/app/assets/javascripts/sidebar/graphql.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import produce from 'immer';
-import VueApollo from 'vue-apollo';
-import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
-import createDefaultClient from '~/lib/graphql';
-import { temporaryConfig, resolvers as workItemResolvers } from '~/work_items/graphql/provider';
-
-const resolvers = {
- Mutation: {
- updateIssueState: (_, { issueType = undefined, isDirty = false }, { cache }) => {
- const sourceData = cache.readQuery({ query: getIssueStateQuery });
- const data = produce(sourceData, (draftData) => {
- draftData.issueState = { issueType, isDirty };
- });
- cache.writeQuery({ query: getIssueStateQuery, data });
- },
- ...workItemResolvers.Mutation,
- },
-};
-
-export const defaultClient = createDefaultClient(
- resolvers,
- // should be removed with the rollout of work item assignees FF
- // https://gitlab.com/gitlab-org/gitlab/-/issues/363030
- temporaryConfig,
-);
-
-export const apolloProvider = new VueApollo({
- defaultClient,
-});
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 2aacce2fb00..cc5de5e4083 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { IssuableType } from '~/issues/constants';
import { parseBoolean } from '~/lib/utils/common_utils';
-import timeTracker from './components/time_tracking/time_tracker.vue';
+import TimeTracker from './components/time_tracking/time_tracker.vue';
export default class SidebarMilestone {
constructor() {
@@ -23,13 +23,13 @@ export default class SidebarMilestone {
el,
name: 'SidebarMilestoneRoot',
components: {
- timeTracker,
+ TimeTracker,
},
provide: {
issuableType: IssuableType.Milestone,
},
render: (createElement) =>
- createElement('timeTracker', {
+ createElement('time-tracker', {
props: {
limitToHours: parseBoolean(limitToHours),
issuableIid: iid.toString(),
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index fec4d0e346d..1cb3c30b9e0 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -22,7 +22,7 @@ import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar
import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue';
import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue';
import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue';
-import { apolloProvider } from '~/sidebar/graphql';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
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';
@@ -39,7 +39,6 @@ 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);
@@ -163,6 +162,7 @@ function mountAssigneesComponent() {
issuableType,
issuableId: id,
allowMultipleAssignees: !el.dataset.maxAssignees,
+ editable,
},
scopedSlots: {
collapsed: ({ users }) =>
@@ -360,13 +360,6 @@ function mountConfidentialComponent() {
? IssuableType.Issue
: IssuableType.MergeRequest,
},
- on: {
- closeForm({ confidential }) {
- if (confidential !== undefined) {
- SidebarEventHub.$emit('confidentialityUpdated', confidential);
- }
- },
- },
}),
});
}
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 f4d0e9b5deb..41d45b486e8 100644
--- a/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
+++ b/app/assets/javascripts/sidebar/queries/issue_time_tracking.query.graphql
@@ -1,12 +1,12 @@
+#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql"
+
query issueTimeTracking($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
id
issuable: issue(iid: $iid) {
- id
+ ...IssueTimeTrackingFragment
humanTimeEstimate
- humanTotalTimeSpent
timeEstimate
- totalTimeSpent
}
}
}
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 5d05cb2f34c..12ef78a6453 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,12 +1,12 @@
+#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql"
+
query mergeRequestTimeTracking($fullPath: ID!, $iid: String!) {
workspace: project(fullPath: $fullPath) {
id
issuable: mergeRequest(iid: $iid) {
- id
+ ...MergeRequestTimeTrackingFragment
humanTimeEstimate
- humanTotalTimeSpent
timeEstimate
- totalTimeSpent
}
}
}
diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue
index ee8b00c1f5d..853293e5eb6 100644
--- a/app/assets/javascripts/snippets/components/show.vue
+++ b/app/assets/javascripts/snippets/components/show.vue
@@ -6,7 +6,7 @@ import {
SNIPPET_MEASURE_BLOBS_CONTENT,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants';
+import { VISIBILITY_LEVEL_PUBLIC_STRING } from '~/visibility_level/constants';
import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import { getSnippetMixin } from '../mixins/snippets';
@@ -31,7 +31,7 @@ export default {
mixins: [getSnippetMixin],
computed: {
embeddable() {
- return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
+ return this.snippet.visibilityLevel === VISIBILITY_LEVEL_PUBLIC_STRING;
},
canBeCloned() {
return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo);
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index 2a9ecbc27dc..84a940ed1f8 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -1,22 +1,23 @@
import { __ } from '~/locale';
-
-export const SNIPPET_VISIBILITY_PRIVATE = 'private';
-export const SNIPPET_VISIBILITY_INTERNAL = 'internal';
-export const SNIPPET_VISIBILITY_PUBLIC = 'public';
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVEL_INTERNAL_STRING,
+ VISIBILITY_LEVEL_PUBLIC_STRING,
+} from '~/visibility_level/constants';
export const SNIPPET_VISIBILITY = {
- [SNIPPET_VISIBILITY_PRIVATE]: {
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: {
label: __('Private'),
icon: 'lock',
description: __('The snippet is visible only to me.'),
description_project: __('The snippet is visible only to project members.'),
},
- [SNIPPET_VISIBILITY_INTERNAL]: {
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: {
label: __('Internal'),
icon: 'shield',
description: __('The snippet is visible to any logged in user except external users.'),
},
- [SNIPPET_VISIBILITY_PUBLIC]: {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: {
label: __('Public'),
icon: 'earth',
description: __('The snippet can be accessed without any authentication.'),
@@ -34,11 +35,6 @@ export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
export const SNIPPET_MAX_BLOBS = 10;
-export const SNIPPET_LEVELS_MAP = {
- 0: SNIPPET_VISIBILITY_PRIVATE,
- 10: SNIPPET_VISIBILITY_INTERNAL,
- 20: SNIPPET_VISIBILITY_PUBLIC,
-};
export const SNIPPET_LEVELS_RESTRICTED = __(
'Other visibility settings have been disabled by the administrator.',
);
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index 21f38c4d8c9..89dd5e586fb 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -2,7 +2,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants';
+import {
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ VISIBILITY_LEVELS_INTEGER_TO_STRING,
+} from '~/visibility_level/constants';
import Translate from '~/vue_shared/translate';
Vue.use(VueApollo);
@@ -36,7 +39,8 @@ export default function appFactory(el, Component) {
apolloProvider,
provide: {
visibilityLevels: JSON.parse(visibilityLevels),
- selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
+ selectedLevel:
+ VISIBILITY_LEVELS_INTEGER_TO_STRING[selectedLevel] ?? VISIBILITY_LEVEL_PRIVATE_STRING,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
reportAbusePath,
canReportSpam,
diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js
index 2a3f590a803..a228d6111ce 100644
--- a/app/assets/javascripts/snippets/utils/blob.js
+++ b/app/assets/javascripts/snippets/utils/blob.js
@@ -1,12 +1,12 @@
import { uniqueId } from 'lodash';
import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
+import { VISIBILITY_LEVELS_INTEGER_TO_STRING } from '~/visibility_level/constants';
import {
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
SNIPPET_BLOB_ACTION_DELETE,
- SNIPPET_LEVELS_MAP,
SNIPPET_VISIBILITY,
} from '../constants';
@@ -72,7 +72,7 @@ export const diffAll = (blobs, origBlobs) => {
export const defaultSnippetVisibilityLevels = (arr) => {
if (Array.isArray(arr)) {
return arr.map((l) => {
- const translatedLevel = SNIPPET_LEVELS_MAP[l];
+ const translatedLevel = VISIBILITY_LEVELS_INTEGER_TO_STRING[l];
return {
value: translatedLevel,
...SNIPPET_VISIBILITY[translatedLevel],
diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue
index 4e4ef49b1c6..df114c27908 100644
--- a/app/assets/javascripts/surveys/merge_request_experience/app.vue
+++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue
@@ -19,6 +19,8 @@ const steps = [
},
];
+const MR_RENDER_LS_KEY = 'mr_survey_rendered';
+
export default {
name: 'MergeRequestExperienceSurveyApp',
components: {
@@ -68,9 +70,20 @@ export default {
onQueryLoaded({ shouldShowCallout }) {
this.visible = shouldShowCallout;
if (!this.visible) this.$emit('close');
+ else if (!localStorage?.getItem(MR_RENDER_LS_KEY)) {
+ this.track('survey:mr_experience', {
+ label: 'render',
+ extra: {
+ accountAge: this.accountAge,
+ },
+ });
+ localStorage?.setItem(MR_RENDER_LS_KEY, '1');
+ }
},
onRate(event) {
+ this.$refs.dismisser?.dismiss();
this.$emit('rate');
+ localStorage?.removeItem(MR_RENDER_LS_KEY);
this.track('survey:mr_experience', {
label: this.step.label,
value: event,
@@ -87,8 +100,18 @@ export default {
},
handleKeyup(e) {
if (e.key !== 'Escape') return;
- this.$emit('close');
+ this.dismiss();
+ },
+ dismiss() {
this.$refs.dismisser?.dismiss();
+ this.$emit('close');
+ this.track('survey:mr_experience', {
+ label: 'dismiss',
+ extra: {
+ accountAge: this.accountAge,
+ },
+ });
+ localStorage?.removeItem(MR_RENDER_LS_KEY);
},
},
};
@@ -100,79 +123,71 @@ export default {
feature-name="mr_experience_survey"
@queryResult.once="onQueryLoaded"
>
- <template #default="{ dismiss }">
- <aside
- class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
- :aria-label="$options.i18n.survey"
- >
- <transition name="survey-slide-up">
+ <aside
+ class="mr-experience-survey-wrapper gl-fixed gl-bottom-0 gl-right-0 gl-p-5"
+ :aria-label="$options.i18n.survey"
+ >
+ <transition name="survey-slide-up">
+ <div
+ v-if="visible"
+ class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ >
+ <gl-button
+ v-tooltip="$options.i18n.close"
+ :aria-label="$options.i18n.close"
+ variant="default"
+ category="tertiary"
+ class="gl-top-4 gl-right-3 gl-absolute"
+ icon="close"
+ @click="dismiss"
+ />
<div
- v-if="visible"
- class="mr-experience-survey-body gl-relative gl-display-flex gl-flex-direction-column gl-bg-white gl-p-5 gl-border gl-rounded-base"
+ v-if="stepIndex === 0"
+ class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
+ role="note"
>
- <gl-button
- v-tooltip="$options.i18n.close"
- :aria-label="$options.i18n.close"
- variant="default"
- category="tertiary"
- class="gl-top-4 gl-right-3 gl-absolute"
- icon="close"
- @click="
- dismiss();
- $emit('close');
- "
- />
- <div
- v-if="stepIndex === 0"
- class="mr-experience-survey-legal gl-border-t gl-mt-5 gl-pt-3 gl-text-gray-500 gl-font-sm"
- role="note"
- >
- <p class="gl-m-0">
- <gl-sprintf :message="$options.i18n.legal">
- <template #link="{ content }">
- <a
- class="gl-text-decoration-underline gl-text-gray-500"
- href="https://about.gitlab.com/privacy/"
- target="_blank"
- rel="noreferrer nofollow"
- v-text="content"
- ></a>
- </template>
- </gl-sprintf>
- </p>
- </div>
- <div class="gl-relative">
- <div class="gl-absolute">
- <div
- v-safe-html="$options.gitlabLogo"
- aria-hidden="true"
- class="mr-experience-survey-logo"
- ></div>
- </div>
+ <p class="gl-m-0">
+ <gl-sprintf :message="$options.i18n.legal">
+ <template #link="{ content }">
+ <a
+ class="gl-text-decoration-underline gl-text-gray-500"
+ href="https://about.gitlab.com/privacy/"
+ target="_blank"
+ rel="noreferrer nofollow"
+ v-text="content"
+ ></a>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ <div class="gl-relative">
+ <div class="gl-absolute">
+ <div
+ v-safe-html="$options.gitlabLogo"
+ aria-hidden="true"
+ class="mr-experience-survey-logo"
+ ></div>
</div>
- <section v-if="step">
- <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
- <gl-sprintf :message="step.question">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <satisfaction-rate
- aria-labelledby="mr_survey_question"
- class="gl-mt-5"
- @rate="
- dismiss();
- onRate($event);
- "
- />
- </section>
- <section v-else class="gl-px-7">
- {{ $options.i18n.thanks }}
- </section>
</div>
- </transition>
- </aside>
- </template>
+ <section v-if="step">
+ <p id="mr_survey_question" ref="question" class="gl-m-0 gl-px-7">
+ <gl-sprintf :message="step.question">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <satisfaction-rate
+ aria-labelledby="mr_survey_question"
+ class="gl-mt-5"
+ @rate="onRate"
+ />
+ </section>
+ <section v-else class="gl-px-7">
+ {{ $options.i18n.thanks }}
+ </section>
+ </div>
+ </transition>
+ </aside>
</user-callout-dismisser>
</template>
diff --git a/app/assets/javascripts/token_access/components/token_access.vue b/app/assets/javascripts/token_access/components/token_access.vue
index de8cd856bf7..363a9d58d65 100644
--- a/app/assets/javascripts/token_access/components/token_access.vue
+++ b/app/assets/javascripts/token_access/components/token_access.vue
@@ -1,7 +1,16 @@
<script>
-import { GlButton, GlCard, GlFormInput, GlLoadingIcon, GlToggle } from '@gitlab/ui';
+import {
+ GlButton,
+ GlCard,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlToggle,
+} from '@gitlab/ui';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
+import { helpPagePath } from '~/helpers/help_page_helper';
import addProjectCIJobTokenScopeMutation from '../graphql/mutations/add_project_ci_job_token_scope.mutation.graphql';
import removeProjectCIJobTokenScopeMutation from '../graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql';
import updateCIJobTokenScopeMutation from '../graphql/mutations/update_ci_job_token_scope.mutation.graphql';
@@ -13,7 +22,7 @@ export default {
i18n: {
toggleLabelTitle: s__('CICD|Limit CI_JOB_TOKEN access'),
toggleHelpText: s__(
- `CICD|Select projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable.`,
+ `CICD|Select the projects that can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more.%{linkEnd}`,
),
cardHeaderTitle: s__('CICD|Add an existing project to the scope'),
addProject: __('Add project'),
@@ -26,7 +35,9 @@ export default {
GlButton,
GlCard,
GlFormInput,
+ GlLink,
GlLoadingIcon,
+ GlSprintf,
GlToggle,
TokenProjectsTable,
},
@@ -76,6 +87,9 @@ export default {
isProjectPathEmpty() {
return this.targetProjectPath === '';
},
+ ciJobTokenHelpPage() {
+ return helpPagePath('ci/jobs/ci_job_token');
+ },
},
methods: {
async updateCIJobTokenScope() {
@@ -99,10 +113,6 @@ export default {
}
} catch (error) {
createFlash({ message: error });
- } finally {
- if (this.jobTokenScopeEnabled) {
- this.getProjects();
- }
}
},
async addProject() {
@@ -172,10 +182,20 @@ export default {
<gl-toggle
v-model="jobTokenScopeEnabled"
:label="$options.i18n.toggleLabelTitle"
- :help="$options.i18n.toggleHelpText"
@change="updateCIJobTokenScope"
- />
- <div v-if="jobTokenScopeEnabled" data-testid="token-section">
+ >
+ <template #help>
+ <gl-sprintf :message="$options.i18n.toggleHelpText">
+ <template #link="{ content }">
+ <gl-link :href="ciJobTokenHelpPage" class="inline-link" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-toggle>
+
+ <div data-testid="token-section">
<gl-card class="gl-mt-5">
<template #header>
<h5 class="gl-my-0">{{ $options.i18n.cardHeaderTitle }}</h5>
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c2892fb8dac..ee5d6a22fc3 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -46,6 +46,7 @@ const populateUserInfo = (user) => {
pronouns: userData.pronouns,
localTime: userData.local_time,
isFollowed: userData.is_followed,
+ state: userData.state,
loaded: true,
});
}
diff --git a/app/assets/javascripts/validators/input_validator.js b/app/assets/javascripts/validators/input_validator.js
index f37373977b8..b799976a0ba 100644
--- a/app/assets/javascripts/validators/input_validator.js
+++ b/app/assets/javascripts/validators/input_validator.js
@@ -19,6 +19,7 @@ export default class InputValidator {
setValidationMessage() {
if (this.invalidInput) {
this.inputDomElement.setCustomValidity(this.errorMessage);
+ // eslint-disable-next-line no-unsanitized/property
this.inputErrorMessage.innerHTML = this.errorMessage;
} else {
this.resetValidationMessage();
@@ -28,6 +29,7 @@ export default class InputValidator {
resetValidationMessage() {
if (this.inputDomElement.validationMessage === this.errorMessage) {
this.inputDomElement.setCustomValidity('');
+ // eslint-disable-next-line no-unsanitized/property
this.inputErrorMessage.innerHTML = this.inputDomElement.title;
}
}
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 65f0eceae55..77736fb6ef5 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -1,10 +1,20 @@
-export const VISIBILITY_LEVEL_PRIVATE = 'private';
-export const VISIBILITY_LEVEL_INTERNAL = 'internal';
-export const VISIBILITY_LEVEL_PUBLIC = 'public';
+export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private';
+export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal';
+export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public';
+
+export const VISIBILITY_LEVEL_PRIVATE_INTEGER = 0;
+export const VISIBILITY_LEVEL_INTERNAL_INTEGER = 10;
+export const VISIBILITY_LEVEL_PUBLIC_INTEGER = 20;
// Matches `lib/gitlab/visibility_level.rb`
-export const VISIBILITY_LEVELS_ENUM = {
- [VISIBILITY_LEVEL_PRIVATE]: 0,
- [VISIBILITY_LEVEL_INTERNAL]: 10,
- [VISIBILITY_LEVEL_PUBLIC]: 20,
+export const VISIBILITY_LEVELS_STRING_TO_INTEGER = {
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: VISIBILITY_LEVEL_PRIVATE_INTEGER,
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: VISIBILITY_LEVEL_INTERNAL_INTEGER,
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: VISIBILITY_LEVEL_PUBLIC_INTEGER,
+};
+
+export const VISIBILITY_LEVELS_INTEGER_TO_STRING = {
+ [VISIBILITY_LEVEL_PRIVATE_INTEGER]: VISIBILITY_LEVEL_PRIVATE_STRING,
+ [VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING,
+ [VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
index 38f40e8a3c8..30a0e7c383c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/action_buttons.vue
@@ -63,6 +63,12 @@ export default {
return btn.tooltipText;
},
+ actionButtonQaSelector(btn) {
+ if (btn.dataQaSelector) {
+ return btn.dataQaSelector;
+ }
+ return 'mr_widget_extension_actions_button';
+ },
},
};
</script>
@@ -105,7 +111,7 @@ export default {
:target="btn.target"
:class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]"
:data-clipboard-text="btn.dataClipboardText"
- :data-qa-selector="btn.dataQaSelector"
+ :data-qa-selector="actionButtonQaSelector(btn)"
:data-method="btn.dataMethod"
:icon="btn.icon"
:data-testid="btn.testId || 'extension-actions-button'"
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 254b280bf14..f377a185879 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
@@ -1,5 +1,5 @@
<script>
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { escape } from 'lodash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { n__, s__, sprintf } from '~/locale';
@@ -9,6 +9,7 @@ const mergeCommitCount = s__('mrWidgetCommitsAdded|%{strongStart}1%{strongEnd} m
export default {
components: {
GlSprintf,
+ GlLink,
},
mixins: [glFeatureFlagMixin()],
props: {
@@ -40,6 +41,11 @@ export default {
required: false,
default: '',
},
+ mergeCommitPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
isMerged() {
@@ -124,7 +130,9 @@ export default {
</template>
</template>
<template #mergeCommitSha>
- <span class="label-branch">{{ mergeCommitSha }}</span>
+ <gl-link :href="mergeCommitPath" class="label-branch" data-testid="merge-commit-sha">{{
+ mergeCommitSha
+ }}</gl-link>
</template>
</gl-sprintf>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
index b1c4f7c5a7c..d7255eb6ad2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue
@@ -103,7 +103,7 @@ export default {
<span v-if="approvalLeftMessage">{{ message }}</span>
<span v-else class="gl-font-weight-bold">{{ message }}</span>
<user-avatar-list
- class="gl-display-inline-block gl-vertical-align-middle"
+ class="gl-display-inline-block gl-vertical-align-middle gl-pt-1"
:img-size="24"
:items="approvers"
/>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index e115710b5d1..30098f7619a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -74,16 +74,12 @@ export default {
<div class="js-deployment-info deployment-info">
<template v-if="hasDeploymentMeta">
<span>{{ deployedText }}</span>
- <tooltip-on-truncate
- :title="deployment.name"
- truncate-target="child"
- class="deploy-link label-truncate"
- >
+ <tooltip-on-truncate :title="deployment.name" truncate-target="child" class="label-truncate">
<gl-link
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
- class="js-deploy-meta gl-font-sm"
+ class="js-deploy-meta gl-font-sm gl-pb-1"
>
{{ deployment.name }}
</gl-link>
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 414c5bf9691..300e2a672cb 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
@@ -13,6 +13,7 @@ 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 StateContainer from '../state_container.vue';
import StatusIcon from './status_icon.vue';
import ChildContent from './child_content.vue';
import { createTelemetryHub } from './telemetry';
@@ -36,6 +37,7 @@ export default {
ChildContent,
DynamicScroller,
DynamicScrollerItem,
+ StateContainer,
},
directives: {
SafeHtml: GlSafeHtmlDirective,
@@ -307,19 +309,20 @@ export default {
</script>
<template>
- <section class="media-section" data-testid="widget-extension">
- <div
+ <section
+ class="media-section"
+ data-testid="widget-extension"
+ data-qa-selector="mr_widget_extension"
+ >
+ <state-container
+ :mr="mr"
+ :status="statusIconName"
+ :is-loading="isLoadingSummary"
:class="{ 'gl-cursor-pointer': isCollapsible }"
- class="media gl-p-5"
+ class="gl-p-5"
@mousedown="onRowMouseDown"
@mouseup="onRowMouseUp"
>
- <status-icon
- :level="1"
- :name="$options.label || $options.name"
- :is-loading="isLoadingSummary"
- :icon-name="statusIconName"
- />
<div
class="media-body gl-display-flex gl-flex-direction-row! gl-align-self-center"
data-testid="widget-extension-top-level"
@@ -352,12 +355,13 @@ export default {
:icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
data-testid="toggle-button"
+ data-qa-selector="toggle_button"
size="small"
@click="toggleCollapsed"
/>
</div>
</div>
- </div>
+ </state-container>
<div
v-if="!isCollapsed"
class="mr-widget-grouped-section gl-relative"
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 1eccc7de660..52c9f047b76 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
@@ -62,7 +62,9 @@ export default {
<strong v-else v-safe-html="generateText(data.header)"></strong>
</div>
<div class="gl-display-flex">
- <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" class="gl-pl-0" />
+ <div v-if="data.icon" class="report-block-child-icon gl-display-flex">
+ <status-icon :icon-name="data.icon.name" :size="12" class="gl-m-auto" />
+ </div>
<div class="gl-w-full">
<div class="gl-display-flex gl-flex-nowrap">
<div class="gl-flex-wrap gl-display-flex gl-w-full">
@@ -109,6 +111,7 @@ export default {
:modal-id="modalId"
:level="3"
data-testid="child-content"
+ data-qa-selector="child_content"
@clickedAction="onClickedAction"
/>
</li>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
index dc748ba44f2..f9d0986d60d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue
@@ -49,18 +49,28 @@ export default {
<div
:class="[
$options.EXTENSION_ICON_CLASS[iconName],
- { 'mr-widget-extension-icon gl-w-6': !isLoading && level === 1 },
+ { 'gl-w-6': !isLoading && level === 1 },
{ 'gl-p-2': isLoading || level === 1 },
]"
- class="gl-rounded-full gl-mr-3 gl-relative gl-p-2"
+ class="gl-mr-3 gl-p-2"
>
- <gl-loading-icon v-if="isLoading" size="sm" inline class="gl-display-block" />
- <gl-icon
- v-else
- :name="$options.EXTENSION_ICON_NAMES[iconName]"
- :size="size"
- :aria-label="iconAriaLabel"
- class="gl-display-block"
- />
+ <div
+ class="gl-rounded-full gl-relative gl-display-flex"
+ :class="{ 'mr-widget-extension-icon': !isLoading && level === 1 }"
+ >
+ <div class="gl-absolute gl-top-half gl-left-50p gl-translate-x-n50 gl-display-flex gl-m-auto">
+ <div class="gl-display-flex gl-m-auto gl-translate-y-n50">
+ <gl-loading-icon v-if="isLoading" size="md" inline />
+ <gl-icon
+ v-else
+ :name="$options.EXTENSION_ICON_NAMES[iconName]"
+ :size="size"
+ :aria-label="iconAriaLabel"
+ :data-qa-selector="`status_${iconName}_icon`"
+ class="gl-display-block"
+ />
+ </div>
+ </div>
+ </div>
</div>
</template>
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 bc84459e298..d67ff11f297 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
@@ -24,7 +24,7 @@ const nonStandardEvents = {
},
issues: {
uniqueUser: {
- expand: ['i_testing_load_performance_widget_total'],
+ expand: ['i_testing_issues_widget_total'],
},
counter: {},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
index 437342bf438..0c36e1ccd7f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -13,7 +13,7 @@ export default {
</script>
<template>
- <div class="circle-icon-container gl-mr-3 align-self-start">
+ <div class="circle-icon-container gl-mr-3 align-self-start gl-mt-2">
<gl-icon :name="name" :size="24" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 1e1a2049414..fe69e96bd87 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -9,11 +9,10 @@ import {
GlTooltipDirective,
GlSafeHtmlDirective,
} from '@gitlab/ui';
-import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
import { s__, n__ } from '~/locale';
-import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue';
-import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
+import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { MT_MERGE_STRATEGY } from '../constants';
@@ -31,14 +30,11 @@ export default {
PipelineMiniGraph,
TimeAgoTooltip,
TooltipOnTruncate,
- LinkedPipelinesMiniList: () =>
- import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
- mixins: [mrWidgetPipelineMixin],
props: {
pipeline: {
type: Object,
@@ -172,7 +168,7 @@ export default {
</p>
</template>
<template v-else-if="!hasPipeline">
- <gl-loading-icon size="lg" />
+ <gl-loading-icon size="md" />
<p
class="gl-flex-grow-1 gl-display-flex gl-ml-3 gl-mb-0"
data-testid="monitoring-pipeline-message"
@@ -276,17 +272,15 @@ export default {
</div>
</div>
<div>
- <span class="gl-align-items-center gl-display-inline-flex mr-widget-pipeline-graph">
- <span class="gl-align-items-center gl-display-inline-flex gl-flex-wrap stage-cell">
- <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" />
- <pipeline-mini-graph
- v-if="hasStages"
- stages-class="mr-widget-pipeline-stages"
- :stages="pipeline.details.stages"
- :is-merge-train="isMergeTrain"
- />
- </span>
- <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
+ <span class="gl-align-items-center gl-display-inline-flex">
+ <pipeline-mini-graph
+ v-if="pipeline.details.stages"
+ :downstream-pipelines="pipeline.triggered"
+ :is-merge-train="isMergeTrain"
+ :stages="pipeline.details.stages"
+ :upstream-pipeline="pipeline.triggered_by"
+ stages-class="mr-widget-pipeline-stages"
+ />
<pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" />
</span>
</div>
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 5b8acb4ebf8..3239285e53e 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,11 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import ciIcon from '~/vue_shared/components/ci_icon.vue';
+import { GlIcon } from '@gitlab/ui';
+import StatusIcon from './extensions/status_icon.vue';
export default {
components: {
- ciIcon,
- GlLoadingIcon,
+ StatusIcon,
+ GlIcon,
},
props: {
status: {
@@ -17,22 +17,20 @@ export default {
isLoading() {
return this.status === 'loading';
},
- statusObj() {
- return {
- group: this.status,
- icon: `status_${this.status}`,
- };
- },
},
};
</script>
<template>
- <div class="gl-display-flex gl-align-self-start">
- <div class="square s24 h-auto d-flex-center gl-mr-3">
- <div v-if="isLoading" class="mr-widget-icon gl-display-inline-flex">
- <gl-loading-icon size="md" class="mr-loading-icon gl-display-inline-flex" />
- </div>
- <ci-icon v-else :status="statusObj" :size="24" />
+ <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3">
+ <div class="gl-display-flex gl-m-auto">
+ <gl-icon v-if="status === 'merged'" name="merge" :size="16" class="gl-text-blue-500" />
+ <gl-icon
+ v-else-if="status === 'closed'"
+ name="merge-request-close"
+ :size="16"
+ class="gl-text-red-500"
+ />
+ <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" />
</div>
</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
index 4a5a03fb598..822c5a68093 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue
@@ -1,13 +1,23 @@
<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
import StatusIcon from './mr_widget_status_icon.vue';
import Actions from './action_buttons.vue';
export default {
components: {
+ GlButton,
StatusIcon,
Actions,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
isLoading: {
type: Boolean,
required: false,
@@ -24,30 +34,67 @@ export default {
default: () => [],
},
},
+ i18n: {
+ expandDetailsTooltip: __('Expand merge details'),
+ collapseDetailsTooltip: __('Collapse merge details'),
+ },
+ computed: {
+ wrapperClasses() {
+ if (this.status === 'merged') return 'gl-bg-blue-50';
+ if (this.status === 'closed') return 'gl-bg-red-50';
+ return null;
+ },
+ },
};
</script>
<template>
- <div class="mr-widget-body media">
+ <div class="mr-widget-body media" :class="wrapperClasses" v-on="$listeners">
<div v-if="isLoading" class="gl-w-full mr-conflict-loader">
- <slot name="loading"></slot>
+ <slot name="loading">
+ <div class="gl-display-flex">
+ <status-icon status="loading" />
+ <div class="media-body">
+ <slot></slot>
+ </div>
+ </div>
+ </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-display-flex gl-w-full">
+ <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"
+ >
+ <slot name="actions">
+ <actions v-if="actions.length" :tertiary-buttons="actions" />
+ </slot>
+ </div>
+ </div>
<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"
+ class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1"
>
- <slot name="actions">
- <actions v-if="actions.length" :tertiary-buttons="actions" />
- </slot>
+ <gl-button
+ v-gl-tooltip
+ :title="
+ mr.mergeDetailsCollapsed
+ ? $options.i18n.expandDetailsTooltip
+ : $options.i18n.collapseDetailsTooltip
+ "
+ :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ category="tertiary"
+ size="small"
+ class="gl-vertical-align-top"
+ @click="() => mr.toggleMergeDetails()"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
index a45823823f0..e2a9caf5419 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -34,7 +34,7 @@ export default {
<template>
<div class="mr-widget-body media gl-flex-wrap">
- <status-icon status="warning" />
+ <status-icon status="failed" />
<p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!">
{{ failedText }}
</p>
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 f74826f95d3..79e878431ed 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,22 +1,24 @@
<script>
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetArchived',
components: {
- statusIcon,
+ StateContainer,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
},
};
</script>
+
<template>
- <div class="mr-widget-body media">
- <div class="space-children">
- <status-icon status="warning" show-disabled-button />
- </div>
- <div class="media-body">
- <span class="gl-ml-0! gl-text-body! bold">
- {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
- </span>
- </div>
- </div>
+ <state-container :mr="mr" status="failed">
+ <span class="gl-font-weight-bold">
+ {{ s__('mrWidget|Merge unavailable: merge requests are read-only on archived projects.') }}
+ </span>
+ </state-container>
</template>
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 690acc9a6dc..3c6c2a44e70 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,5 +1,5 @@
<script>
-import { GlSkeletonLoader, GlIcon, GlSprintf } from '@gitlab/ui';
+import { GlSkeletonLoader, 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';
@@ -28,7 +28,6 @@ export default {
components: {
MrWidgetAuthor,
GlSkeletonLoader,
- GlIcon,
GlSprintf,
StateContainer,
},
@@ -151,7 +150,7 @@ export default {
};
</script>
<template>
- <state-container status="scheduled" :is-loading="loading" :actions="actions">
+ <state-container :mr="mr" 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" />
@@ -168,8 +167,5 @@ export default {
</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" />
- </template>
</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 b0cda85f361..39c56cbb93d 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
@@ -58,8 +58,8 @@ export default {
};
</script>
<template>
- <state-container status="warning" :actions="actions">
- <span class="bold gl-ml-0!">
+ <state-container :mr="mr" status="failed" :actions="actions">
+ <span class="gl-font-weight-bold">
<template v-if="mergeError">{{ mergeError }}</template>
{{ s__('mrWidget|This merge request failed to be merged automatically') }}
</span>
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 e2d87d8d536..922075516f3 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,20 +1,23 @@
<script>
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetChecking',
components: {
- statusIcon,
+ StateContainer,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
},
};
</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! bold">
- {{ s__('mrWidget|Checking if merge request can be merged…') }}
- </span>
- </div>
- </div>
+ <state-container :mr="mr" status="loading">
+ <span class="gl-font-weight-bold">
+ {{ s__('mrWidget|Checking if merge request can be merged…') }}
+ </span>
+ </state-container>
</template>
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 61f7d26f51e..806f8f939a6 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,16 +1,14 @@
<script>
import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetClosed',
components: {
MrWidgetAuthorTime,
- statusIcon,
+ StateContainer,
},
props: {
- /* TODO: This is providing all store and service down when it
- only needs metrics and targetBranch */
mr: {
type: Object,
required: true,
@@ -19,15 +17,12 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon status="warning" />
- <div class="media-body">
- <mr-widget-author-time
- :action-text="s__('mrWidget|Closed by')"
- :author="mr.metrics.closedBy"
- :date-title="mr.metrics.closedAt"
- :date-readable="mr.metrics.readableClosedAt"
- />
- </div>
- </div>
+ <state-container :mr="mr" status="closed">
+ <mr-widget-author-time
+ :action-text="s__('mrWidget|Closed by')"
+ :author="mr.metrics.closedBy"
+ :date-title="mr.metrics.closedAt"
+ :date-readable="mr.metrics.readableClosedAt"
+ />
+ </state-container>
</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 8abd915b93e..d60d3cfc9ea 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
@@ -86,7 +86,7 @@ export default {
};
</script>
<template>
- <state-container status="warning" :is-loading="isLoading">
+ <state-container :mr="mr" status="failed" :is-loading="isLoading">
<template #loading>
<gl-skeleton-loader :width="334" :height="30">
<rect x="0" y="7" width="150" height="16" rx="4" />
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 18103ac4a0e..8a7f15d8d1a 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
@@ -1,16 +1,14 @@
<script>
-import { GlButton } from '@gitlab/ui';
import { stripHtml } from '~/lib/utils/text_utility';
import { sprintf, s__, n__ } from '~/locale';
import eventHub from '../../event_hub';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StateContainer from '../state_container.vue';
export default {
name: 'MRWidgetFailedToMerge',
components: {
- GlButton,
- statusIcon,
+ StateContainer,
},
props: {
@@ -47,6 +45,16 @@ export default {
this.timer,
);
},
+ actions() {
+ return [
+ {
+ text: s__('mrWidget|Refresh now'),
+ onClick: () => this.refresh(),
+ testId: 'merge-request-failed-refresh-button',
+ dataQaSelector: 'merge_request_error_content',
+ },
+ ];
+ },
},
mounted() {
@@ -87,30 +95,18 @@ export default {
};
</script>
<template>
- <div class="mr-widget-body media">
- <template v-if="isRefreshing">
- <status-icon status="loading" />
- <span class="media-body bold js-refresh-label"> {{ s__('mrWidget|Refreshing now') }} </span>
- </template>
- <template v-else>
- <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" data-testid="merge-error">
- {{ mergeError }}
- </span>
- <span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
- <span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
- </span>
- <gl-button
- size="small"
- data-testid="merge-request-failed-refresh-button"
- data-qa-selector="merge_request_error_content"
- @click="refresh"
- >
- {{ s__('mrWidget|Refresh now') }}
- </gl-button>
- </div>
- </template>
- </div>
+ <state-container v-if="isRefreshing" :mr="mr" status="loading">
+ <span class="gl-font-weight-bold">
+ {{ s__('mrWidget|Refreshing now') }}
+ </span>
+ </state-container>
+ <state-container v-else :mr="mr" status="failed" :actions="actions">
+ <span class="gl-font-weight-bold">
+ <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>
+ </state-container>
</template>
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 4416123cd51..e9298b0c856 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,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import { GlTooltipDirective } from '@gitlab/ui';
import api from '~/api';
import createFlash from '~/flash';
import { s__, __ } from '~/locale';
@@ -16,7 +16,6 @@ export default {
},
components: {
MrWidgetAuthorTime,
- GlIcon,
StateContainer,
},
props: {
@@ -49,18 +48,6 @@ export default {
const { sourceBranchRemoved, isRemovingSourceBranch } = this.mr;
return !sourceBranchRemoved && (isRemovingSourceBranch || this.isMakingRequest);
},
- shouldShowMergedButtons() {
- const {
- canRevertInCurrentMR,
- canCherryPickInCurrentMR,
- revertInForkPath,
- cherryPickInForkPath,
- } = this.mr;
-
- return (
- canRevertInCurrentMR || canCherryPickInCurrentMR || revertInForkPath || cherryPickInForkPath
- );
- },
revertTitle() {
return s__('mrWidget|Revert this merge request in a new merge request');
},
@@ -163,10 +150,7 @@ export default {
};
</script>
<template>
- <state-container :actions="actions">
- <template #icon>
- <gl-icon name="merge" :size="24" class="gl-text-blue-500 gl-mr-3 gl-mt-1" />
- </template>
+ <state-container :mr="mr" :actions="actions" status="merged">
<mr-widget-author-time
:action-text="s__('mrWidget|Merged by')"
:author="mr.metrics.mergedBy"
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 c7574a41bb8..51ac2576f75 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
@@ -4,7 +4,7 @@ import simplePoll from '~/lib/utils/simple_poll';
import MergeRequest from '~/merge_request';
import eventHub from '../../event_hub';
import { MERGE_ACTIVE_STATUS_PHRASES, STATE_MACHINE } from '../../constants';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
const { transitions } = STATE_MACHINE;
const { MERGE_FAILURE } = transitions;
@@ -12,7 +12,7 @@ const { MERGE_FAILURE } = transitions;
export default {
name: 'MRWidgetMerging',
components: {
- statusIcon,
+ StatusIcon,
},
props: {
mr: {
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 659d12d1160..214d1b49732 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
@@ -9,7 +9,7 @@ import {
MR_WIDGET_MISSING_BRANCH_RESTORE,
MR_WIDGET_MISSING_BRANCH_MANUALCLI,
} from '../../i18n';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetMissingBranch',
@@ -19,7 +19,7 @@ export default {
components: {
GlIcon,
GlSprintf,
- statusIcon,
+ StatusIcon,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
@@ -71,10 +71,10 @@ export default {
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon :show-disabled-button="true" status="failed" />
<div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold js-branch-text" data-testid="widget-content">
+ <span class="gl-font-weight-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_not_allowed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
index c203d2824fa..d837551a813 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_not_allowed.vue
@@ -11,9 +11,9 @@ export default {
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="success" />
+ <status-icon status="success" />
<div class="media-body space-children">
- <span class="bold">
+ <span class="gl-font-weight-bold">
{{
s__(`mrWidget|Ready to be merged automatically.
Ask someone with write access to this repository to merge this request`)
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 e99ee59b877..13920daca15 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
@@ -12,9 +12,9 @@ export default {
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon status="failed" />
<div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold">
+ <span class="gl-font-weight-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 6c5fc916799..37c8d5d15f3 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
@@ -81,16 +81,19 @@ export default {
return 'loading';
}
if (!this.canPushToSourceBranch && !this.rebaseInProgress) {
- return 'warning';
+ return 'failed';
}
return 'success';
},
- showDisabledButton() {
- return ['failed', 'loading'].includes(this.status);
- },
fastForwardMergeText() {
return __('Merge blocked: the source branch must be rebased onto the target branch.');
},
+ showRebaseWithoutPipeline() {
+ return (
+ !this.mr.onlyAllowMergeIfPipelineSucceeds ||
+ (this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline)
+ );
+ },
},
methods: {
rebase({ skipCi = false } = {}) {
@@ -149,7 +152,7 @@ export default {
};
</script>
<template>
- <state-container :status="status" :is-loading="isLoading">
+ <state-container :mr="mr" :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" />
@@ -192,6 +195,7 @@ export default {
</template>
<template v-if="!isLoading" #actions>
<gl-button
+ v-if="showRebaseWithoutPipeline"
:loading="isMakingRequest"
variant="confirm"
size="small"
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 d507e5f232b..3cbd171a035 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
@@ -2,14 +2,14 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
-import statusIcon from '../mr_widget_status_icon.vue';
+import StatusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'PipelineFailed',
components: {
GlLink,
GlSprintf,
- statusIcon,
+ StatusIcon,
},
computed: {
troubleshootingDocsPath() {
@@ -26,9 +26,9 @@ export default {
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="true" status="warning" />
+ <status-icon status="failed" />
<div class="media-body space-children">
- <span class="gl-ml-0! gl-text-body! bold">
+ <span class="gl-font-weight-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 d2c85b14999..78430abcfe9 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
@@ -680,6 +680,7 @@ export default {
:is-fast-forward-enabled="!shouldShowMergeEdit"
:commits-count="commitsCount"
:target-branch="stateData.targetBranch"
+ :merge-commit-path="mr.mergeCommitPath"
/>
</li>
<li v-if="mr.state !== 'closed'" class="gl-line-height-normal">
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 d149f5208fc..27919f90cc3 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
@@ -22,7 +22,7 @@ export default {
</script>
<template>
- <state-container status="warning">
+ <state-container :mr="mr" status="failed">
<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"
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 035d62eaa59..8f2e4eb2131 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
@@ -24,7 +24,7 @@ export default {
</script>
<template>
- <state-container status="warning">
+ <state-container :mr="mr" status="failed">
<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!"
>
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 cf7f83c014a..0458e9dfaf5 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
@@ -163,7 +163,7 @@ export default {
</script>
<template>
- <state-container status="warning">
+ <state-container :mr="mr" status="failed">
<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>
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
index f1c1bde256f..2f52ac70833 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue
@@ -15,7 +15,12 @@ export default {
</script>
<template>
- <section role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app">
+ <section
+ v-if="widgets.length"
+ role="region"
+ :aria-label="__('Merge request reports')"
+ data-testid="mr-widget-app"
+ >
<component
:is="widget"
v-for="(widget, index) in widgets"
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
index 9c8819327e6..c9fc2dde0bd 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue
@@ -1,21 +1,32 @@
<script>
+import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { normalizeHeaders } from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
+import { sprintf, __ } from '~/locale';
import Poll from '~/lib/utils/poll';
import StatusIcon from '../extensions/status_icon.vue';
-import { EXTENSION_ICON_NAMES } from '../../constants';
+import ActionButtons from '../action_buttons.vue';
+import { EXTENSION_ICONS } from '../../constants';
+import ContentSection from './widget_content_section.vue';
const FETCH_TYPE_COLLAPSED = 'collapsed';
+const FETCH_TYPE_EXPANDED = 'expanded';
export default {
components: {
+ ActionButtons,
StatusIcon,
+ GlButton,
+ GlLoadingIcon,
+ ContentSection,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
/**
* @param {value.collapsed} Object
- * @param {value.extended} Object
+ * @param {value.expanded} Object
*/
value: {
type: Object,
@@ -35,7 +46,7 @@ export default {
type: Function,
required: true,
},
- fetchExtendedData: {
+ fetchExpandedData: {
type: Function,
required: false,
default: undefined,
@@ -61,7 +72,16 @@ export default {
type: String,
default: 'neutral',
required: false,
- validator: (value) => Object.keys(EXTENSION_ICON_NAMES).indexOf(value) > -1,
+ validator: (value) => Object.keys(EXTENSION_ICONS).indexOf(value) > -1,
+ },
+ isCollapsible: {
+ type: Boolean,
+ required: true,
+ },
+ actionButtons: {
+ type: Array,
+ required: false,
+ default: () => [],
},
widgetName: {
type: String,
@@ -70,10 +90,22 @@ export default {
},
data() {
return {
+ isExpandedForTheFirstTime: true,
+ isCollapsed: true,
isLoading: false,
- error: null,
+ isLoadingExpandedContent: false,
+ summaryError: null,
+ contentError: null,
};
},
+ computed: {
+ collapseButtonLabel() {
+ return sprintf(this.isCollapsed ? __('Show details') : __('Hide details'));
+ },
+ summaryStatusIcon() {
+ return this.summaryError ? this.$options.failedStatusIcon : this.statusIconName;
+ },
+ },
watch: {
isLoading(newValue) {
this.$emit('is-loading', newValue);
@@ -85,12 +117,36 @@ export default {
try {
await this.fetch(this.fetchCollapsedData, FETCH_TYPE_COLLAPSED);
} catch {
- this.error = this.errorText;
+ this.summaryError = this.errorText;
}
this.isLoading = false;
},
methods: {
+ toggleCollapsed() {
+ this.isCollapsed = !this.isCollapsed;
+
+ if (this.isExpandedForTheFirstTime && typeof this.fetchExpandedData === 'function') {
+ this.isExpandedForTheFirstTime = false;
+ this.fetchExpandedContent();
+ }
+ },
+ async fetchExpandedContent() {
+ this.isLoadingExpandedContent = true;
+ this.contentError = null;
+
+ try {
+ await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED);
+ } catch {
+ this.contentError = this.errorText;
+
+ // Reset these values so that we allow refetching
+ this.isExpandedForTheFirstTime = true;
+ this.isCollapsed = true;
+ }
+
+ this.isLoadingExpandedContent = false;
+ },
fetch(handler, dataType) {
const requests = this.multiPolling ? handler() : [handler];
@@ -125,6 +181,7 @@ export default {
});
},
},
+ failedStatusIcon: EXTENSION_ICONS.failed,
};
</script>
@@ -135,24 +192,58 @@ export default {
:level="1"
:name="widgetName"
:is-loading="isLoading"
- :icon-name="statusIconName"
+ :icon-name="summaryStatusIcon"
/>
<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>
+ <span v-if="summaryError">{{ summaryError }}</span>
+ <slot v-else name="summary">{{ isLoading ? loadingText : summary }}</slot>
+ </div>
+ <action-buttons
+ v-if="actionButtons.length > 0"
+ :widget="widgetName"
+ :tertiary-buttons="actionButtons"
+ />
+ <div
+ v-if="isCollapsible"
+ class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
+ >
+ <gl-button
+ v-gl-tooltip
+ :title="collapseButtonLabel"
+ :aria-expanded="`${!isCollapsed}`"
+ :aria-label="collapseButtonLabel"
+ :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
+ category="tertiary"
+ data-testid="toggle-button"
+ size="small"
+ @click="toggleCollapsed"
+ />
</div>
- <!-- actions will go here -->
- <!-- toggle button will go here -->
</div>
</div>
<div
+ v-if="!isCollapsed || contentError"
class="mr-widget-grouped-section gl-relative"
data-testid="widget-extension-collapsed-section"
>
- <slot name="content">{{ content }}</slot>
+ <div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center">
+ <gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
+ </div>
+ <content-section
+ v-else-if="contentError"
+ class="report-block-container"
+ :status-icon-name="$options.failedStatusIcon"
+ :widget-name="widgetName"
+ >
+ {{ contentError }}
+ </content-section>
+ <slot v-else name="content">
+ {{ content }}
+ </slot>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue
new file mode 100644
index 00000000000..61e3744b5dc
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_section.vue
@@ -0,0 +1,35 @@
+<script>
+import { EXTENSION_ICONS } from '../../constants';
+import StatusIcon from '../extensions/status_icon.vue';
+
+export default {
+ components: {
+ StatusIcon,
+ },
+ props: {
+ statusIconName: {
+ type: String,
+ default: '',
+ required: false,
+ validator: (value) => value === '' || Object.keys(EXTENSION_ICONS).includes(value),
+ },
+ widgetName: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-px-7">
+ <div class="gl-pl-4 gl-display-flex">
+ <status-icon
+ v-if="statusIconName"
+ :level="2"
+ :name="widgetName"
+ :icon-name="statusIconName"
+ />
+ <slot name="default"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index c148a35209f..be4e34ffff0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -140,6 +140,7 @@ export const EXTENSION_ICON_NAMES = {
neutral: 'status-neutral',
error: 'status-alert',
notice: 'status-alert',
+ scheduled: 'status-scheduled',
severityCritical: 'severity-critical',
severityHigh: 'severity-high',
severityMedium: 'severity-medium',
@@ -155,6 +156,7 @@ export const EXTENSION_ICON_CLASS = {
neutral: 'gl-text-gray-400',
error: 'gl-text-red-500',
notice: 'gl-text-gray-500',
+ scheduled: 'gl-text-blue-500',
severityCritical: 'gl-text-red-800',
severityHigh: 'gl-text-red-600',
severityMedium: 'gl-text-orange-400',
diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
index c74445a5b80..97b9b59e2c3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.js
@@ -32,7 +32,7 @@ export default {
});
});
- return fileNames.join(' ');
+ return fileNames.join(' ').trim();
},
summary(data) {
if (data.parsingInProgress) {
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
deleted file mode 100644
index 7b77d7475bc..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- computed: {
- triggered() {
- return [];
- },
- triggeredBy() {
- return [];
- },
- },
-};
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 1e25143e15c..c8a2a8d119b 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
@@ -59,28 +59,28 @@ export default {
Loading,
ExtensionsContainer,
WidgetContainer,
- 'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
+ MrWidgetSuggestPipeline: WidgetSuggestPipeline,
MrWidgetPipelineContainer,
MrWidgetAlertMessage,
- 'mr-widget-merged': MergedState,
- 'mr-widget-closed': ClosedState,
- 'mr-widget-merging': MergingState,
- 'mr-widget-failed-to-merge': FailedToMerge,
- 'mr-widget-wip': WorkInProgressState,
- 'mr-widget-archived': ArchivedState,
- 'mr-widget-conflicts': ConflictsState,
- 'mr-widget-nothing-to-merge': NothingToMergeState,
- 'mr-widget-not-allowed': NotAllowedState,
- 'mr-widget-missing-branch': MissingBranchState,
- '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,
- 'mr-widget-pipeline-blocked': PipelineBlockedState,
- 'mr-widget-pipeline-failed': PipelineFailedState,
+ MrWidgetMerged: MergedState,
+ MrWidgetClosed: ClosedState,
+ MrWidgetMerging: MergingState,
+ MrWidgetFailedToMerge: FailedToMerge,
+ MrWidgetWip: WorkInProgressState,
+ MrWidgetArchived: ArchivedState,
+ MrWidgetConflicts: ConflictsState,
+ MrWidgetNothingToMerge: NothingToMergeState,
+ MrWidgetNotAllowed: NotAllowedState,
+ MrWidgetMissingBranch: MissingBranchState,
+ MrWidgetReadyToMerge: () => import('./components/states/new_ready_to_merge.vue'),
+ ShaMismatch,
+ MrWidgetChecking: CheckingState,
+ MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState,
+ MrWidgetPipelineBlocked: PipelineBlockedState,
+ MrWidgetPipelineFailed: PipelineFailedState,
MrWidgetAutoMergeEnabled,
- 'mr-widget-auto-merge-failed': AutoMergeFailed,
- 'mr-widget-rebase': RebaseState,
+ MrWidgetAutoMergeFailed: AutoMergeFailed,
+ MrWidgetRebase: RebaseState,
SourceBranchRemovalStatus,
GroupedCodequalityReportsApp: () =>
import('../reports/codequality_report/grouped_codequality_reports_app.vue'),
@@ -230,6 +230,11 @@ export default {
shouldShowCodeQualityExtension() {
return window.gon?.features?.refactorCodeQualityExtension;
},
+ shouldShowMergeDetails() {
+ if (this.mr.state === 'readyToMerge') return true;
+
+ return !this.mr.mergeDetailsCollapsed;
+ },
},
watch: {
'mr.machineValue': {
@@ -318,6 +323,12 @@ export default {
this.initPolling();
this.bindEventHubListeners();
eventHub.$on('mr.discussion.updated', this.checkStatus);
+
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= 768) {
+ this.mr.toggleMergeDetails(false);
+ }
+ });
},
getServiceEndpoints(store) {
return {
@@ -428,6 +439,7 @@ export default {
.then((res) => {
if (res.data) {
const el = document.createElement('div');
+ // eslint-disable-next-line no-unsanitized/property
el.innerHTML = res.data;
document.body.appendChild(el);
document.dispatchEvent(new CustomEvent('merged:UpdateActions'));
@@ -620,7 +632,12 @@ 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="mr.commitsCount" :mr="mr" :service="service" />
+ <ready-to-merge
+ v-if="mr.commitsCount"
+ v-show="shouldShowMergeDetails"
+ :mr="mr"
+ :service="service"
+ />
</div>
</div>
<mr-widget-pipeline-container
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
index 981c667f27a..eac72ffb2f2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -3,6 +3,7 @@ query getState($projectPath: ID!, $iid: String!) {
id
archived
onlyAllowMergeIfPipelineSucceeds
+ allowMergeOnSkippedPipeline
mergeRequest(iid: $iid) {
id
autoMergeEnabled
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 146cf7e11a7..e6ff586892f 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
@@ -28,6 +28,7 @@ export default class MergeRequestStore {
this.stateMachine = machine(STATE_MACHINE.definition);
this.machineValue = this.stateMachine.value;
+ this.mergeDetailsCollapsed = window.innerWidth < 768;
this.setPaths(data);
@@ -168,6 +169,7 @@ export default class MergeRequestStore {
this.mergeError = data.merge_error;
this.mergeStatus = data.merge_status;
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
+ this.allowMergeOnSkippedPipeline = data.allow_merge_on_skipped_pipeline || false;
this.projectArchived = data.project_archived;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.shouldBeRebased = Boolean(data.should_be_rebased);
@@ -195,6 +197,7 @@ export default class MergeRequestStore {
this.projectArchived = project.archived;
this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
+ this.allowMergeOnSkippedPipeline = project.allowMergeOnSkippedPipeline;
this.autoMergeEnabled = mergeRequest.autoMergeEnabled;
this.canBeMerged = mergeRequest.mergeStatus === 'can_be_merged';
@@ -403,4 +406,8 @@ export default class MergeRequestStore {
this.transitionStateMachine(transitionOptions);
}
+
+ toggleMergeDetails(val = !this.mergeDetailsCollapsed) {
+ this.mergeDetailsCollapsed = val;
+ }
}
diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue
index 6db18afe51c..c6c22f9c61f 100644
--- a/app/assets/javascripts/vue_shared/components/actions_button.vue
+++ b/app/assets/javascripts/vue_shared/components/actions_button.vue
@@ -77,7 +77,7 @@ export default {
<template v-for="(action, index) in actions">
<gl-dropdown-item
:key="action.key"
- :is-check-item="true"
+ is-check-item
:is-checked="action.key === selectedAction.key"
:secondary-text="action.secondaryText"
:data-qa-selector="`${action.key}_menu_item`"
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index 5de71c35be9..84bd6bca601 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
import CiIcon from './ci_icon.vue';
/**
* Renders CI Badge link with CI icon and status text based on
@@ -57,13 +58,28 @@ export default {
},
cssClass() {
const className = this.status.group;
- return className ? `ci-status ci-${className} qa-status-badge` : 'ci-status qa-status-badge';
+ return className ? `ci-status ci-${className}` : 'ci-status';
+ },
+ },
+ methods: {
+ navigateToPipeline() {
+ visitUrl(this.detailsPath);
+
+ // event used for tracking
+ this.$emit('ciStatusBadgeClick');
},
},
};
</script>
<template>
- <a v-gl-tooltip :href="detailsPath" :class="cssClass" :title="title">
+ <a
+ v-gl-tooltip
+ :class="cssClass"
+ class="gl-cursor-pointer"
+ :title="title"
+ data-qa-selector="status_badge_link"
+ @click="navigateToPipeline"
+ >
<ci-icon :status="status" :css-classes="iconClasses" />
<template v-if="showText">
diff --git a/app/assets/javascripts/vue_shared/components/code_block.stories.js b/app/assets/javascripts/vue_shared/components/code_block.stories.js
new file mode 100644
index 00000000000..e02a346c1de
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block.stories.js
@@ -0,0 +1,18 @@
+import CodeBlock from './code_block.vue';
+
+export default {
+ component: CodeBlock,
+ title: 'vue_shared/code_block',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { CodeBlock },
+ props: Object.keys(argTypes),
+ template: '<code-block v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ code: `git commit -a "Message"\ngit push`,
+};
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
index 9856f35c7f6..4a69845d3a4 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.vue
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -4,7 +4,8 @@ export default {
props: {
code: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
maxHeight: {
type: String,
@@ -32,5 +33,5 @@ export default {
class="code-block rounded code"
:class="$options.userColorScheme"
:style="styleObject"
- ><code class="d-block">{{ code }}</code></pre>
+ ><slot><code class="d-block">{{ code }}</code></slot></pre>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
new file mode 100644
index 00000000000..bf81a811d16
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.stories.js
@@ -0,0 +1,18 @@
+import CodeBlockHighlighted from './code_block_highlighted.vue';
+
+export default {
+ component: CodeBlockHighlighted,
+ title: 'vue_shared/code_block_highlighted',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { CodeBlockHighlighted },
+ props: Object.keys(argTypes),
+ template: '<code-block-highlighted v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ code: `const foo = 1;\nconsole.log(foo + ' yay')`,
+ language: 'javascript',
+};
diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
new file mode 100644
index 00000000000..65b08b608e8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue
@@ -0,0 +1,72 @@
+<script>
+import { GlSafeHtmlDirective } from '@gitlab/ui';
+
+import languageLoader from '~/content_editor/services/highlight_js_language_loader';
+import CodeBlock from './code_block.vue';
+
+export default {
+ name: 'CodeBlockHighlighted',
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ components: {
+ CodeBlock,
+ },
+ props: {
+ code: {
+ type: String,
+ required: true,
+ },
+ language: {
+ type: String,
+ required: true,
+ },
+ maxHeight: {
+ type: String,
+ required: false,
+ default: 'initial',
+ },
+ },
+ data() {
+ return {
+ hljs: null,
+ languageLoaded: false,
+ };
+ },
+ computed: {
+ highlighted() {
+ if (this.hljs && this.languageLoaded) {
+ return this.hljs.highlight(this.code, { language: this.language }).value;
+ }
+
+ return this.code;
+ },
+ },
+ async mounted() {
+ this.hljs = await this.loadHighlightJS();
+ if (this.language) {
+ await this.loadLanguage();
+ }
+ },
+ methods: {
+ async loadLanguage() {
+ try {
+ const { default: languageDefinition } = await languageLoader[this.language]();
+
+ this.hljs.registerLanguage(this.language, languageDefinition);
+ this.languageLoaded = true;
+ } catch (e) {
+ this.$emit('error', e);
+ }
+ },
+ loadHighlightJS() {
+ return import('highlight.js/lib/core');
+ },
+ },
+};
+</script>
+<template>
+ <code-block :max-height="maxHeight" class="highlight">
+ <span v-safe-html="highlighted"></span>
+ </code-block>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
index 91906388049..22f3c35b9c3 100644
--- a/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
+++ b/app/assets/javascripts/vue_shared/components/color_select_dropdown/dropdown_contents_color_view.vue
@@ -42,8 +42,8 @@ export default {
v-for="color in colors"
:key="color.color"
:is-checked="isColorSelected(color)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
@click.native.capture.stop="handleColorClick(color)"
>
<color-item :color="color.color" :title="color.title" />
diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
index 8481280f25f..7ecc309db52 100644
--- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
+++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.stories.js
@@ -3,7 +3,7 @@ import ConfirmDanger from './confirm_danger.vue';
export default {
component: ConfirmDanger,
- title: 'vue_shared/components/modals/confirm_danger_modal',
+ title: 'vue_shared/modals/confirm_danger_modal',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
index aec67a18a05..38b1a587b34 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
@@ -1,4 +1,4 @@
-import dateformat from 'dateformat';
+import dateformat from '~/lib/dateformat';
import { __ } from '~/locale';
/**
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
index eeed5e9dc3a..8256d953466 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js
@@ -5,7 +5,7 @@ import DropdownWidget from './dropdown_widget.vue';
export default {
component: DropdownWidget,
- title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget',
+ title: 'vue_shared/dropdown/dropdown_widget/dropdown_widget',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
index 840911dc99c..faa50a50c69 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue
@@ -149,8 +149,8 @@ export default {
v-for="option in presetOptions"
:key="option.id"
:is-checked="isSelected(option)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
@click.native.capture.stop="selectOption(option)"
>
<slot name="preset-item" :item="option">
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 5d7f4ae2a01..ffe09634a3b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -46,7 +46,7 @@ export const SortDirection = {
export const FILTERED_SEARCH_LABELS = 'labels';
export const FILTERED_SEARCH_TERM = 'filtered-search-term';
-export const TOKEN_TITLE_ASSIGNEE = __('Assignee');
+export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee');
export const TOKEN_TITLE_AUTHOR = __('Author');
export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
export const TOKEN_TITLE_CONTACT = s__('Crm|Contact');
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 33d507dad57..e311df6e66f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
@@ -369,7 +369,7 @@ export default {
<gl-dropdown-item
v-for="sortBy in sortOptions"
:key="sortBy.id"
- :is-check-item="true"
+ is-check-item
:is-checked="sortBy.id === selectedSortOption.id"
@click="handleSortOptionClick(sortBy)"
>{{ sortBy.title }}</gl-dropdown-item
diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
index cdd7a074f34..377f1e7c136 100644
--- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
+++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.stories.js
@@ -2,7 +2,7 @@ import InputCopyToggleVisibility from './input_copy_toggle_visibility.vue';
export default {
component: InputCopyToggleVisibility,
- title: 'vue_shared/components/form/input_copy_toggle_visibility',
+ title: 'vue_shared/form/input_copy_toggle_visibility',
};
const defaultProps = {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 1d1b65aa1af..458dfe0ed23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -182,7 +182,7 @@ export default {
<div class="md-header">
<gl-tabs content-class="gl-display-none">
<gl-tab
- title-link-class="gl-pt-3 gl-px-3 js-md-write-button"
+ title-link-class="gl-py-4 gl-px-3 js-md-write-button"
:title="$options.i18n.writeTabTitle"
:active="!previewMarkdown"
data-testid="write-tab"
@@ -190,7 +190,7 @@ export default {
/>
<gl-tab
v-if="enablePreview"
- title-link-class="gl-pt-3 gl-px-3 js-md-preview-button"
+ title-link-class="gl-py-4 gl-px-3 js-md-preview-button"
:title="$options.i18n.previewTabTitle"
:active="previewMarkdown"
data-testid="preview-tab"
@@ -201,7 +201,7 @@ export default {
<div
data-testid="md-header-toolbar"
:class="{ 'gl-display-none!': previewMarkdown }"
- class="md-header-toolbar gl-ml-auto gl-pb-3 gl-justify-content-center"
+ class="md-header-toolbar gl-ml-auto gl-py-2 gl-justify-content-center"
>
<template v-if="canSuggest">
<toolbar-button
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index de3eda6b04f..9b81444fc04 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -163,6 +163,7 @@ export default {
// resets the container HTML (replaces it with the updated noteHTML)
// calls `renderSuggestions` once the updated noteHTML is added to the DOM
+ // eslint-disable-next-line no-unsanitized/property
this.$refs.container.innerHTML = this.noteHtml;
this.isRendered = false;
this.renderSuggestions();
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index aa325862f06..b5640e12541 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -72,7 +72,7 @@ export default {
</gl-sprintf>
</template>
</div>
- <span v-if="canAttachFile" class="uploading-container">
+ <span v-if="canAttachFile" class="uploading-container gl-line-height-32">
<span class="uploading-progress-container hide">
<gl-icon name="paperclip" />
<span class="attaching-file-message"></span>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 3593ea16968..7e99f1b01b2 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -29,7 +29,7 @@ import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_
import '~/behaviors/markdown/render_gfm';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
-import noteHeader from '~/notes/components/note_header.vue';
+import NoteHeader from '~/notes/components/note_header.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { spriteIcon } from '~/lib/utils/common_utils';
import TimelineEntryItem from './timeline_entry_item.vue';
@@ -43,7 +43,7 @@ export default {
name: 'SystemNote',
components: {
GlIcon,
- noteHeader,
+ NoteHeader,
TimelineEntryItem,
GlButton,
GlSkeletonLoader,
diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
index e31446f4bb8..f16afc77164 100644
--- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.stories.js
@@ -3,7 +3,7 @@ import PaginationBar from './pagination_bar.vue';
export default {
component: PaginationBar,
- title: 'vue_shared/components/pagination_bar/pagination_bar',
+ title: 'vue_shared/pagination_bar/pagination_bar',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
index 110c6c73bad..bfb30c74cb8 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
+++ b/app/assets/javascripts/vue_shared/components/project_avatar.stories.js
@@ -2,7 +2,7 @@ import ProjectAvatar from './project_avatar.vue';
export default {
component: ProjectAvatar,
- title: 'vue_shared/components/project_avatar',
+ title: 'vue_shared/project_avatar',
};
const Template = (args, { argTypes }) => ({
@@ -13,8 +13,7 @@ const Template = (args, { argTypes }) => ({
export const Default = Template.bind({});
Default.args = {
- projectAvatarUrl:
- 'https://gitlab.com/uploads/-/system/project/avatar/278964/logo-extra-whitespace.png?width=64',
+ projectAvatarUrl: 'https://gitlab.com/uploads/-/system/project/avatar/278964/project_avatar.png',
projectName: 'GitLab',
};
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
index 9700117a3da..4021e23a3f6 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js
@@ -2,7 +2,7 @@ import ProjectListItem from './project_list_item.vue';
export default {
component: ProjectListItem,
- title: 'vue_shared/components/project_selector/project_list_item',
+ title: 'vue_shared/project_selector/project_list_item',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
index 43a8e241d77..32d7cdad568 100644
--- a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue
@@ -49,7 +49,7 @@ export default {
v-for="option in parsedOptions"
:key="option.value"
:is-checked="option.selected"
- :is-check-item="true"
+ is-check-item
@click="setSelected(option.value)"
>
{{ option.label }}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index bfaf3b92c34..c5d3704ead9 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -253,7 +253,7 @@ export default {
<gl-dropdown-item
v-for="architecture in architectures"
:key="architecture.name"
- :is-check-item="true"
+ is-check-item
:is-checked="selectedArchitecture === architecture.name"
data-testid="architecture-dropdown-item"
@click="selectArchitecture(architecture.name)"
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
index 5242743ad30..53e4a08e486 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
@@ -2,7 +2,7 @@ import SettingsBlock from './settings_block.vue';
export default {
component: SettingsBlock,
- title: 'vue_shared/components/settings/settings_block',
+ title: 'vue_shared/settings/settings_block',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
index dfa2ca2d20c..0f5560ff628 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue
@@ -180,7 +180,7 @@ export default {
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
- :is-check-item="true"
+ is-check-item
:is-checked="isSelectedProject(project)"
@click.stop.prevent="handleProjectSelect(project)"
>{{ project.name_with_namespace }}</gl-dropdown-item
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index f595e635f2c..8d3d4d5f86a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -154,8 +154,8 @@ export default {
v-for="(label, index) in visibleLabels"
:key="label.id"
:is-checked="isLabelSelected(label)"
- :is-check-centered="true"
- :is-check-item="true"
+ is-check-centered
+ is-check-item
:active="shouldHighlightFirstItem && index === 0"
active-class="is-focused"
data-testid="labels-list"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
index aaddab43e2a..154a8e866d0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue
@@ -80,6 +80,7 @@ export default {
v-if="!showDropdownContentsCreateView"
ref="searchInput"
:value="searchKey"
+ :placeholder="__('Search labels')"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
data-testid="dropdown-input-field"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
index 294e5bd9f90..8a2bab4cb9a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js
@@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue';
export default {
component: TodoButton,
- title: 'vue_shared/components/sidebar/todo_toggle/todo_button',
+ title: 'vue_shared/sidebar/todo_toggle/todo_button',
};
const Template = (args, { argTypes }) => ({
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 cc930d67fa4..30f57f506a6 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -81,6 +81,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
protobuf: 'protobuf',
puppet: 'puppet',
python: 'python',
+ python3: 'python',
q: 'q',
qml: 'qml',
r: 'r',
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
index 5be92af5b55..8b52df83fdf 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/wrap_comments.js
@@ -3,6 +3,8 @@ import { HLJS_COMMENT_SELECTOR } from '../constants';
const createWrapper = (content) => {
const span = document.createElement('span');
span.className = HLJS_COMMENT_SELECTOR;
+
+ // eslint-disable-next-line no-unsanitized/property
span.innerHTML = content;
return span.outerHTML;
};
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 f471db24889..9c6c12eac7d 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
@@ -42,7 +42,7 @@ export default {
return {
languageDefinition: null,
content: this.blob.rawTextBlob,
- language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language],
+ language: ROUGE_TO_HLJS_LANGUAGE_MAP[this.blob.language?.toLowerCase()],
hljs: null,
firstChunk: null,
chunks: {},
@@ -62,7 +62,7 @@ export default {
const supportedLanguages = Object.keys(languageLoader);
return (
!supportedLanguages.includes(this.language) &&
- !supportedLanguages.includes(this.blob.language)
+ !supportedLanguages.includes(this.blob.language?.toLowerCase())
);
},
},
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index 994fa68fb1a..c0aef42b0f2 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -68,7 +68,7 @@ export default {
<template v-for="(item, itemIndex) in actionItems">
<gl-dropdown-item
:key="item.eventName"
- :is-check-item="true"
+ is-check-item
:is-checked="selectedItem === item"
@click="changeSelectedItem(item)"
>
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
index 42334d80eec..ce65266cbc9 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
@@ -72,7 +72,7 @@ export default {
v-for="timezone in filteredResults"
:key="timezone.formattedTimezone"
:is-checked="isSelected(timezone)"
- :is-check-item="true"
+ is-check-item
@click="selectTimezone(timezone)"
>
{{ timezone.formattedTimezone }}
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
index f27901a30a9..e621442e601 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.stories.js
@@ -5,7 +5,7 @@ const defaultWidth = '250px';
export default {
component: TooltipOnTruncate,
- title: 'vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue',
+ title: 'vue_shared/tooltip_on_truncate/tooltip_on_truncate.vue',
};
const createStory = ({ ...options }) => {
diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
index 424cab20c7e..a001b6bdf24 100644
--- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
+++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue
@@ -149,7 +149,7 @@ export default {
>
<slot>
<button
- class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-4 gl-mb-0"
type="button"
@click="openFileUpload"
>
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 cd610314292..6bd66981860 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
@@ -90,9 +90,8 @@ export default {
</script>
<template>
- <span>
+ <span ref="userAvatar">
<gl-avatar
- ref="userAvatar"
:class="{
lazy: lazy,
[cssClasses]: true,
@@ -108,7 +107,7 @@ export default {
tooltipText ||
$slots.default /* eslint-disable-line @gitlab/vue-prefer-dollar-scopedslots */
"
- :target="() => $refs.userAvatar.$el"
+ :target="() => $refs.userAvatar"
:placement="tooltipPlacement"
boundary="window"
>
diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
index d2030c14029..1f0f4cde234 100644
--- a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
+++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js
@@ -5,7 +5,7 @@ import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue';
export default {
component: UserDeletionObstaclesList,
- title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list',
+ title: 'vue_shared/user_deletion_obstacles/user_deletion_obstacles_list',
};
const Template = (args, { argTypes }) => ({
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/constants.js b/app/assets/javascripts/vue_shared/components/user_popover/constants.js
index 1d49aefd297..bcbe72b4b4f 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/constants.js
+++ b/app/assets/javascripts/vue_shared/components/user_popover/constants.js
@@ -1 +1,14 @@
+import { __ } from '~/locale';
+
export const USER_POPOVER_DELAY = 200;
+export const I18N_ERROR_FOLLOW = __(
+ 'An error occurred while trying to follow this user, please try again.',
+);
+export const I18N_ERROR_UNFOLLOW = __(
+ 'An error occurred while trying to unfollow this user, please try again.',
+);
+export const I18N_USER_BLOCKED = __('User is blocked');
+export const I18N_USER_BUSY = __('Busy');
+export const I18N_USER_LEARN = __('Learn more about %{name}');
+export const I18N_USER_FOLLOW = __('Follow');
+export const I18N_USER_UNFOLLOW = __('Unfollow');
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 2b9804796ae..4b39a8e45bb 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
@@ -9,23 +9,31 @@ import {
GlButton,
GlAvatarLabeled,
} from '@gitlab/ui';
-import { __ } from '~/locale';
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';
+import {
+ I18N_ERROR_FOLLOW,
+ I18N_ERROR_UNFOLLOW,
+ I18N_USER_BLOCKED,
+ I18N_USER_BUSY,
+ I18N_USER_LEARN,
+ I18N_USER_FOLLOW,
+ I18N_USER_UNFOLLOW,
+ USER_POPOVER_DELAY,
+} from './constants';
const MAX_SKELETON_LINES = 4;
export default {
name: 'UserPopover',
maxSkeletonLines: MAX_SKELETON_LINES,
+ I18N_USER_BLOCKED,
+ I18N_USER_BUSY,
+ I18N_USER_LEARN,
USER_POPOVER_DELAY,
- i18n: {
- busy: __('Busy'),
- },
components: {
GlIcon,
GlLink,
@@ -94,7 +102,7 @@ export default {
toggleFollowButtonText() {
if (this.toggleFollowLoading) return null;
- return this.user?.isFollowed ? __('Unfollow') : __('Follow');
+ return this.user?.isFollowed ? I18N_USER_UNFOLLOW : I18N_USER_FOLLOW;
},
toggleFollowButtonVariant() {
return this.user?.isFollowed ? 'default' : 'confirm';
@@ -102,6 +110,9 @@ export default {
hasPronouns() {
return Boolean(this.user?.pronouns?.trim());
},
+ isBlocked() {
+ return this.user?.state === 'blocked';
+ },
isBusy() {
return isUserBusy(this.availabilityStatus);
},
@@ -129,7 +140,7 @@ export default {
this.$emit('follow');
} catch (error) {
createFlash({
- message: __('An error occurred while trying to follow this user, please try again.'),
+ message: I18N_ERROR_FOLLOW,
error,
captureError: true,
});
@@ -149,7 +160,7 @@ export default {
this.$emit('unfollow');
} catch (error) {
createFlash({
- message: __('An error occurred while trying to unfollow this user, please try again.'),
+ message: I18N_ERROR_UNFOLLOW,
error,
captureError: true,
});
@@ -189,16 +200,21 @@ export default {
:label="user.name"
:sub-label="username"
>
- <gl-button
- v-if="shouldRenderToggleFollowButton"
- class="gl-mt-3 gl-align-self-start"
- :variant="toggleFollowButtonVariant"
- :loading="toggleFollowLoading"
- size="small"
- data-testid="toggle-follow-button"
- @click="toggleFollow"
- >{{ toggleFollowButtonText }}</gl-button
- >
+ <template v-if="isBlocked">
+ <span class="gl-mt-4 gl-font-style-italic">{{ $options.I18N_USER_BLOCKED }}</span>
+ </template>
+ <template v-else>
+ <gl-button
+ v-if="shouldRenderToggleFollowButton"
+ class="gl-mt-3 gl-align-self-start"
+ :variant="toggleFollowButtonVariant"
+ :loading="toggleFollowLoading"
+ size="small"
+ data-testid="toggle-follow-button"
+ @click="toggleFollow"
+ >{{ toggleFollowButtonText }}</gl-button
+ >
+ </template>
<template #meta>
<span
@@ -208,7 +224,7 @@ export default {
>({{ user.pronouns }})</span
>
<span v-if="isBusy" class="gl-text-gray-500 gl-font-sm gl-font-weight-normal gl-p-1"
- >({{ $options.i18n.busy }})</span
+ >({{ $options.I18N_USER_BUSY }})</span
>
</template>
</gl-avatar-labeled>
@@ -223,39 +239,41 @@ export default {
/>
</template>
<template v-else>
- <div class="gl-text-gray-500">
- <div v-if="user.bio" class="gl-display-flex gl-mb-2">
- <gl-icon name="profile" class="gl-flex-shrink-0" />
- <span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
+ <template v-if="!isBlocked">
+ <div class="gl-text-gray-500">
+ <div v-if="user.bio" class="gl-display-flex gl-mb-2">
+ <gl-icon name="profile" class="gl-flex-shrink-0" />
+ <span ref="bio" class="gl-ml-2">{{ user.bio }}</span>
+ </div>
+ <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
+ <gl-icon name="work" class="gl-flex-shrink-0" />
+ <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ </div>
+ <div v-if="user.location" class="gl-display-flex gl-mb-2">
+ <gl-icon name="location" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.location }}</span>
+ </div>
+ <div
+ v-if="user.localTime && !user.bot"
+ class="gl-display-flex gl-mb-2"
+ data-testid="user-popover-local-time"
+ >
+ <gl-icon name="clock" class="gl-flex-shrink-0" />
+ <span class="gl-ml-2">{{ user.localTime }}</span>
+ </div>
</div>
- <div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
- <gl-icon name="work" class="gl-flex-shrink-0" />
- <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span>
+ <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
+ <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
- <div v-if="user.location" class="gl-display-flex gl-mb-2">
- <gl-icon name="location" class="gl-flex-shrink-0" />
- <span class="gl-ml-2">{{ user.location }}</span>
+ <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
+ <gl-icon name="question" />
+ <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
+ <gl-sprintf :message="$options.I18N_USER_LEARN">
+ <template #name>{{ user.name }}</template>
+ </gl-sprintf>
+ </gl-link>
</div>
- <div
- v-if="user.localTime && !user.bot"
- class="gl-display-flex gl-mb-2"
- data-testid="user-popover-local-time"
- >
- <gl-icon name="clock" class="gl-flex-shrink-0" />
- <span class="gl-ml-2">{{ user.localTime }}</span>
- </div>
- </div>
- <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status">
- <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
- </div>
- <div v-if="user.bot && user.websiteUrl" class="gl-text-blue-500">
- <gl-icon name="question" />
- <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
- <gl-sprintf :message="__('Learn more about %{username}')">
- <template #username>{{ user.name }}</template>
- </gl-sprintf>
- </gl-link>
- </div>
+ </template>
</template>
</div>
</gl-popover>
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 43a590c2367..3180bd0d283 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
@@ -320,7 +320,7 @@ export default {
<gl-dropdown-item
v-if="isSearchEmpty"
:is-checked="selectedIsEmpty"
- :is-check-centered="true"
+ is-check-centered
data-testid="unassign"
@click.native.capture.stop="$emit('input', [])"
>
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 38083327593..7e735f358eb 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
@@ -232,7 +232,11 @@ export default {
</span>
</div>
<div class="issuable-info">
- <work-item-type-icon v-if="showWorkItemTypeIcon" :work-item-type="issuable.type" />
+ <work-item-type-icon
+ v-if="showWorkItemTypeIcon"
+ :work-item-type="issuable.type"
+ show-tooltip-on-hover
+ />
<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/new_namespace/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
index d2fc2c66924..e42720bf1db 100644
--- a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
@@ -10,6 +10,7 @@ export default {
mounted() {
const legacyEntry = document.querySelector(this.selector);
if (legacyEntry.tagName === 'TEMPLATE') {
+ // eslint-disable-next-line no-unsanitized/property
this.$el.innerHTML = legacyEntry.innerHTML;
} else {
this.source = legacyEntry.parentNode;
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
index 2e80db30e9a..6a83669d206 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql
@@ -14,6 +14,7 @@ query securityReportDownloadPaths(
id
name
artifacts {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
nodes {
downloadPath
fileType
diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
index e4f0c392b91..1f1e56a5876 100644
--- a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql
@@ -4,6 +4,7 @@ query getPipelineCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityRe
project(fullPath: $projectPath) {
id
pipeline(iid: $iid) {
+ # eslint-disable-next-line @graphql-eslint/require-id-when-available
...JobArtifacts
}
}
diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js
index af671e72129..c1baa7b8dd3 100644
--- a/app/assets/javascripts/webpack_non_compiled_placeholder.js
+++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js
@@ -20,6 +20,7 @@ const reloadMessage = LIVE_RELOAD
? 'You have live_reload enabled, the page will reload automatically when complete.'
: 'You have live_reload disabled, the page will reload automatically in a few seconds.';
+// eslint-disable-next-line no-unsanitized/property
div.innerHTML = `
<!-- https://github.com/webpack/media/blob/master/logo/icon-square-big.svg -->
<svg height="50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200">
diff --git a/app/assets/javascripts/work_items/components/item_title.vue b/app/assets/javascripts/work_items/components/item_title.vue
index 551ebbadb21..b2c8b7ae1db 100644
--- a/app/assets/javascripts/work_items/components/item_title.vue
+++ b/app/assets/javascripts/work_items/components/item_title.vue
@@ -39,14 +39,14 @@ export default {
:class="{ 'gl-cursor-text': disabled }"
aria-labelledby="item-title"
>
- <div
+ <span
id="item-title"
ref="titleEl"
role="textbox"
:aria-label="__('Title')"
:data-placeholder="placeholder"
:contenteditable="!disabled"
- class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base"
+ class="gl-px-4 gl-py-3 gl-ml-n4 gl-border gl-border-white gl-rounded-base gl-display-block"
:class="{ 'gl-hover-border-gray-200 gl-pseudo-placeholder': !disabled }"
@blur="handleBlur"
@keyup="handleInput"
@@ -55,8 +55,7 @@ export default {
@keydown.meta.u.prevent
@keydown.ctrl.b.prevent
@keydown.meta.b.prevent
+ >{{ title }}</span
>
- {{ title }}
- </div>
</h2>
</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 2753c3fa388..9f9d94ec3c2 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -8,10 +8,14 @@ import {
} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_DELETE,
+ I18N_WORK_ITEM_ARE_YOU_SURE_DELETE,
+} from '../constants';
export default {
i18n: {
- deleteTask: s__('WorkItem|Delete task'),
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
},
@@ -31,6 +35,11 @@ export default {
required: false,
default: null,
},
+ workItemType: {
+ type: String,
+ required: false,
+ default: null,
+ },
canUpdate: {
type: Boolean,
required: false,
@@ -53,6 +62,14 @@ export default {
},
},
emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
+ computed: {
+ i18n() {
+ return {
+ deleteWorkItem: sprintfWorkItem(I18N_WORK_ITEM_DELETE, this.workItemType),
+ areYouSureDelete: sprintfWorkItem(I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, this.workItemType),
+ };
+ },
+ },
methods: {
handleToggleWorkItemConfidentiality() {
this.track('click_toggle_work_item_confidentiality');
@@ -75,6 +92,7 @@ export default {
<div>
<gl-dropdown
icon="ellipsis_v"
+ data-testid="work-item-actions-dropdown"
text-sr-only
:text="__('More actions')"
category="tertiary"
@@ -97,20 +115,18 @@ export default {
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
data-testid="delete-action"
- >{{ $options.i18n.deleteTask }}</gl-dropdown-item
+ >{{ i18n.deleteWorkItem }}</gl-dropdown-item
>
</gl-dropdown>
<gl-modal
modal-id="work-item-confirm-delete"
- :title="$options.i18n.deleteWorkItem"
- :ok-title="$options.i18n.deleteWorkItem"
+ :title="i18n.deleteWorkItem"
+ :ok-title="i18n.deleteWorkItem"
ok-variant="danger"
@ok="handleDeleteWorkItem"
@hide="handleCancelDeleteWorkItem"
>
- {{
- s__('WorkItem|Are you sure you want to delete the task? This action cannot be reversed.')
- }}
+ {{ i18n.areYouSureDelete }}
</gl-modal>
</div>
</template>
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 7342f215b5e..4585426edaa 100644
--- a/app/assets/javascripts/work_items/components/work_item_assignees.vue
+++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue
@@ -8,6 +8,7 @@ import {
GlButton,
GlDropdownItem,
GlDropdownDivider,
+ GlIntersectionObserver,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
@@ -19,7 +20,7 @@ import Tracking from '~/tracking';
import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import { i18n, TRACKING_CATEGORY_SHOW, DEFAULT_PAGE_SIZE_ASSIGNEES } from '../constants';
function isTokenSelectorElement(el) {
return (
@@ -50,9 +51,9 @@ export default {
InviteMembersTrigger,
GlDropdownItem,
GlDropdownDivider,
+ GlIntersectionObserver,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -80,6 +81,10 @@ export default {
required: false,
default: false,
},
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -87,12 +92,15 @@ export default {
searchStarted: false,
localAssignees: this.assignees.map(addClass),
searchKey: '',
- searchUsers: [],
+ users: {
+ nodes: [],
+ },
currentUser: null,
+ isLoadingMore: false,
};
},
apollo: {
- searchUsers: {
+ users: {
query() {
return userSearchQuery;
},
@@ -100,13 +108,14 @@ export default {
return {
fullPath: this.fullPath,
search: this.searchKey,
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
};
},
skip() {
return !this.searchStarted;
},
update(data) {
- return data.workspace?.users?.nodes.map((node) => addClass({ ...node, ...node.user }));
+ return data.workspace?.users;
},
error() {
this.$emit('error', i18n.fetchError);
@@ -117,6 +126,12 @@ export default {
},
},
computed: {
+ searchUsers() {
+ return this.users.nodes.map((node) => addClass({ ...node, ...node.user }));
+ },
+ pageInfo() {
+ return this.users.pageInfo;
+ },
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
@@ -131,7 +146,7 @@ export default {
return !this.isEditing ? 'gl-shadow-none!' : '';
},
isLoadingUsers() {
- return this.$apollo.queries.searchUsers.loading;
+ return this.$apollo.queries.users.loading;
},
assigneeText() {
return n__('WorkItem|Assignee', 'WorkItem|Assignees', this.localAssignees.length);
@@ -159,6 +174,12 @@ export default {
assigneeIds() {
return this.localAssignees.map(({ id }) => id);
},
+ hasNextPage() {
+ return this.pageInfo?.hasNextPage;
+ },
+ showIntersectionSkeletonLoader() {
+ return this.isLoadingMore && this.dropdownItems.length;
+ },
},
watch: {
assignees: {
@@ -221,6 +242,16 @@ export default {
this.isEditing = true;
this.searchStarted = true;
},
+ async fetchMoreAssignees() {
+ this.isLoadingMore = true;
+ await this.$apollo.queries.users.fetchMore({
+ variables: {
+ after: this.pageInfo.endCursor,
+ first: DEFAULT_PAGE_SIZE_ASSIGNEES,
+ },
+ });
+ this.isLoadingMore = false;
+ },
async focusTokenSelector() {
this.handleFocus();
await this.$nextTick();
@@ -263,7 +294,7 @@ export default {
</script>
<template>
- <div class="form-row gl-mb-5 work-item-assignees gl-relative">
+ <div class="form-row gl-mb-5 work-item-assignees gl-relative gl-flex-nowrap">
<span
class="gl-font-weight-bold col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
data-testid="assignees-title"
@@ -275,7 +306,7 @@ export default {
:container-class="containerClass"
:class="{ 'gl-hover-border-gray-200': canUpdate }"
:dropdown-items="dropdownItems"
- :loading="isLoadingUsers"
+ :loading="isLoadingUsers && !isLoadingMore"
: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"
@@ -326,17 +357,32 @@ export default {
<rect width="280" height="20" x="10" y="130" rx="4" />
</gl-skeleton-loader>
</template>
- <template v-if="canInviteMembers" #dropdown-footer>
- <gl-dropdown-divider />
- <gl-dropdown-item @click="closeDropdown">
- <invite-members-trigger
- :display-text="__('Invite members')"
- trigger-element="side-nav"
- icon="plus"
- trigger-source="work-item-assignees-dropdown"
- classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2"
- />
- </gl-dropdown-item>
+ <template #dropdown-footer>
+ <gl-intersection-observer
+ v-if="hasNextPage && !isLoadingUsers"
+ @appear="fetchMoreAssignees"
+ />
+ <gl-skeleton-loader
+ v-if="showIntersectionSkeletonLoader"
+ :height="100"
+ data-testid="next-page-loading"
+ class="gl-text-center gl-py-3"
+ >
+ <rect width="380" height="20" x="10" y="15" rx="4" />
+ <rect width="280" height="20" x="10" y="50" rx="4" />
+ </gl-skeleton-loader>
+ <div v-if="canInviteMembers">
+ <gl-dropdown-divider />
+ <gl-dropdown-item @click="closeDropdown">
+ <invite-members-trigger
+ :display-text="__('Invite members')"
+ trigger-element="side-nav"
+ icon="plus"
+ trigger-source="work-item-assignees-dropdown"
+ classes="gl-display-block gl-text-body! gl-hover-text-decoration-none gl-pb-2"
+ />
+ </gl-dropdown-item>
+ </div>
</template>
</gl-token-selector>
</div>
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 cf59789ce2d..c2e4a50fe31 100644
--- a/app/assets/javascripts/work_items/components/work_item_description.vue
+++ b/app/assets/javascripts/work_items/components/work_item_description.vue
@@ -8,7 +8,7 @@ import { __, s__ } from '~/locale';
import Tracking from '~/tracking';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import workItemQuery from '../graphql/work_item.query.graphql';
-import updateWorkItemWidgetsMutation from '../graphql/update_work_item_widgets.mutation.graphql';
+import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
export default {
@@ -21,12 +21,15 @@ export default {
MarkdownField,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
required: true,
},
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
markdownDocsPath: helpPagePath('user/markdown'),
data() {
@@ -139,9 +142,9 @@ export default {
this.track('updated_description');
const {
- data: { workItemUpdateWidgets },
+ data: { workItemUpdate },
} = await this.$apollo.mutate({
- mutation: updateWorkItemWidgetsMutation,
+ mutation: updateWorkItemMutation,
variables: {
input: {
id: this.workItem.id,
@@ -152,8 +155,8 @@ export default {
},
});
- if (workItemUpdateWidgets.errors?.length) {
- throw new Error(workItemUpdateWidgets.errors[0]);
+ if (workItemUpdate.errors?.length) {
+ throw new Error(workItemUpdate.errors[0]);
}
this.isEditing = false;
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 a5580c14a7a..3d25df9fcb8 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -16,12 +16,14 @@ import {
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_DESCRIPTION,
+ WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
+import workItemDatesSubscription from '../graphql/work_item_dates.subscription.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';
@@ -30,9 +32,9 @@ import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
import WorkItemDescription from './work_item_description.vue';
+import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
-import WorkItemWeight from './work_item_weight.vue';
import WorkItemInformation from './work_item_information.vue';
export default {
@@ -50,10 +52,11 @@ export default {
WorkItemAssignees,
WorkItemActions,
WorkItemDescription,
+ WorkItemDueDate,
WorkItemLabels,
WorkItemTitle,
WorkItemState,
- WorkItemWeight,
+ WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'),
WorkItemInformation,
LocalStorageSync,
WorkItemTypeIcon,
@@ -98,14 +101,36 @@ export default {
error() {
this.error = this.$options.i18n.fetchError;
},
- subscribeToMore: {
- document: workItemTitleSubscription,
- variables() {
- return {
- issuableId: this.workItemId,
- };
- },
+ result() {
+ if (!this.isModal) {
+ const path = this.workItem.project?.fullPath
+ ? ` · ${this.workItem.project.fullPath}`
+ : '';
+
+ document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`;
+ }
},
+ subscribeToMore: [
+ {
+ document: workItemTitleSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
+ },
+ {
+ document: workItemDatesSubscription,
+ variables() {
+ return {
+ issuableId: this.workItemId,
+ };
+ },
+ skip() {
+ return !this.workItemDueDate;
+ },
+ },
+ ],
},
},
computed: {
@@ -121,6 +146,9 @@ export default {
canDelete() {
return this.workItem?.userPermissions?.deleteWorkItem;
},
+ fullPath() {
+ return this.workItem?.project.fullPath;
+ },
workItemsMvc2Enabled() {
return this.glFeatures.workItemsMvc2;
},
@@ -133,6 +161,11 @@ export default {
workItemLabels() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS);
},
+ workItemDueDate() {
+ return this.workItem?.widgets?.find(
+ (widget) => widget.type === WIDGET_TYPE_START_AND_DUE_DATE,
+ );
+ },
workItemWeight() {
return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT);
},
@@ -276,11 +309,12 @@ export default {
<work-item-actions
v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
+ :work-item-type="workItemType"
:can-delete="canDelete"
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
- @deleteWorkItem="$emit('deleteWorkItem')"
+ @deleteWorkItem="$emit('deleteWorkItem', workItemType)"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="error = $event"
/>
@@ -317,21 +351,32 @@ export default {
:can-update="canUpdate"
@error="error = $event"
/>
+ <work-item-assignees
+ v-if="workItemAssignees"
+ :can-update="canUpdate"
+ :work-item-id="workItem.id"
+ :assignees="workItemAssignees.assignees.nodes"
+ :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
+ :work-item-type="workItemType"
+ :can-invite-members="workItemAssignees.canInviteMembers"
+ :full-path="fullPath"
+ @error="error = $event"
+ />
<template v-if="workItemsMvc2Enabled">
- <work-item-assignees
- v-if="workItemAssignees"
- :can-update="canUpdate"
- :work-item-id="workItem.id"
- :assignees="workItemAssignees.assignees.nodes"
- :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees"
- :work-item-type="workItemType"
- :can-invite-members="workItemAssignees.canInviteMembers"
- @error="error = $event"
- />
<work-item-labels
v-if="workItemLabels"
:work-item-id="workItem.id"
:can-update="canUpdate"
+ :full-path="fullPath"
+ @error="error = $event"
+ />
+ <work-item-due-date
+ v-if="workItemDueDate"
+ :can-update="canUpdate"
+ :due-date="workItemDueDate.dueDate"
+ :start-date="workItemDueDate.startDate"
+ :work-item-id="workItem.id"
+ :work-item-type="workItemType"
@error="error = $event"
/>
</template>
@@ -347,6 +392,7 @@ export default {
<work-item-description
v-if="hasDescriptionWidget"
:work-item-id="workItem.id"
+ :full-path="fullPath"
class="gl-pt-5"
@error="error = $event"
/>
diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue
new file mode 100644
index 00000000000..05f8fa8f5e1
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue
@@ -0,0 +1,257 @@
+<script>
+import { GlButton, GlDatepicker, GlFormGroup } from '@gitlab/ui';
+import * as Sentry from '@sentry/browser';
+import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import {
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ sprintfWorkItem,
+ TRACKING_CATEGORY_SHOW,
+} from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+
+const nullObjectDate = new Date(0);
+
+export default {
+ i18n: {
+ addDueDate: s__('WorkItem|Add due date'),
+ addStartDate: s__('WorkItem|Add start date'),
+ dates: s__('WorkItem|Dates'),
+ dueDate: s__('WorkItem|Due date'),
+ none: s__('WorkItem|None'),
+ startDate: s__('WorkItem|Start date'),
+ },
+ dueDateInputId: 'due-date-input',
+ startDateInputId: 'start-date-input',
+ components: {
+ GlButton,
+ GlDatepicker,
+ GlFormGroup,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ canUpdate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ dueDate: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ startDate: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ workItemId: {
+ type: String,
+ required: true,
+ },
+ workItemType: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dirtyDueDate: null,
+ dirtyStartDate: null,
+ isUpdating: false,
+ showDueDateInput: false,
+ showStartDateInput: false,
+ };
+ },
+ computed: {
+ datesUnchanged() {
+ const dirtyDueDate = this.dirtyDueDate || nullObjectDate;
+ const dirtyStartDate = this.dirtyStartDate || nullObjectDate;
+ const dueDate = this.dueDate ? newDateAsLocaleTime(this.dueDate) : nullObjectDate;
+ const startDate = this.startDate ? newDateAsLocaleTime(this.startDate) : nullObjectDate;
+ return (
+ dirtyDueDate.getTime() === dueDate.getTime() &&
+ dirtyStartDate.getTime() === startDate.getTime()
+ );
+ },
+ isDatepickerDisabled() {
+ return !this.canUpdate || this.isUpdating;
+ },
+ isReadonlyWithOnlyDueDate() {
+ return !this.canUpdate && this.dueDate && !this.startDate;
+ },
+ isReadonlyWithOnlyStartDate() {
+ return !this.canUpdate && !this.dueDate && this.startDate;
+ },
+ isReadonlyWithNoDates() {
+ return !this.canUpdate && !this.dueDate && !this.startDate;
+ },
+ labelClass() {
+ return this.isReadonlyWithNoDates ? 'gl-align-self-center gl-pb-0!' : 'gl-mt-3 gl-pb-0!';
+ },
+ showDueDateButton() {
+ return this.canUpdate && !this.showDueDateInput;
+ },
+ showStartDateButton() {
+ return this.canUpdate && !this.showStartDateInput;
+ },
+ tracking() {
+ return {
+ category: TRACKING_CATEGORY_SHOW,
+ label: 'item_dates',
+ property: `type_${this.workItemType}`,
+ };
+ },
+ },
+ watch: {
+ dueDate: {
+ handler(newDueDate) {
+ this.dirtyDueDate = newDateAsLocaleTime(newDueDate);
+ this.showDueDateInput = Boolean(newDueDate);
+ },
+ immediate: true,
+ },
+ startDate: {
+ handler(newStartDate) {
+ this.dirtyStartDate = newDateAsLocaleTime(newStartDate);
+ this.showStartDateInput = Boolean(newStartDate);
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ clearDueDatePicker() {
+ this.dirtyDueDate = null;
+ this.showDueDateInput = false;
+ this.updateDates();
+ },
+ clearStartDatePicker() {
+ this.dirtyStartDate = null;
+ this.showStartDateInput = false;
+ this.updateDates();
+ },
+ async clickShowDueDate() {
+ this.showDueDateInput = true;
+ await this.$nextTick();
+ this.$refs.dueDatePicker.calendar.show();
+ },
+ async clickShowStartDate() {
+ this.showStartDateInput = true;
+ await this.$nextTick();
+ this.$refs.startDatePicker.calendar.show();
+ },
+ handleStartDateInput() {
+ if (this.dirtyDueDate && this.dirtyStartDate > this.dirtyDueDate) {
+ this.dirtyDueDate = this.dirtyStartDate;
+ this.clickShowDueDate();
+ return;
+ }
+
+ this.updateDates();
+ },
+ updateDates() {
+ if (!this.canUpdate || this.datesUnchanged) {
+ return;
+ }
+
+ this.track('updated_dates');
+
+ this.isUpdating = true;
+
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemMutation,
+ variables: {
+ input: {
+ id: this.workItemId,
+ startAndDueDateWidget: {
+ dueDate: getDateWithUTC(this.dirtyDueDate),
+ startDate: getDateWithUTC(this.dirtyStartDate),
+ },
+ },
+ },
+ })
+ .then(({ data }) => {
+ if (data.workItemUpdate.errors.length) {
+ throw new Error(data.workItemUpdate.errors.join('; '));
+ }
+ })
+ .catch((error) => {
+ const message = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+ this.$emit('error', message);
+ Sentry.captureException(error);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="work-item-due-date"
+ :label="$options.i18n.dates"
+ :label-class="labelClass"
+ label-cols="3"
+ label-cols-lg="2"
+ >
+ <span v-if="isReadonlyWithNoDates" class="gl-text-gray-400 gl-ml-4">
+ {{ $options.i18n.none }}
+ </span>
+ <div v-else class="gl-display-flex gl-flex-wrap gl-gap-5">
+ <gl-form-group
+ class="gl-display-flex gl-align-items-center gl-m-0"
+ :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }"
+ :label="$options.i18n.startDate"
+ :label-for="$options.startDateInputId"
+ :label-sr-only="!showStartDateInput"
+ label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3"
+ >
+ <gl-datepicker
+ v-if="showStartDateInput"
+ ref="startDatePicker"
+ v-model="dirtyStartDate"
+ container="body"
+ :disabled="isDatepickerDisabled"
+ :input-id="$options.startDateInputId"
+ show-clear-button
+ :target="null"
+ @clear="clearStartDatePicker"
+ @close="handleStartDateInput"
+ />
+ <gl-button v-if="showStartDateButton" category="tertiary" @click="clickShowStartDate">
+ {{ $options.i18n.addStartDate }}
+ </gl-button>
+ </gl-form-group>
+ <gl-form-group
+ v-if="!isReadonlyWithOnlyStartDate"
+ class="gl-display-flex gl-align-items-center gl-m-0"
+ :class="{ 'gl-ml-n3': isReadonlyWithOnlyDueDate }"
+ :label="$options.i18n.dueDate"
+ :label-for="$options.dueDateInputId"
+ :label-sr-only="!showDueDateInput"
+ label-class="gl-flex-shrink-0 gl-text-secondary gl-font-weight-normal! gl-pb-0! gl-ml-4 gl-mr-3"
+ >
+ <gl-datepicker
+ v-if="showDueDateInput"
+ ref="dueDatePicker"
+ v-model="dirtyDueDate"
+ container="body"
+ :disabled="isDatepickerDisabled"
+ :input-id="$options.dueDateInputId"
+ :min-date="dirtyStartDate"
+ show-clear-button
+ :target="null"
+ @clear="clearDueDatePicker"
+ @close="updateDates"
+ />
+ <gl-button v-if="showDueDateButton" category="tertiary" @click="clickShowDueDate">
+ {{ $options.i18n.addDueDate }}
+ </gl-button>
+ </gl-form-group>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue
index 2ff7ba169ea..ce75cc98a75 100644
--- a/app/assets/javascripts/work_items/components/work_item_information.vue
+++ b/app/assets/javascripts/work_items/components/work_item_information.vue
@@ -5,16 +5,14 @@ import { helpPagePath } from '~/helpers/help_page_helper';
export default {
i18n: {
- learnTasksButtonText: s__('WorkItem|Learn about tasks'),
- workItemsText: s__('WorkItem|work items'),
+ learnTasksLinkText: s__('WorkItem|Learn about tasks.'),
tasksInformationTitle: s__('WorkItem|Introducing tasks'),
tasksInformationBody: s__(
- 'WorkItem|A task provides the ability to break down your work into smaller pieces tied to an issue. Tasks are the first items using our new %{workItemsLink} objects. Additional work item types will be coming soon.',
+ 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}',
),
},
helpPageLinks: {
tasksDocLinkPath: helpPagePath('user/tasks'),
- workItemsLinkPath: helpPagePath(`development/work_items`),
},
components: {
GlAlert,
@@ -38,16 +36,14 @@ export default {
v-if="showInfoBanner"
variant="tip"
:title="$options.i18n.tasksInformationTitle"
- :primary-button-link="$options.helpPageLinks.tasksDocLinkPath"
- :primary-button-text="$options.i18n.learnTasksButtonText"
data-testid="work-item-information"
class="gl-mt-3"
@dismiss="$emit('work-item-banner-dismissed')"
>
<gl-sprintf :message="$options.i18n.tasksInformationBody">
- <template #workItemsLink>
- <gl-link :href="$options.helpPageLinks.workItemsLinkPath">{{
- $options.i18n.workItemsText
+ <template #learnMoreLink>
+ <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{
+ $options.i18n.learnTasksLinkText
}}</gl-link>
</template>
></gl-sprintf
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 e73488bbd70..b8b5198be57 100644
--- a/app/assets/javascripts/work_items/components/work_item_labels.vue
+++ b/app/assets/javascripts/work_items/components/work_item_labels.vue
@@ -31,7 +31,6 @@ export default {
LabelItem,
},
mixins: [Tracking.mixin()],
- inject: ['fullPath'],
props: {
workItemId: {
type: String,
@@ -41,6 +40,10 @@ export default {
type: Boolean,
required: true,
},
+ fullPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -189,7 +192,7 @@ export default {
</script>
<template>
- <div class="form-row gl-mb-5 work-item-labels gl-relative">
+ <div class="form-row gl-mb-5 work-item-labels gl-relative gl-flex-nowrap">
<span
class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break"
data-testid="labels-title"
@@ -216,7 +219,7 @@ export default {
class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2"
data-testid="empty-state"
>
- <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span>
+ <span v-if="canUpdate" class="gl-ml-2">{{ __('Add labels') }}</span>
<span v-else class="gl-ml-2">{{ __('None') }}</span>
</div>
</template>
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 86f03583ea3..8f31b07b6a3 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,6 +1,6 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import { createApolloProvider } from '../../graphql/provider';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import WorkItemLinks from './work_item_links.vue';
Vue.use(GlToast);
@@ -16,18 +16,19 @@ export default function initWorkItemLinks() {
return;
}
- const { projectPath, wiHasIssueWeightsFeature } = workItemLinksRoot.dataset;
+ const { projectPath, wiHasIssueWeightsFeature, iid } = workItemLinksRoot.dataset;
// eslint-disable-next-line no-new
new Vue({
el: workItemLinksRoot,
name: 'WorkItemLinksRoot',
- apolloProvider: createApolloProvider(),
+ apolloProvider,
components: {
- workItemLinks: WorkItemLinks,
+ WorkItemLinks,
},
provide: {
projectPath,
+ iid,
fullPath: projectPath,
hasIssueWeightsFeature: wiHasIssueWeightsFeature,
},
diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
new file mode 100644
index 00000000000..34874908f9b
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+import { __ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue';
+
+import { STATE_OPEN } from '../../constants';
+import WorkItemLinksMenu from './work_item_links_menu.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ RichTimestampTooltip,
+ WorkItemLinksMenu,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ canUpdate: {
+ type: Boolean,
+ required: true,
+ },
+ issuableGid: {
+ type: String,
+ required: true,
+ },
+ childItem: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ isItemOpen() {
+ return this.childItem.state === STATE_OPEN;
+ },
+ iconClass() {
+ return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500';
+ },
+ iconName() {
+ return this.isItemOpen ? 'issue-open-m' : 'issue-close';
+ },
+ stateTimestamp() {
+ return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt;
+ },
+ stateTimestampTypeText() {
+ return this.isItemOpen ? __('Created') : __('Closed');
+ },
+ childPath() {
+ return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ 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 class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1">
+ <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon">
+ <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" />
+ </span>
+ <rich-timestamp-tooltip
+ :target="`stateIcon-${childItem.id}`"
+ :raw-timestamp="stateTimestamp"
+ :timestamp-type-text="stateTimestampTypeText"
+ />
+ <gl-icon
+ v-if="childItem.confidential"
+ v-gl-tooltip.top
+ name="eye-slash"
+ class="gl-mr-2 gl-text-orange-500"
+ data-testid="confidential-icon"
+ :aria-label="__('Confidential')"
+ :title="__('Confidential')"
+ />
+ <gl-button
+ :href="childPath"
+ category="tertiary"
+ variant="link"
+ class="gl-text-truncate gl-max-w-80 gl-text-black-normal!"
+ @click="$emit('click', childItem.id, $event)"
+ @mouseover="$emit('mouseover', childItem.id, $event)"
+ @mouseout="$emit('mouseout', childItem.id, $event)"
+ >
+ {{ childItem.title }}
+ </gl-button>
+ </div>
+ <div
+ v-if="canUpdate"
+ class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center"
+ >
+ <work-item-links-menu
+ :work-item-id="childItem.id"
+ :parent-work-item-id="issuableGid"
+ data-testid="links-menu"
+ @removeChild="$emit('remove', childItem.id)"
+ />
+ </div>
+ </div>
+</template>
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 534ebabee08..840fd910272 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
@@ -5,22 +5,17 @@ import { s__ } from '~/locale';
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 issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql';
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,
- WORK_ITEM_STATUS_TEXT,
- WIDGET_TYPE_HIERARCHY,
-} from '../../constants';
+import { WIDGET_ICONS, WORK_ITEM_STATUS_TEXT, 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 WorkItemLinkChild from './work_item_link_child.vue';
import WorkItemLinksForm from './work_item_links_form.vue';
-import WorkItemLinksMenu from './work_item_links_menu.vue';
export default {
components: {
@@ -28,14 +23,14 @@ export default {
GlIcon,
GlAlert,
GlLoadingIcon,
+ WorkItemLinkChild,
WorkItemLinksForm,
- WorkItemLinksMenu,
WorkItemDetailModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- inject: ['projectPath'],
+ inject: ['projectPath', 'iid'],
props: {
workItemId: {
type: String,
@@ -63,6 +58,18 @@ export default {
this.error = e.message || this.$options.i18n.fetchError;
},
},
+ parentIssue: {
+ query: issueConfidentialQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: String(this.iid),
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable;
+ },
+ },
},
data() {
return {
@@ -72,9 +79,13 @@ export default {
activeToast: null,
prefetchedWorkItem: null,
error: undefined,
+ parentIssue: null,
};
},
computed: {
+ confidential() {
+ return this.parentIssue?.confidential || this.workItem?.confidential || false;
+ },
children() {
return (
this.workItem?.widgets.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.children
@@ -84,9 +95,6 @@ export default {
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;
@@ -95,9 +103,7 @@ export default {
return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
},
toggleLabel() {
- return this.isOpen
- ? s__('WorkItem|Collapse child items')
- : s__('WorkItem|Expand child items');
+ return this.isOpen ? s__('WorkItem|Collapse tasks') : s__('WorkItem|Expand tasks');
},
issuableGid() {
return this.issuableId ? convertToGraphQLId(TYPE_WORK_ITEM, this.issuableId) : null;
@@ -112,22 +118,7 @@ export default {
return this.isLoading && this.children.length === 0 ? '...' : this.children.length;
},
},
- mounted() {
- SidebarEventHub.$on('confidentialityUpdated', this.refetchWorkItems);
- },
- destroyed() {
- SidebarEventHub.$off('confidentialityUpdated', this.refetchWorkItems);
- },
methods: {
- 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;
},
@@ -169,9 +160,6 @@ export default {
replace: true,
});
},
- childPath(childItemId) {
- return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(childItemId)}`;
- },
toggleChildFromCache(workItem, childId, store) {
const sourceData = store.readQuery({
query: getWorkItemLinksQuery,
@@ -242,14 +230,12 @@ export default {
},
},
i18n: {
- title: s__('WorkItem|Child items'),
- fetchError: s__(
- 'WorkItem|Something went wrong when fetching the items list. Please refresh this page.',
- ),
+ title: s__('WorkItem|Tasks'),
+ fetchError: s__('WorkItem|Something went wrong when fetching tasks. 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!',
+ 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.',
),
- addChildButtonLabel: s__('WorkItem|Add a task'),
+ addChildButtonLabel: s__('WorkItem|Add'),
},
WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK,
WORK_ITEM_STATUS_TEXT,
@@ -257,7 +243,10 @@ export default {
</script>
<template>
- <div class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10">
+ <div
+ class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-5"
+ data-testid="work-item-links"
+ >
<div
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 }"
@@ -319,48 +308,18 @@ export default {
@cancel="hideAddForm"
@addWorkItemChild="addChild"
/>
- <div
+ <work-item-link-child
v-for="child in children"
:key="child.id"
- 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 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-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>
+ :project-path="projectPath"
+ :can-update="canUpdate"
+ :issuable-gid="issuableGid"
+ :child-item="child"
+ @click="openChild"
+ @mouseover="prefetchWorkItem"
+ @mouseout="clearPrefetching"
+ @remove="removeChild"
+ />
<work-item-detail-modal
ref="modal"
:work-item-id="activeChildId"
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 080d4025cc3..3880ae25c8c 100644
--- a/app/assets/javascripts/work_items/components/work_item_state.vue
+++ b/app/assets/javascripts/work_items/components/work_item_state.vue
@@ -2,7 +2,8 @@
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
import {
- i18n,
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_UPDATING,
STATE_OPEN,
STATE_CLOSED,
STATE_EVENT_CLOSE,
@@ -93,7 +94,9 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- this.$emit('error', i18n.updateError);
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+
+ this.$emit('error', msg);
Sentry.captureException(error);
}
diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue
index cd5cc3894f6..c52a6854fad 100644
--- a/app/assets/javascripts/work_items/components/work_item_title.vue
+++ b/app/assets/javascripts/work_items/components/work_item_title.vue
@@ -1,7 +1,11 @@
<script>
import * as Sentry from '@sentry/browser';
import Tracking from '~/tracking';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
+import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_UPDATING,
+ TRACKING_CATEGORY_SHOW,
+} from '../constants';
import { getUpdateWorkItemMutation } from './update_work_item';
import ItemTitle from './item_title.vue';
@@ -78,7 +82,8 @@ export default {
throw new Error(errors[0]);
}
} catch (error) {
- this.$emit('error', i18n.updateError);
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
+ this.$emit('error', msg);
Sentry.captureException(error);
}
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
index fd914fa350b..31e75663055 100644
--- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue
+++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue
@@ -1,11 +1,14 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { WORK_ITEMS_TYPE_MAP } from '../constants';
export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
workItemType: {
type: String,
@@ -22,6 +25,11 @@ export default {
required: false,
default: '',
},
+ showTooltipOnHover: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
iconName() {
@@ -32,13 +40,21 @@ export default {
workItemTypeName() {
return WORK_ITEMS_TYPE_MAP[this.workItemType]?.name;
},
+ workItemTooltipTitle() {
+ return this.showTooltipOnHover ? this.workItemTypeName : '';
+ },
},
};
</script>
<template>
<span>
- <gl-icon :name="iconName" class="gl-mr-2" />
+ <gl-icon
+ v-gl-tooltip.hover="showTooltipOnHover"
+ :name="iconName"
+ :title="workItemTooltipTitle"
+ class="gl-mr-2 gl-text-gray-500"
+ />
<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
deleted file mode 100644
index b0ad7c97bb1..00000000000
--- a/app/assets/javascripts/work_items/components/work_item_weight.vue
+++ /dev/null
@@ -1,162 +0,0 @@
-<script>
-import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
-import * as Sentry from '@sentry/browser';
-import { __ } from '~/locale';
-import Tracking from '~/tracking';
-import { i18n, TRACKING_CATEGORY_SHOW } from '../constants';
-import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
-
-/* eslint-disable @gitlab/require-i18n-strings */
-const allowedKeys = [
- 'Alt',
- 'ArrowDown',
- 'ArrowLeft',
- 'ArrowRight',
- 'ArrowUp',
- 'Backspace',
- 'Control',
- 'Delete',
- 'End',
- 'Enter',
- 'Home',
- 'Meta',
- 'PageDown',
- 'PageUp',
- 'Tab',
- '0',
- '1',
- '2',
- '3',
- '4',
- '5',
- '6',
- '7',
- '8',
- '9',
-];
-/* eslint-enable @gitlab/require-i18n-strings */
-
-export default {
- inputId: 'weight-widget-input',
- components: {
- GlForm,
- GlFormGroup,
- GlFormInput,
- },
- mixins: [Tracking.mixin()],
- inject: ['hasIssueWeightsFeature'],
- props: {
- canUpdate: {
- type: Boolean,
- required: false,
- default: false,
- },
- weight: {
- type: Number,
- required: false,
- default: undefined,
- },
- workItemId: {
- type: String,
- required: true,
- },
- workItemType: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- isEditing: false,
- };
- },
- computed: {
- placeholder() {
- return this.canUpdate && this.isEditing ? __('Enter a number') : __('None');
- },
- tracking() {
- return {
- category: TRACKING_CATEGORY_SHOW,
- label: 'item_weight',
- property: `type_${this.workItemType}`,
- };
- },
- type() {
- return this.canUpdate && this.isEditing ? 'number' : 'text';
- },
- },
- methods: {
- blurInput() {
- this.$refs.input.$el.blur();
- },
- handleFocus() {
- this.isEditing = true;
- },
- handleKeydown(event) {
- if (!allowedKeys.includes(event.key)) {
- event.preventDefault();
- }
- },
- 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: 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);
- });
- },
- },
-};
-</script>
-
-<template>
- <gl-form v-if="hasIssueWeightsFeature" @submit.prevent="blurInput">
- <gl-form-group
- class="gl-align-items-center"
- :label="__('Weight')"
- :label-for="$options.inputId"
- label-class="gl-pb-0! gl-overflow-wrap-break"
- label-cols="3"
- label-cols-lg="2"
- >
- <gl-form-input
- :id="$options.inputId"
- ref="input"
- min="0"
- :placeholder="placeholder"
- :readonly="!canUpdate"
- size="sm"
- :type="type"
- :value="weight"
- @blur="updateWeight"
- @focus="handleFocus"
- @keydown="handleKeydown"
- @keydown.exact.esc.stop="blurInput"
- />
- </gl-form-group>
- </gl-form>
-</template>
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index a2aea3cd327..78219e62d01 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -1,4 +1,5 @@
-import { s__ } from '~/locale';
+import { s__, sprintf } from '~/locale';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
export const STATE_OPEN = 'OPEN';
export const STATE_CLOSED = 'CLOSED';
@@ -13,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
export const WIDGET_TYPE_LABELS = 'LABELS';
+export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';
@@ -31,6 +33,30 @@ export const i18n = {
),
};
+export const I18N_WORK_ITEM_ERROR_CREATING = s__(
+ 'WorkItem|Something went wrong when creating %{workItemType}. Please try again.',
+);
+export const I18N_WORK_ITEM_ERROR_UPDATING = s__(
+ 'WorkItem|Something went wrong while updating the %{workItemType}. Please try again.',
+);
+export const I18N_WORK_ITEM_ERROR_DELETING = s__(
+ 'WorkItem|Something went wrong when deleting the %{workItemType}. Please try again.',
+);
+export const I18N_WORK_ITEM_DELETE = s__('WorkItem|Delete %{workItemType}');
+export const I18N_WORK_ITEM_ARE_YOU_SURE_DELETE = s__(
+ 'WorkItem|Are you sure you want to delete the %{workItemType}? This action cannot be reversed.',
+);
+export const I18N_WORK_ITEM_DELETED = s__('WorkItem|%{workItemType} deleted');
+
+export const sprintfWorkItem = (msg, workItemTypeArg) => {
+ const workItemType = workItemTypeArg || s__('WorkItem|Work item');
+ return capitalizeFirstCharacter(
+ sprintf(msg, {
+ workItemType: workItemType.toLocaleLowerCase(),
+ }),
+ );
+};
+
export const WIDGET_ICONS = {
TASK: 'issue-type-task',
};
@@ -62,3 +88,5 @@ export const WORK_ITEMS_TYPE_MAP = {
name: s__('WorkItem|Requirements'),
},
};
+
+export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10;
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 4cc23fa0071..1228c876a55 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,4 +1,4 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation createWorkItem($input: WorkItemCreateInput!) {
workItemCreate(input: $input) {
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 1f98cd4fa2b..ccfe62cc585 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 "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./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 790b8e60b6a..43c92cf89ec 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 "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
mutation localUpdateWorkItem($input: LocalUpdateWorkItemInput) {
localUpdateWorkItem(input: $input) @client {
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 0a887fcfc00..25eb8099251 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 "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./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 fad5a9fa5bc..ad861a60d15 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 "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./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
deleted file mode 100644
index 6a94c96b347..00000000000
--- a/app/assets/javascripts/work_items/graphql/update_work_item_widgets.mutation.graphql
+++ /dev/null
@@ -1,10 +0,0 @@
-#import "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
-
-mutation workItemUpdateWidgets($input: WorkItemUpdateWidgetsInput!) {
- workItemUpdateWidgets(input: $input) {
- workItem {
- ...WorkItem
- }
- errors
- }
-}
diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
index e8ef27ec778..f4c77ed2ec0 100644
--- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql
@@ -1,4 +1,5 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "ee_else_ce/work_items/graphql/work_item_widgets.fragment.graphql"
fragment WorkItem on WorkItem {
id
@@ -6,6 +7,12 @@ fragment WorkItem on WorkItem {
state
description
confidential
+ createdAt
+ closedAt
+ project {
+ id
+ fullPath
+ }
workItemType {
id
name
@@ -16,34 +23,6 @@ fragment WorkItem on WorkItem {
updateWorkItem
}
widgets {
- ... on WorkItemWidgetDescription {
- type
- description
- descriptionHtml
- }
- ... on WorkItemWidgetAssignees {
- type
- allowsMultipleAssignees
- canInviteMembers
- assignees {
- nodes {
- ...User
- }
- }
- }
- ... on WorkItemWidgetHierarchy {
- type
- parent {
- id
- iid
- title
- confidential
- }
- children {
- nodes {
- id
- }
- }
- }
+ ...WorkItemWidgets
}
}
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 a9f7b714551..276061af193 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 "ee_else_ce/work_items/graphql/work_item.fragment.graphql"
+#import "./work_item.fragment.graphql"
query workItem($id: WorkItemID!) {
workItem(id: $id) {
diff --git a/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
new file mode 100644
index 00000000000..7e045fdf431
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_dates.subscription.graphql
@@ -0,0 +1,13 @@
+subscription issuableDatesUpdated($issuableId: IssuableID!) {
+ issuableDatesUpdated(issuableId: $issuableId) {
+ ... on WorkItem {
+ id
+ widgets {
+ ... on WorkItemWidgetStartAndDueDate {
+ dueDate
+ startDate
+ }
+ }
+ }
+ }
+}
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 df62ca1c143..7b63d9c7ca3 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
@@ -1,4 +1,4 @@
-query workItemQuery($id: WorkItemID!) {
+query workItemLinksQuery($id: WorkItemID!) {
workItem(id: $id) {
id
workItemType {
@@ -26,6 +26,8 @@ query workItemQuery($id: WorkItemID!) {
}
title
state
+ createdAt
+ closedAt
}
}
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
new file mode 100644
index 00000000000..3005069f59a
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -0,0 +1,36 @@
+fragment WorkItemWidgets on WorkItemWidget {
+ ... on WorkItemWidgetDescription {
+ type
+ description
+ descriptionHtml
+ }
+ ... on WorkItemWidgetAssignees {
+ type
+ allowsMultipleAssignees
+ canInviteMembers
+ assignees {
+ nodes {
+ ...User
+ }
+ }
+ }
+ ... on WorkItemWidgetStartAndDueDate {
+ type
+ dueDate
+ startDate
+ }
+ ... on WorkItemWidgetHierarchy {
+ type
+ parent {
+ id
+ iid
+ title
+ confidential
+ }
+ children {
+ nodes {
+ id
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
index 6437df597b4..bb4c7052238 100644
--- a/app/assets/javascripts/work_items/index.js
+++ b/app/assets/javascripts/work_items/index.js
@@ -1,8 +1,8 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { apolloProvider } from '~/graphql_shared/issuable_client';
import App from './components/app.vue';
import { createRouter } from './router';
-import { createApolloProvider } from './graphql/provider';
export const initWorkItemsRoot = () => {
const el = document.querySelector('#js-work-items');
@@ -12,7 +12,7 @@ export const initWorkItemsRoot = () => {
el,
name: 'WorkItemsRoot',
router: createRouter(el.dataset.fullPath),
- apolloProvider: createApolloProvider(),
+ apolloProvider,
provide: {
fullPath,
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue
index 482da5419c6..3b7257591e2 100644
--- a/app/assets/javascripts/work_items/pages/create_work_item.vue
+++ b/app/assets/javascripts/work_items/pages/create_work_item.vue
@@ -1,7 +1,9 @@
<script>
import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { getPreferredLocales, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql';
import createWorkItemFromTaskMutation from '../graphql/create_work_item_from_task.mutation.graphql';
@@ -10,7 +12,6 @@ import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.
import ItemTitle from '../components/item_title.vue';
export default {
- createErrorText: s__('WorkItem|Something went wrong when creating a task. Please try again'),
fetchTypesErrorText: s__(
'WorkItem|Something went wrong when fetching work item types. Please try again',
),
@@ -69,7 +70,7 @@ export default {
update(data) {
return data.workspace?.workItemTypes?.nodes.map((node) => ({
value: node.id,
- text: node.name,
+ text: capitalizeFirstCharacter(node.name.toLocaleLowerCase(getPreferredLocales()[0])),
}));
},
error() {
@@ -78,15 +79,19 @@ export default {
},
},
computed: {
- dropdownButtonText() {
- return this.selectedWorkItemType?.name || s__('WorkItem|Type');
- },
formOptions() {
return [{ value: null, text: s__('WorkItem|Select type') }, ...this.workItemTypes];
},
isButtonDisabled() {
return this.title.trim().length === 0 || !this.selectedWorkItemType;
},
+ createErrorText() {
+ const workItemType = this.workItemTypes.find(
+ (item) => item.value === this.selectedWorkItemType,
+ )?.text;
+
+ return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType);
+ },
},
methods: {
async createWorkItem() {
@@ -128,7 +133,7 @@ export default {
} = response;
this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } });
} catch {
- this.error = this.$options.createErrorText;
+ this.error = this.createErrorText;
}
},
async createWorkItemFromTask() {
@@ -150,7 +155,7 @@ export default {
});
this.$emit('onCreate', data.workItemCreateFromTask.workItem.descriptionHtml);
} catch {
- this.error = this.$options.createErrorText;
+ this.error = this.createErrorText;
}
},
handleTitleInput(title) {
diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue
index e9840889bdb..a2cacd8bd7a 100644
--- a/app/assets/javascripts/work_items/pages/work_item_root.vue
+++ b/app/assets/javascripts/work_items/pages/work_item_root.vue
@@ -3,9 +3,13 @@ import { GlAlert } from '@gitlab/ui';
import { TYPE_WORK_ITEM } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { visitUrl } from '~/lib/utils/url_utility';
-import { s__ } from '~/locale';
import ZenMode from '~/zen_mode';
import WorkItemDetail from '../components/work_item_detail.vue';
+import {
+ sprintfWorkItem,
+ I18N_WORK_ITEM_ERROR_DELETING,
+ I18N_WORK_ITEM_DELETED,
+} from '../constants';
import deleteWorkItemMutation from '../graphql/delete_work_item.mutation.graphql';
export default {
@@ -34,7 +38,7 @@ export default {
this.ZenMode = new ZenMode();
},
methods: {
- deleteWorkItem() {
+ deleteWorkItem(workItemType) {
this.$apollo
.mutate({
mutation: deleteWorkItemMutation,
@@ -53,13 +57,12 @@ export default {
throw new Error(workItemDelete.errors[0]);
}
- this.$toast.show(s__('WorkItem|Work item deleted'));
+ const msg = sprintfWorkItem(I18N_WORK_ITEM_DELETED, workItemType);
+ this.$toast.show(msg);
visitUrl(this.issuesListPath);
})
.catch((e) => {
- this.error =
- e.message ||
- s__('WorkItem|Something went wrong when deleting the work item. Please try again.');
+ this.error = e.message || sprintfWorkItem(I18N_WORK_ITEM_ERROR_DELETING, workItemType);
});
},
},
@@ -69,6 +72,6 @@ export default {
<template>
<div>
<gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert>
- <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem" />
+ <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" />
</div>
</template>
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 004dc22c9b8..9e81e1d4771 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -4,7 +4,6 @@
@import './pages/commits';
@import './pages/deploy_keys';
@import './pages/detail_page';
-@import './pages/editor';
@import './pages/environment_logs';
@import './pages/events';
@import './pages/groups';
@@ -21,7 +20,6 @@
@import './pages/notifications';
@import './pages/pipelines';
@import './pages/profile';
-@import './pages/profiles/preferences';
@import './pages/projects';
@import './pages/prometheus';
@import './pages/registry';
diff --git a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
index f6be241d644..324c23022ca 100644
--- a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
+++ b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss
@@ -7,14 +7,14 @@
@return $string;
}
-@mixin dropzone-background($stroke-color, $stroke-width: 4, $stroke-linecap: 'butt') {
- background-image: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='#{encodecolor($stroke-color)}' stroke-width='#{$stroke-width}' stroke-dasharray='6%2c4' stroke-dashoffset='0' stroke-linecap='#{encodecolor($stroke-linecap)}'/%3e%3c/svg%3e");
+@mixin dropzone-background($stroke-color, $stroke-width: 4) {
+ background-image: url("data:image/svg+xml, %3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='#{$border-radius-default}' ry='#{$border-radius-default}' stroke='#{encodecolor($stroke-color)}' stroke-width='#{$stroke-width}' stroke-dasharray='6%2c4' stroke-dashoffset='0' stroke-linecap='butt' /%3e %3c/svg%3e");
}
.upload-dropzone-border {
border: 0;
- @include dropzone-background($gray-400, 2, 'round');
- border-radius: 8px;
+ @include dropzone-background($gray-400, 2);
+ border-radius: $border-radius-default;
}
.upload-dropzone-card {
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index ad0036df607..34c7ffa58fe 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -149,7 +149,7 @@
margin: $sidebar-top-item-tb-margin $sidebar-top-item-lr-margin;
&:hover {
- background-color: $indigo-900-alpha-008;
+ background-color: $nav-active-bg;
}
}
@@ -275,7 +275,7 @@
&:not(.fly-out-top-item) {
> a:not(.has-sub-items) {
- background-color: $indigo-900-alpha-008;
+ background-color: $nav-active-bg;
}
}
}
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 904d041fdc9..8d1fb5eb55f 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -34,19 +34,28 @@
@media (min-width: map-get($grid-breakpoints, md)) {
// The `+11` is to ensure the file header border shows when scrolled -
// the bottom of the compare-versions header and the top of the file header
- $mr-file-header-top: calc(#{$header-height} + #{$mr-tabs-height});
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
+ --top: var(--initial-top);
position: -webkit-sticky;
position: sticky;
- top: $mr-file-header-top;
+ top: var(--top);
z-index: 120;
+ &.is-sidebar-moved {
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px});
+ }
+
.with-system-header & {
- top: calc(#{$mr-file-header-top} + #{$system-header-height});
+ --top: calc(var(--initial-top) + #{$system-header-height});
}
.with-system-header.with-performance-bar & {
- top: calc(#{$mr-file-header-top} + #{$system-header-height} + #{$performance-bar-height});
+ --top: calc(var(--initial-top) + #{$system-header-height} + #{$performance-bar-height});
+ }
+
+ .with-performance-bar & {
+ top: calc(var(--initial-top) + #{$performance-bar-height});
}
&::before {
@@ -60,10 +69,6 @@
pointer-events: none;
}
- .with-performance-bar & {
- top: calc(#{$mr-file-header-top} + #{$performance-bar-height});
- }
-
&.is-commit {
top: calc(#{$header-height} + #{$commit-stat-summary-height});
@@ -788,11 +793,13 @@ table.code {
}
.diff-comments-more-count,
-.diff-notes-collapse {
+.diff-notes-collapse,
+.diff-codequality-collapse {
@include avatar-counter(50%);
}
-.diff-notes-collapse {
+.diff-notes-collapse,
+.diff-codequality-collapse {
border: 0;
border-radius: 50%;
padding: 0;
@@ -977,7 +984,8 @@ table.code {
position: relative;
}
- .diff-notes-collapse {
+ .diff-notes-collapse,
+ .diff-codequality-collapse {
position: absolute;
left: -12px;
}
@@ -1107,6 +1115,7 @@ table.code {
}
.diff-notes-collapse,
+ .diff-codequality-collapse,
.note,
.discussion-reply-holder {
display: none;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index b980d7fdaa7..07516275e58 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -39,8 +39,8 @@
.file-title {
position: relative;
- background-color: $gray-light;
- border-bottom: 1px solid $border-color;
+ background-color: var(--gray-10, $gray-10);
+ border-bottom: 1px solid var(--border-color, $border-color);
margin: 0;
text-align: left;
padding: 10px $gl-padding;
@@ -471,11 +471,6 @@ span.idiff {
}
}
-.jupyter-notebook-scrolled {
- overflow-y: auto;
- max-height: 20rem;
-}
-
#js-openapi-viewer {
pre.version,
code {
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 37f92d3cf3d..ed41d10f3b2 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -48,7 +48,7 @@
opacity: 0;
}
- a {
+ a:not(.canary-badge) {
display: flex;
align-items: center;
padding: 2px 8px;
@@ -61,10 +61,6 @@
}
}
- .canary-badge {
- margin-left: -8px;
- }
-
.project-item-select {
right: auto;
left: 0;
@@ -564,15 +560,11 @@
}
.frequent-items-list-item-container > a:hover {
- background-color: $nav-active-bg;
+ background-color: $nav-active-bg !important;
}
}
.top-nav-toggle {
- .dropdown-icon {
- @include gl-mr-3;
- }
-
.dropdown-chevron {
top: 0;
}
@@ -581,7 +573,7 @@
.top-nav-menu-item {
&.active,
&:hover {
- background-color: $nav-active-bg;
+ background-color: $nav-active-bg !important;
}
.gl-icon {
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index ab426f388c6..a63ce66e681 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -31,8 +31,7 @@
width: 100%;
padding-left: 10px;
padding-right: 10px;
- white-space: break-spaces;
- word-break: break-word;
+ white-space: pre;
&:empty::before {
content: '\200b';
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index fb05f8575ef..02b76b89482 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -7,9 +7,6 @@ html {
}
body {
- // Improves readability for dyslexic users; supported only in Chrome/Safari so far
- text-decoration-skip: ink;
-
&.navless {
background-color: $white !important;
}
@@ -139,6 +136,13 @@ body {
}
}
+.gl--flex-full {
+ @include gl-display-flex;
+ @include gl-align-items-stretch;
+ @include gl-overflow-hidden;
+}
+
+
.with-performance-bar .layout-page {
margin-top: calc(#{$header-height} + #{$performance-bar-height});
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 7522f791b8e..b623f18c4ae 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -104,7 +104,6 @@
li.md-header-toolbar {
margin-left: auto;
visibility: hidden;
- padding-bottom: $gl-padding-8;
&.active {
visibility: visible;
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 92ca8654287..7e0a601223d 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -13,9 +13,9 @@
a,
button {
- padding: $gl-padding-8;
- font-size: 14px;
- line-height: 28px;
+ padding: $gl-spacing-scale-5 $gl-spacing-scale-4;
+ font-size: $gl-font-size;
+ line-height: $gl-line-height-16;
color: $gl-text-color-secondary;
border: 0;
white-space: nowrap;
@@ -88,10 +88,6 @@
float: left;
}
- li a {
- padding: 16px 15px 11px;
- }
-
/* Small devices (phones, 768px and lower) */
@include media-breakpoint-down(sm) {
width: 100%;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index ae0f18753ad..7878e08e549 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -208,7 +208,7 @@
position: relative;
top: -3px;
padding: $gl-padding-4 0;
- background-color: $gray-light;
+ background-color: $body-bg;
&.opened {
color: $green-500;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 031f5dc45ca..e79fb843967 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -333,7 +333,7 @@
font-size: 13px;
line-height: 1.6em;
overflow-x: auto;
- border-radius: 2px;
+ border-radius: $border-radius-default;
// Multi-line code blocks should scroll horizontally
code {
@@ -427,10 +427,11 @@
padding-inline-start: 28px;
margin-inline-start: 0 !important;
+ > p > input.task-list-item-checkbox,
> input.task-list-item-checkbox {
position: absolute;
- inset-inline-start: 8px;
- top: 5px;
+ inset-inline-start: $gl-padding-8;
+ inset-block-start: 5px;
}
}
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index e9ad930ef2b..bd32a817d5d 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -364,7 +364,7 @@ $well-expand-item: #e8f2f7 !default;
$well-inner-border: #eef0f2 !default;
$well-light-border: #f1f1f1;
$well-light-text-color: #5b6169;
-$nav-active-bg: var(--nav-active-bg, rgba($black, 0.08)) !important;
+$nav-active-bg: rgba($black, 0.08);
/*
* Text
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 197073412e8..d45bc865da5 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -5,34 +5,6 @@
pointer-events: none;
}
-.dropdown-projects {
- .dropdown-content {
- max-height: 200px;
- }
-}
-
-.issue-board-dropdown-content {
- margin: 0;
- padding: $gl-padding-4 $gl-padding $gl-padding;
- border-bottom: 0;
- color: var(--gray-500, $gray-500);
-}
-
-[data-page$='epic_boards:index'],
-[data-page$='epic_boards:show'],
-.issue-boards-page {
- .content-wrapper {
- padding-bottom: 0;
- }
-}
-
-[data-page$='epic_boards:index'],
-[data-page$='epic_boards:show'] {
- .filtered-search-wrapper {
- display: none !important;
- }
-}
-
.boards-app {
@include media-breakpoint-up(sm) {
transition: width $gl-transition-duration-medium;
@@ -87,33 +59,7 @@
width: 400px;
}
- .board-title-caret {
- border-radius: $border-radius-default;
- line-height: $gl-spacing-scale-5;
-
- &.btn svg {
- top: 0;
- }
-
- &:hover {
- background-color: var(--gray-50, $gray-50);
- transition: background-color 0.1s linear;
- }
- }
-
- &:not(.is-collapsed) {
- .board-title-caret {
- margin-right: $gl-padding-4;
- }
- }
-
&.is-collapsed {
- width: 50px;
-
- .board-title-caret {
- margin-top: 1px;
- }
-
.board-title-text > span,
.issue-count-badge > span {
height: 16px;
@@ -124,17 +70,11 @@
// rotated element has square dimensions so it won't overlap with its siblings.
margin: calc(50% - 8px) 0;
- transform: rotate(90deg);
transform-origin: center;
}
}
}
-.board-inner {
- font-size: $issue-boards-font-size;
- background: var(--gray-50, $gray-50);
-}
-
// to highlight columns we have animated pulse of box-shadow
// we don't want to actually animate the box-shadow property
// because that causes costly repaints. Instead we can add a
@@ -169,103 +109,45 @@
}
}
-.board-title {
- height: 3rem;
-
- .max-issue-size::before {
- content: '/';
- }
-}
-
-.board-list-component {
- min-height: 0; // firefox fix
-}
-
-.board-list {
- overflow-y: auto;
- overflow-x: hidden;
-}
-
-.board-list-loading {
- margin-top: 10px;
- font-size: (26px / $issue-boards-font-size) * 1em;
-}
-
.board-card {
background: var(--gray-10, $white);
box-shadow: 0 1px 2px rgba(var(--black, $black), 0.1);
- line-height: $gl-padding;
- list-style: none;
- position: relative;
-
- &:not(:last-child) {
- margin-bottom: $gl-padding-8;
- }
-
- &.is-active,
- &.is-active .board-card-assignee:hover a {
- background-color: var(--blue-50, $blue-50);
- }
-
- &.multi-select {
- border-color: var(--blue-200, $blue-200);
- background-color: var(--blue-50, $blue-50);
- }
-
- &.sortable-chosen {
- box-shadow: 0 2px 4px 0 rgba($black, 0.16);
- }
- .gl-label {
- margin-top: 4px;
- margin-right: 4px;
+ &:last-child {
+ @include gl-mb-0;
}
- .confidential-icon,
- .hidden-icon {
- color: var(--orange-500, $orange-500);
- cursor: help;
+ .move-to-position {
+ visibility: hidden;
}
- .issue-blocked-icon {
- color: var(--red-500, $red-500);
+ &:hover .move-to-position {
+ visibility: visible;
}
- @include media-breakpoint-down(md) {
- padding: $gl-padding-8;
+ @include media-breakpoint-down(sm) {
+ .move-to-position {
+ visibility: visible;
+ }
}
}
.board-card-title {
- @include overflow-break-word();
- font-size: 1em;
+ width: 95%;
a {
- color: var(--gray-900, $gray-900);
- }
-
- @include media-breakpoint-down(md) {
- font-size: $label-font-size;
+ @include media-breakpoint-down(md) {
+ font-size: $gl-font-size-sm;
+ }
}
}
.board-card-assignee {
- margin-top: -$gl-padding-4;
- margin-bottom: -$gl-padding-4;
-
.avatar-counter {
- vertical-align: middle;
- line-height: $gl-padding-24;
min-width: $gl-padding-24;
height: $gl-padding-24;
border-radius: $gl-padding-24;
- background-color: var(--gray-400, $gray-400);
font-size: $gl-font-size-xs;
- cursor: help;
- font-weight: $gl-font-weight-bold;
- margin-left: -$gl-padding-4;
- border: 0;
- padding: 0 $gl-padding-4;
@include media-breakpoint-down(md) {
min-width: auto;
@@ -275,12 +157,8 @@
}
}
- img {
- vertical-align: top;
- }
-
.user-avatar-link:not(:only-child) {
- margin-left: -$gl-padding-4;
+ margin-left: -$gl-padding;
&:nth-of-type(1) {
z-index: 2;
@@ -299,89 +177,26 @@
}
@include media-breakpoint-down(md) {
- margin-top: 0;
- margin-bottom: 0;
+ margin-bottom: 0 !important;
}
}
.board-card-number {
- font-size: $gl-font-size-xs;
- color: var(--gray-500, $gray-500);
-
- @include media-breakpoint-up(md) {
- font-size: $label-font-size;
+ @include media-breakpoint-down(md) {
+ font-size: $gl-font-size-sm;
}
}
.board-list-count {
- padding: 10px 0;
- color: var(--gray-500, $gray-500);
font-size: 13px;
}
-.board-new-issue-form {
- z-index: 4;
- margin: 5px;
-}
-
-.right-sidebar.boards-sidebar {
- .gutter-toggle {
- bottom: 15px;
- width: 22px;
- padding-left: $gl-padding-32;
-
- svg {
- position: absolute;
- top: 50%;
- right: 0;
- margin-top: (-11px / 2);
- height: $gl-font-size-12;
- width: $gl-font-size-12;
- }
- }
-
- .issuable-header-text {
- @include overflow-break-word();
- padding-right: 35px;
- }
-}
-
-.right-sidebar.right-sidebar-expanded {
- &.boards-sidebar-slide-enter-active,
- &.boards-sidebar-slide-leave-active {
- transition: width $gl-transition-duration-medium, padding $gl-transition-duration-medium;
- }
-
- &.boards-sidebar-slide-enter,
- &.boards-sidebar-slide-leave-active {
- width: 0;
- padding-left: 0;
- padding-right: 0;
- }
-}
-
.board-card-info {
- color: var(--gray-500, $gray-500);
- white-space: nowrap;
- margin-right: $gl-padding-8;
-
- &:not(.board-card-weight) {
- cursor: help;
- }
-
- &.board-card-weight {
- color: var(--gray-500, $gray-500);
- cursor: pointer;
-
- &:hover {
- color: initial;
- text-decoration: underline;
- }
+ &.board-card-weight:hover {
+ color: initial;
}
.board-card-info-icon {
- color: var(--gray-500, $gray-500);
- margin-right: $gl-padding-4;
vertical-align: text-top;
}
@@ -394,15 +209,6 @@
cursor: help;
}
-.board-labels-toggle-wrapper,
-.board-swimlanes-toggle-wrapper {
- /**
- * Make the wrapper the same height as a button so it aligns properly when the
- * filtered-search-box input element increases in size on Linux smaller breakpoints
- */
- height: $input-height;
-}
-
.issue-boards-content:not(.breadcrumbs) {
isolation: isolate;
}
@@ -422,7 +228,6 @@
.boards-list {
height: calc(100vh - #{$issue-boards-filter-height});
- overflow-x: scroll;
}
.boards-sidebar {
@@ -433,15 +238,7 @@
.boards-sidebar {
.sidebar-collapsed-icon {
- display: none;
- }
-
- .gl-drawer-header {
- align-items: flex-start;
- }
-
- .labels-select-wrapper.is-embedded .labels-select-wrapper.is-embedded {
- width: auto;
+ @include gl-display-none;
}
.show.dropdown .dropdown-menu {
@@ -449,10 +246,6 @@
}
}
-.board-header-collapsed-info-icon:hover {
- color: var(--gray-900, $gray-900);
-}
-
.board-card-skeleton {
height: 110px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/page_bundles/editor.scss
index c177d0b74a2..b7b698b2128 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/page_bundles/editor.scss
@@ -1,11 +1,13 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
.file-editor {
.nav-links {
- border-top: 1px solid $border-color;
- border-right: 1px solid $border-color;
- border-left: 1px solid $border-color;
+ border-top: 1px solid var(--border-color, $border-color);
+ border-right: 1px solid var(--border-color, $border-color);
+ border-left: 1px solid var(--border-color, $border-color);
border-bottom: 0;
border-radius: $border-radius-small $border-radius-small 0 0;
- background: $gray-normal;
+ background: var(--gray-50, $gray-50);
}
#editor,
@@ -23,10 +25,6 @@
}
}
- .ace_gutter-cell {
- background-color: $gray-light;
- }
-
.cancel-btn {
color: $red-600;
@@ -40,9 +38,9 @@
}
.editor-ref {
- background: $gray-light;
+ background: var(--gray-10, $gray-10);
padding-right: $gl-padding;
- border-right: 1px solid $border-color;
+ border-right: 1px solid var(--border-color, $border-color);
display: block;
float: left;
margin-right: 10px;
diff --git a/app/assets/stylesheets/page_bundles/group.scss b/app/assets/stylesheets/page_bundles/group.scss
index 71dbb855103..5086cdbf9bc 100644
--- a/app/assets/stylesheets/page_bundles/group.scss
+++ b/app/assets/stylesheets/page_bundles/group.scss
@@ -1,35 +1,16 @@
@import 'page_bundles/mixins_and_variables_and_functions';
.group-home-panel {
- margin-top: $gl-padding;
- margin-bottom: $gl-padding;
-
.home-panel-avatar {
width: $home-panel-title-row-height;
height: $home-panel-title-row-height;
- flex-shrink: 0;
flex-basis: $home-panel-title-row-height;
}
.home-panel-title {
- font-size: 20px;
- line-height: $gl-line-height-24;
- font-weight: bold;
-
.icon {
vertical-align: -1px;
}
-
- .home-panel-topic-list {
- font-size: $gl-font-size;
- font-weight: $gl-font-weight-normal;
-
- .icon {
- position: relative;
- top: 3px;
- margin-right: $gl-padding-4;
- }
- }
}
.home-panel-title-row {
@@ -52,7 +33,7 @@
line-height: $gl-font-size-large;
}
- .home-panel-topic-list,
+
.home-panel-metadata {
font-size: $gl-font-size-small;
}
@@ -60,8 +41,6 @@
}
.home-panel-metadata {
- font-weight: normal;
- font-size: 14px;
line-height: $gl-btn-line-height;
}
diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss
index c664e0a734e..26d694f7421 100644
--- a/app/assets/stylesheets/page_bundles/issues_show.scss
+++ b/app/assets/stylesheets/page_bundles/issues_show.scss
@@ -1,77 +1,24 @@
@import 'mixins_and_variables_and_functions';
.description {
- ul,
- ol {
- /* We're changing list-style-position to inside because the default of
- * outside doesn't move negative margin to the left of the bullet. */
- list-style-position: inside;
- }
-
li {
position: relative;
- /* In the browser, the li element comes after (to the right of) the bullet point, so hovering
- * over the left of the bullet point doesn't trigger a row hover. To trigger hovering on the
- * left, we're applying negative margin here to shift the li element left. */
- margin-inline-start: -1rem;
- padding-inline-start: 2.5rem;
+ margin-inline-start: 2.25rem;
.drag-icon {
position: absolute;
inset-block-start: 0.3rem;
- inset-inline-start: 1rem;
- }
-
- /* The inside bullet aligns itself to the bottom, which we see when text to the right of
- * a multi-line list item wraps. We fix this by aligning it to the top, and excluding
- * other elements. Targeting ::marker doesn't seem to work, instead we exclude custom elements
- * or anything with a class */
- > *:not(gl-emoji, code, [class]) {
- vertical-align: top;
- }
-
- /* The inside bullet is treated like an element inside the li element, so when we have a
- * multi-paragraph list item, the text doesn't start on the right of the bullet because
- * it is a block level p element. We make it inline to fix this. */
- > p:first-of-type {
- display: inline-block;
- max-width: calc(100% - 1.5rem);
- }
-
- /* We fix the other paragraphs not indenting to the
- * right of the bullet due to the inside bullet. */
- p ~ a,
- p ~ blockquote,
- p ~ code,
- p ~ details,
- p ~ dl,
- p ~ h1,
- p ~ h2,
- p ~ h3,
- p ~ h4,
- p ~ h5,
- p ~ h6,
- p ~ hr,
- p ~ ol,
- p ~ p,
- p ~ table:not(.code), /* We need :not(.code) to override typography.scss */
- p ~ ul,
- p ~ .markdown-code-block {
- margin-inline-start: 1rem;
+ inset-inline-start: -2.3rem;
+ padding-inline-end: 1rem;
+ width: 2rem;
}
}
- ul.task-list {
- > li.task-list-item {
- /* We're using !important to override the same selector in typography.scss */
- margin-inline-start: -1rem !important;
- padding-inline-start: 2.5rem;
+ ul.task-list > li.task-list-item {
+ margin-inline-start: 0.5rem !important; /* Override typography.scss */
- > input.task-list-item-checkbox {
- position: static;
- vertical-align: middle;
- margin-block-start: -2px;
- }
+ > .drag-icon {
+ inset-inline-start: -0.6rem;
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index b7a75884425..463c8f74342 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -41,17 +41,21 @@ $tabs-holder-z-index: 250;
// If they don't match, the file tree and the diff files stick
// to the top at different heights, which is a bad-looking defect
$diff-file-header-top: 11px;
- $top-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
+ --initial-pos: calc(#{$header-height} + #{$mr-tabs-height} + #{$diff-file-header-top});
+ --top-pos: var(--initial-pos);
position: -webkit-sticky;
position: sticky;
- // Unitless zero values are not allowed in calculations
- top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
- max-height: calc(100vh - #{$top-pos} - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
+ top: var(--top-pos);
+ max-height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px));
.drag-handle {
bottom: 16px;
}
+
+ &.is-sidebar-moved {
+ --top-pos: calc(var(--initial-pos) + 26px);
+ }
}
.tree-list-holder {
@@ -196,12 +200,8 @@ $tabs-holder-z-index: 250;
background-color: var(--gray-50, $gray-50);
}
-.mr-conflict-loader {
- max-width: 334px;
-
- > svg {
- vertical-align: middle;
- }
+.mr-widget-body-loading svg {
+ vertical-align: middle;
}
.mr-info-list {
@@ -398,12 +398,6 @@ $tabs-holder-z-index: 250;
display: block;
}
- .mr-widget-pipeline-graph {
- .dropdown-menu {
- z-index: $zindex-dropdown-menu;
- }
- }
-
.normal {
flex: 1;
flex-basis: auto;
@@ -440,7 +434,7 @@ $tabs-holder-z-index: 250;
.mr-widget-body {
&:not(.mr-widget-body-line-height-1) {
- line-height: 28px;
+ line-height: 24px;
}
@include clearfix;
@@ -475,12 +469,6 @@ $tabs-holder-z-index: 250;
margin: 0 0 0 10px;
}
- .bold {
- font-weight: $gl-font-weight-bold;
- color: var(--gray-600, $gray-600);
- margin-left: 10px;
- }
-
.state-label {
font-weight: $gl-font-weight-bold;
padding-right: 10px;
@@ -490,11 +478,6 @@ $tabs-holder-z-index: 250;
color: var(--red-500, $red-500);
}
- .spacing,
- .bold {
- vertical-align: middle;
- }
-
.dropdown-menu {
li a {
padding: 5px;
@@ -621,8 +604,8 @@ $tabs-holder-z-index: 250;
.mr-widget-extension-icon::before {
@include gl-content-empty;
@include gl-absolute;
- @include gl-left-0;
- @include gl-top-0;
+ @include gl-left-50p;
+ @include gl-top-half;
@include gl-opacity-3;
@include gl-border-solid;
@include gl-border-4;
@@ -630,24 +613,20 @@ $tabs-holder-z-index: 250;
width: 24px;
height: 24px;
+ transform: translate(-50%, -50%);
}
.mr-widget-extension-icon::after {
@include gl-content-empty;
@include gl-absolute;
@include gl-rounded-full;
+ @include gl-left-50p;
+ @include gl-top-half;
- top: 4px;
- left: 4px;
width: 16px;
height: 16px;
- border: 4px solid currentColor;
-}
-
-.mr-widget-extension-icon svg {
- position: relative;
- top: 2px;
- left: 2px;
+ border: 4px solid;
+ transform: translate(-50%, -50%);
}
.mr-widget-heading {
@@ -777,6 +756,7 @@ $tabs-holder-z-index: 250;
&.show .dropdown-menu {
width: calc(100vw - 20px);
max-width: 650px;
+ max-height: calc(100vh - 50px);
.gl-new-dropdown-inner {
max-height: none !important;
@@ -818,8 +798,7 @@ $tabs-holder-z-index: 250;
}
.md-preview-holder {
- max-height: 180px;
- height: 180px;
+ max-height: 172px;
}
}
@@ -840,3 +819,29 @@ $tabs-holder-z-index: 250;
}
}
}
+
+.merge-request-sticky-header {
+ z-index: 204;
+ box-shadow: 0 1px 2px $issue-boards-card-shadow;
+ --width: calc(100% - #{$contextual-sidebar-width});
+
+ @include media-breakpoint-down(lg) {
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width});
+ }
+}
+
+.detail-page-header-actions {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+}
+
+.page-with-icon-sidebar .issue-sticky-header {
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width});
+}
+
+.merge-request-notification-toggle {
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
index 412971253ca..0c73bece035 100644
--- a/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss
@@ -28,13 +28,6 @@
.pipeline-schedule-table-row {
.branch-name-cell {
max-width: 300px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .next-run-cell {
- color: var(--gray-500, $gray-500);
}
a {
@@ -50,7 +43,6 @@
.bordered-box.content-block {
border: 1px solid var(--border-color, $border-color);
background-color: transparent;
- padding: $gl-spacing-scale-5;
}
}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/page_bundles/profiles/preferences.scss
index c7d7aacceec..c9c78a70163 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/page_bundles/profiles/preferences.scss
@@ -1,3 +1,5 @@
+@import 'page_bundles/mixins_and_variables_and_functions';
+
.application-theme {
$ui-gray-bg: #303030;
$ui-light-gray-bg: #f0f0f0;
diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index d0748779f47..03c9fc7508d 100644
--- a/app/assets/stylesheets/page_bundles/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -16,6 +16,10 @@
line-height: 20px;
}
+.report-block-child-icon {
+ height: 20px;
+}
+
.report-block-list {
list-style: none;
padding: 0 1px;
diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss
index e7813e3b56e..3eacf98688e 100644
--- a/app/assets/stylesheets/page_bundles/todos.scss
+++ b/app/assets/stylesheets/page_bundles/todos.scss
@@ -9,12 +9,6 @@
// workaround because we cannot use border-collapse
border-top: 1px solid transparent;
- &:hover {
- background-color: var(--blue-50, $blue-50);
- border-color: var(--blue-200, $blue-200);
- cursor: pointer;
- }
-
// overwrite border style of .content-list
&:last-child {
border-bottom: 1px solid transparent;
@@ -26,8 +20,6 @@
&.todo-pending.done-reversible {
&:hover {
- border-color: var(--border-color, $border-color);
- background-color: var(--gray-50, $gray-50);
border-top: 1px solid transparent;
.todo-avatar,
@@ -40,20 +32,12 @@
.todo-item {
opacity: 0.2;
}
-
- .btn {
- background-color: var(--gray-50, $gray-50);
- }
}
}
.todo-item {
@include transition(opacity);
- .status-box {
- line-height: inherit;
- }
-
.todo-label,
.todo-project {
a {
@@ -66,22 +50,6 @@
color: var(--gl-text-color, $gl-text-color);
}
- pre {
- border: 0;
- background: var(--gray-50, $gray-50);
- border-radius: 0;
- color: var(--gray-500, $gray-500);
- margin: 0 20px;
- overflow: hidden;
- }
-
- .note-image-attach {
- margin-top: 4px;
- margin-left: 0;
- max-width: 200px;
- float: none;
- }
-
.gl-label-scoped {
--label-inset-border: inset 0 0 0 1px currentColor;
}
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 9220fa82b46..d0fc011dde7 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -7,7 +7,7 @@
#weight-widget-input:not(:hover, :focus),
#weight-widget-input[readonly] {
- box-shadow: inset 0 0 0 $gl-border-size-1 var(--white, $white);
+ box-shadow: none;
}
#weight-widget-input[readonly] {
@@ -19,8 +19,38 @@
display: none;
}
- .assignees-selector:hover .assign-myself {
- display: block;
+ @include media-breakpoint-up(sm) {
+ .assignees-selector:hover .assign-myself {
+ display: block;
+ }
+ }
+}
+
+.work-item-due-date {
+ .gl-datepicker-input.gl-form-input.form-control {
+ width: 10rem;
+
+ &:not(:focus, :hover) {
+ box-shadow: none;
+
+ ~ .gl-datepicker-actions {
+ display: none;
+ }
+ }
+
+ &[disabled] {
+ background-color: var(--white, $white);
+ box-shadow: none;
+
+ ~ .gl-datepicker-actions {
+ display: none;
+ }
+ }
+ }
+
+ .gl-datepicker-actions:focus,
+ .gl-datepicker-actions:hover {
+ display: flex !important;
}
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index c96d8ecc782..19318d87731 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -33,12 +33,6 @@
height: 22px;
}
}
-
- .mr-widget-pipeline-graph {
- .dropdown-menu {
- margin-top: 11px;
- }
- }
}
.branch-info .commit-icon {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 69797c6b303..85205f4d5ac 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -297,7 +297,7 @@
padding: 0;
.issuable-context-form {
- --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
+ --initial-top: calc(#{$header-height} + 76px);
--top: var(--initial-top);
@include gl-sticky;
@@ -613,7 +613,7 @@
}
.participants-author {
- &:nth-of-type(7n) {
+ &:nth-of-type(8n) {
padding-right: 0;
}
@@ -962,40 +962,26 @@
border-left: 2px solid $gray-50;
position: absolute;
left: 39px;
- height: 80%;
+ height: calc(100% + #{$gl-spacing-scale-5});
+ top: -#{$gl-spacing-scale-5};
}
- &:first-child::before,
- &:last-child::after {
+ &:first-child::before {
content: none;
}
&:first-child {
&::after {
- top: 50%;
+ top: $gl-spacing-scale-5;
+ height: calc(100% + #{$gl-spacing-scale-5});
}
}
- &:last-child {
+ &:last-child,
+ &.create-timeline-event {
&::before {
- bottom: 50%;
- }
- }
-
- &:not(:first-child):not(:last-child) {
- &::before {
- top: -10%;
- }
-
- &::after {
- bottom: -10%;
- }
- }
-
- &.timeline-event-note-form {
- &::before {
- top: -15% !important; // Override default positioning
- height: 20%;
+ top: - #{$gl-spacing-scale-5} !important; // Override default positioning
+ @include gl-h-8;
}
&::after {
@@ -1007,3 +993,22 @@
.timeline-event-note-form {
padding-left: 20px;
}
+
+.timeline-entry:not(:last-child) {
+ .timeline-event-border {
+ @include gl-pb-5;
+ @include gl-border-gray-50;
+ @include gl-border-1;
+ @include gl-border-b-solid;
+ }
+}
+
+.timeline-group:last-child {
+ .timeline-entry:last-child,
+ .create-timeline-event {
+ .timeline-event-bottom-border {
+ @include gl-border-b;
+ @include gl-pt-5;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index a151c28fe93..843daec8cda 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -259,13 +259,15 @@ ul.related-merge-requests > li gl-emoji {
}
.issue-sticky-header {
+ --width: 100%;
+
@include gl-left-0;
- @include gl-w-full;
+ width: var(--width);
top: $header-height;
// collapsed right sidebar
@include media-breakpoint-up(sm) {
- width: calc(100% - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$gutter-collapsed-width});
}
}
@@ -283,12 +285,12 @@ ul.related-merge-requests > li gl-emoji {
// collapsed left sidebar + collapsed right sidebar
.issue-sticky-header {
left: $contextual-sidebar-collapsed-width;
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
}
// collapsed left sidebar + expanded right sidebar
.right-sidebar-expanded .issue-sticky-header {
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
}
}
@@ -296,23 +298,23 @@ ul.related-merge-requests > li gl-emoji {
// expanded left sidebar + collapsed right sidebar
.issue-sticky-header {
left: $contextual-sidebar-width;
- width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-collapsed-width});
}
// collapsed left sidebar + collapsed right sidebar
.page-with-icon-sidebar .issue-sticky-header {
left: $contextual-sidebar-collapsed-width;
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-collapsed-width});
}
// expanded left sidebar + expanded right sidebar
.right-sidebar-expanded .issue-sticky-header {
- width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
+ --width: calc(100% - #{$contextual-sidebar-width} - #{$gutter-width});
}
// collapsed left sidebar + expanded right sidebar
.right-sidebar-expanded.page-with-icon-sidebar .issue-sticky-header {
- width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
+ --width: calc(100% - #{$contextual-sidebar-collapsed-width} - #{$gutter-width});
}
}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 1beb9f05b6c..d4ad6da7f4d 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -1,3 +1,4 @@
+@import 'framework/variables';
/* Login Page */
.login-page {
.container {
@@ -41,6 +42,13 @@
font-size: 13px;
}
+ .signin-text {
+ p {
+ margin-bottom: 0;
+ line-height: 1.5;
+ }
+ }
+
.borderless {
.login-box,
.omniauth-container {
@@ -118,6 +126,18 @@
}
.new-session-tabs {
+ &.nav-links-unboxed {
+ border-color: transparent;
+ box-shadow: none;
+
+ .nav-item {
+ border-left: 0;
+ border-right: 0;
+ border-bottom: 1px solid $gray-100;
+ background-color: transparent;
+ }
+ }
+
display: flex;
box-shadow: 0 0 0 1px $border-color;
border-top-right-radius: $border-radius-default;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 9692becef4f..cb77c31d59a 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -48,7 +48,7 @@
.common-note-form {
.md-area {
- padding: $gl-padding-8 $gl-padding;
+ padding: 0 $gl-padding;
border: 1px solid $border-color;
border-radius: $border-radius-base;
transition: border-color ease-in-out 0.15s,
@@ -305,7 +305,6 @@ table {
}
.comment-toolbar {
- padding-top: $gl-padding-top;
color: $gl-text-color-secondary;
border-top: 1px solid $border-color;
}
@@ -336,8 +335,7 @@ table {
.toolbar-text {
font-size: 14px;
- line-height: 16px;
- margin-top: 2px;
+ line-height: $gl-spacing-scale-7;
@include media-breakpoint-up(md) {
float: left;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index db07f16dfd0..fc1b78bf730 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -164,7 +164,7 @@ $system-note-svg-size: 16px;
}
.note-body {
- padding: $gl-padding-4;
+ padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding-8;
overflow-x: auto;
overflow-y: hidden;
@@ -305,7 +305,7 @@ $system-note-svg-size: 16px;
height: $system-note-icon-size;
border: 1px solid $gray-10;
border-radius: $system-note-icon-size;
- margin: -6px 20px 0 0;
+ margin: -6px 0 0;
svg {
width: $system-note-svg-size;
@@ -334,10 +334,14 @@ $system-note-svg-size: 16px;
border-radius: 0;
@media (min-width: map-get($grid-breakpoints, md)) {
- top: calc(#{$mr-tabs-height} + #{$header-height});
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height});
+
+ &.is-sidebar-moved {
+ --initial-top: calc(#{$header-height} + #{$mr-tabs-height + 28px});
+ }
.with-performance-bar & {
- top: 123px;
+ --top: 123px;
}
}
@@ -551,6 +555,7 @@ $system-note-svg-size: 16px;
.note-header {
display: flex;
justify-content: space-between;
+ align-items: center;
flex-wrap: wrap;
> .note-header-info,
@@ -581,7 +586,7 @@ $system-note-svg-size: 16px;
.note-header-info {
min-width: 0;
- padding-left: $gl-padding-4;
+ padding-left: $gl-padding-8;
&.discussion {
padding-bottom: 0;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 6c909b8d9fa..e8f71c8a21c 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -49,7 +49,7 @@ input[type='checkbox']:hover {
}
&.header-search-is-active {
- .navbar-collapse {
+ .global-search-container {
flex-grow: 1;
}
@@ -59,12 +59,6 @@ input[type='checkbox']:hover {
overflow: hidden;
}
}
-
- @include media-breakpoint-up(xl) {
- .navbar-nav {
- padding-left: 1rem;
- }
- }
}
}
@@ -383,6 +377,10 @@ input[type='checkbox']:hover {
.line_holder {
pre {
padding: 0; // This overrides the existing style that will add space between each line.
+ .line {
+ @include gl-word-break-word;
+ white-space: break-spaces;
+ }
}
svg {
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 935595d1b3b..56acf6de828 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -349,3 +349,9 @@
}
}
}
+
+.gl-md-flex-wrap-nowrap.gl-md-flex-wrap-nowrap {
+ @include gl-media-breakpoint-up(md) {
+ @include gl-flex-wrap-nowrap;
+ }
+}
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index ffe4d5dde9d..0450b3d9a44 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -10,7 +10,6 @@ body.gl-dark {
--gray-50: #303030;
--gray-100: #404040;
--gray-200: #525252;
- --gray-600: #bfbfbf;
--gray-700: #dbdbdb;
--gray-900: #fafafa;
--green-100: #0d532a;
@@ -18,7 +17,6 @@ body.gl-dark {
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--black: #fff;
- --nav-active-bg: rgba(255, 255, 255, 0.08);
}
:root {
--white: #333;
@@ -332,9 +330,6 @@ kbd kbd {
color: #fff;
background-color: #c17d10;
}
-.bg-transparent {
- background-color: transparent !important;
-}
.rounded-circle {
border-radius: 50% !important;
}
@@ -459,7 +454,7 @@ a.gl-badge.badge-warning:active {
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #868686;
+ color: #999;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -594,9 +589,6 @@ svg {
html {
overflow-y: scroll;
}
-body {
- text-decoration-skip: ink;
-}
.btn {
border-radius: 4px;
font-size: 0.875rem;
@@ -815,20 +807,17 @@ kbd {
.navbar-gitlab .header-content .title img {
height: 24px;
}
-.navbar-gitlab .header-content .title a {
+.navbar-gitlab .header-content .title a:not(.canary-badge) {
display: flex;
align-items: center;
padding: 2px 8px;
margin: 4px 2px 4px -8px;
border-radius: 4px;
}
-.navbar-gitlab .header-content .title a:active {
+.navbar-gitlab .header-content .title a:not(.canary-badge):active {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 0 3px #1068bf;
outline: none;
}
-.navbar-gitlab .header-content .title .canary-badge {
- margin-left: -8px;
-}
.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) {
margin: 0 2px;
}
@@ -1012,9 +1001,6 @@ kbd {
visibility: hidden;
top: 3px;
}
-.top-nav-toggle .dropdown-icon {
- margin-right: 0.5rem;
-}
.tanuki-logo .tanuki {
fill: #e24329;
}
@@ -1137,7 +1123,7 @@ kbd {
font-weight: 600;
}
.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: rgba(41, 41, 97, 0.08);
+ background-color: rgba(255, 255, 255, 0.08);
}
.nav-sidebar ul {
padding-left: 0;
@@ -1790,7 +1776,6 @@ body.gl-dark {
--white: #333;
--black: #fff;
--svg-status-bg: #333;
- --nav-active-bg: rgba(255, 255, 255, 0.08);
}
.nav-sidebar,
.toggle-sidebar-button,
@@ -1802,15 +1787,6 @@ body.gl-dark {
.avatar {
background: rgba(255, 255, 255, 0.04);
}
-.nav-sidebar li a {
- color: var(--gray-600);
-}
-.nav-sidebar li.active {
- box-shadow: none;
-}
-.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: var(--nav-active-bg);
-}
body.gl-dark {
--gl-theme-accent: #868686;
}
@@ -2038,7 +2014,6 @@ body.gl-dark {
--white: #333;
--black: #fff;
--svg-status-bg: #333;
- --nav-active-bg: rgba(255, 255, 255, 0.08);
}
.tab-width-8 {
tab-size: 8;
@@ -2113,6 +2088,12 @@ body.gl-dark {
.gl-pt-0 {
padding-top: 0;
}
+.gl-mr-auto {
+ margin-right: auto;
+}
+.gl-mr-3 {
+ margin-right: 0.5rem;
+}
.gl-ml-n2 {
margin-left: -0.25rem;
}
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 00ca98bfd27..356fb58b4c8 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -311,9 +311,6 @@ kbd kbd {
color: #fff;
background-color: #ab6100;
}
-.bg-transparent {
- background-color: transparent !important;
-}
.rounded-circle {
border-radius: 50% !important;
}
@@ -438,7 +435,7 @@ a.gl-badge.badge-warning:active {
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #868686;
+ color: #666;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
@@ -573,9 +570,6 @@ svg {
html {
overflow-y: scroll;
}
-body {
- text-decoration-skip: ink;
-}
.btn {
border-radius: 4px;
font-size: 0.875rem;
@@ -794,20 +788,17 @@ kbd {
.navbar-gitlab .header-content .title img {
height: 24px;
}
-.navbar-gitlab .header-content .title a {
+.navbar-gitlab .header-content .title a:not(.canary-badge) {
display: flex;
align-items: center;
padding: 2px 8px;
margin: 4px 2px 4px -8px;
border-radius: 4px;
}
-.navbar-gitlab .header-content .title a:active {
+.navbar-gitlab .header-content .title a:not(.canary-badge):active {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 0 0 3px #63a6e9;
outline: none;
}
-.navbar-gitlab .header-content .title .canary-badge {
- margin-left: -8px;
-}
.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) {
margin: 0 2px;
}
@@ -991,9 +982,6 @@ kbd {
visibility: hidden;
top: 3px;
}
-.top-nav-toggle .dropdown-icon {
- margin-right: 0.5rem;
-}
.tanuki-logo .tanuki {
fill: #e24329;
}
@@ -1116,7 +1104,7 @@ kbd {
font-weight: 600;
}
.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) {
- background-color: rgba(41, 41, 97, 0.08);
+ background-color: rgba(0, 0, 0, 0.08);
}
.nav-sidebar ul {
padding-left: 0;
@@ -1751,6 +1739,12 @@ svg.s16 {
.gl-pt-0 {
padding-top: 0;
}
+.gl-mr-auto {
+ margin-right: auto;
+}
+.gl-mr-3 {
+ margin-right: 0.5rem;
+}
.gl-ml-n2 {
margin-left: -0.25rem;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index c0e2d8d44d4..edc579f48f6 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -11,6 +11,9 @@ html {
font-family: sans-serif;
line-height: 1.15;
}
+header {
+ display: block;
+}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -28,7 +31,8 @@ hr {
height: 0;
overflow: visible;
}
-h1 {
+h1,
+h3 {
margin-top: 0;
margin-bottom: 0.25rem;
}
@@ -49,26 +53,49 @@ img {
vertical-align: middle;
border-style: none;
}
+svg {
+ overflow: hidden;
+ vertical-align: middle;
+}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
-input {
+button {
+ border-radius: 0;
+}
+input,
+button {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
+button,
input {
overflow: visible;
}
+button {
+ text-transform: none;
+}
+[role="button"] {
+ cursor: pointer;
+}
+button:not(:disabled),
+[type="button"]:not(:disabled),
[type="submit"]:not(:disabled) {
cursor: pointer;
}
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
+input[type="checkbox"] {
+ box-sizing: border-box;
+ padding: 0;
+}
fieldset {
min-width: 0;
padding: 0;
@@ -78,7 +105,8 @@ fieldset {
[hidden] {
display: none !important;
}
-h1 {
+h1,
+h3 {
margin-bottom: 0.25rem;
font-weight: 600;
line-height: 1.2;
@@ -87,6 +115,9 @@ h1 {
h1 {
font-size: 2.1875rem;
}
+h3 {
+ font-size: 1.53125rem;
+}
hr {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -120,23 +151,42 @@ hr {
max-width: 1140px;
}
}
-.col-sm-12,
-.col {
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ margin-right: -15px;
+ margin-left: -15px;
+}
+.col-md-6,
+.col-sm-12 {
position: relative;
width: 100%;
padding-right: 15px;
padding-left: 15px;
}
-.col {
- flex-basis: 0;
- 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;
@@ -169,16 +219,6 @@ hr {
.form-group {
margin-bottom: 1rem;
}
-.form-row {
- display: flex;
- flex-wrap: wrap;
- margin-right: -5px;
- margin-left: -5px;
-}
-.form-row > .col {
- padding-right: 5px;
- padding-left: 5px;
-}
.btn {
display: inline-block;
font-weight: 400;
@@ -204,6 +244,137 @@ hr {
fieldset:disabled a.btn {
pointer-events: none;
}
+.btn-block {
+ display: block;
+ width: 100%;
+}
+.btn-block + .btn-block {
+ margin-top: 0.5rem;
+}
+input.btn-block[type="submit"],
+input.btn-block[type="button"] {
+ width: 100%;
+}
+.custom-control {
+ position: relative;
+ z-index: 1;
+ display: block;
+ min-height: 1.5rem;
+ padding-left: 1.5rem;
+ color-adjust: exact;
+}
+.custom-control-input {
+ position: absolute;
+ left: 0;
+ z-index: -1;
+ width: 1rem;
+ height: 1.25rem;
+ opacity: 0;
+}
+.custom-control-input:checked ~ .custom-control-label::before {
+ color: #fff;
+ border-color: #007bff;
+ background-color: #007bff;
+}
+.custom-control-input:not(:disabled):active ~ .custom-control-label::before {
+ color: #fff;
+ background-color: #b3d7ff;
+ border-color: #b3d7ff;
+}
+.custom-control-input:disabled ~ .custom-control-label {
+ color: #5e5e5e;
+}
+.custom-control-input:disabled ~ .custom-control-label::before {
+ background-color: #fafafa;
+}
+.custom-control-label {
+ position: relative;
+ margin-bottom: 0;
+ vertical-align: top;
+}
+.custom-control-label::before {
+ position: absolute;
+ top: 0.25rem;
+ left: -1.5rem;
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ pointer-events: none;
+ content: "";
+ background-color: #fff;
+ border: #666 solid 1px;
+}
+.custom-control-label::after {
+ position: absolute;
+ top: 0.25rem;
+ left: -1.5rem;
+ display: block;
+ width: 1rem;
+ height: 1rem;
+ content: "";
+ background: no-repeat 50% / 50% 50%;
+}
+.custom-checkbox .custom-control-label::before {
+ border-radius: 0.25rem;
+}
+.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e");
+}
+.custom-checkbox
+ .custom-control-input:indeterminate
+ ~ .custom-control-label::before {
+ border-color: #007bff;
+ background-color: #007bff;
+}
+.custom-checkbox
+ .custom-control-input:indeterminate
+ ~ .custom-control-label::after {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e");
+}
+.custom-checkbox
+ .custom-control-input:disabled:checked
+ ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+.custom-checkbox
+ .custom-control-input:disabled:indeterminate
+ ~ .custom-control-label::before {
+ background-color: rgba(0, 123, 255, 0.5);
+}
+@media (prefers-reduced-motion: reduce) {
+}
+.tab-content > .tab-pane {
+ display: none;
+}
+.tab-content > .active {
+ display: block;
+}
+.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;
+}
+.clearfix::after {
+ display: block;
+ clear: both;
+ content: "";
+}
+.fixed-top {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ z-index: 1030;
+}
.mt-3 {
margin-top: 1rem !important;
}
@@ -213,8 +384,8 @@ fieldset:disabled a.btn {
.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 {
@@ -245,19 +416,109 @@ fieldset:disabled a.btn {
.gl-form-input:disabled,
.gl-form-input.form-control:disabled {
cursor: not-allowed;
- color: #868686;
+ color: #666;
}
.gl-form-input::placeholder,
.gl-form-input.form-control::placeholder {
color: #868686;
}
+.gl-form-checkbox {
+ font-size: 0.875rem;
+ line-height: 1rem;
+ color: #303030;
+}
+.gl-form-checkbox .custom-control-input:disabled,
+.gl-form-checkbox .custom-control-input:disabled ~ .custom-control-label {
+ cursor: not-allowed;
+ color: #868686;
+}
+.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label {
+ cursor: pointer;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input
+ ~ .custom-control-label::before,
+.gl-form-checkbox.custom-control
+ .custom-control-input
+ ~ .custom-control-label::after {
+ top: 0;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input
+ ~ .custom-control-label::before {
+ background-color: #fff;
+ border-color: #868686;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:checked
+ ~ .custom-control-label::before {
+ background-color: #1f75cb;
+ border-color: #1f75cb;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:checked
+ ~ .custom-control-label::after,
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:indeterminate
+ ~ .custom-control-label::after {
+ background: none;
+ background-color: #fff;
+ mask-repeat: no-repeat;
+ mask-position: center center;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:checked
+ ~ .custom-control-label::after {
+ mask-image: url('data:image/svg+xml,%3Csvg width="8" height="7" viewBox="0 0 8 7" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 3.05299L2.99123 5L7 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A');
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input[type="checkbox"]:indeterminate
+ ~ .custom-control-label::after {
+ mask-image: url('data:image/svg+xml,%3Csvg width="8" height="2" viewBox="0 0 8 2" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 1L8 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A');
+}
+.gl-form-checkbox.custom-control.custom-checkbox
+ .custom-control-input:indeterminate
+ ~ .custom-control-label::before {
+ background-color: #1f75cb;
+ border-color: #1f75cb;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:disabled
+ ~ .custom-control-label {
+ cursor: not-allowed;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:disabled
+ ~ .custom-control-label::before {
+ background-color: #f0f0f0;
+ border-color: #dbdbdb;
+ pointer-events: auto;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:checked:disabled
+ ~ .custom-control-label::before,
+.gl-form-checkbox.custom-control
+ .custom-control-input:indeterminate:disabled
+ ~ .custom-control-label::before {
+ background-color: #dbdbdb;
+ border-color: #dbdbdb;
+}
+.gl-form-checkbox.custom-control
+ .custom-control-input:checked:disabled
+ ~ .custom-control-label::after,
+.gl-form-checkbox.custom-control
+ .custom-control-input:indeterminate:disabled
+ ~ .custom-control-label::after {
+ background-color: #5e5e5e;
+}
.gl-button {
display: inline-flex;
}
.gl-button:not(.btn-link):active {
text-decoration: none;
}
-.gl-button.gl-button {
+.gl-button.gl-button,
+.gl-button.gl-button.btn-block {
border-width: 0;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
@@ -273,7 +534,8 @@ fieldset:disabled a.btn {
font-size: 0.875rem;
border-radius: 0.25rem;
}
-.gl-button.gl-button .gl-button-text {
+.gl-button.gl-button .gl-button-text,
+.gl-button.gl-button.btn-block .gl-button-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -282,29 +544,39 @@ fieldset:disabled a.btn {
margin-top: -1px;
margin-bottom: -1px;
}
-.gl-button.gl-button .gl-button-icon {
+.gl-button.gl-button .gl-button-icon,
+.gl-button.gl-button.btn-block .gl-button-icon {
height: 1rem;
width: 1rem;
flex-shrink: 0;
margin-right: 0.25rem;
top: auto;
}
-.gl-button.gl-button.btn-default {
+.gl-button.gl-button.btn-default,
+.gl-button.gl-button.btn-block.btn-default {
background-color: #fff;
}
-.gl-button.gl-button.btn-default:active {
+.gl-button.gl-button.btn-default:active,
+.gl-button.gl-button.btn-default.active,
+.gl-button.gl-button.btn-block.btn-default:active,
+.gl-button.gl-button.btn-block.btn-default.active {
box-shadow: inset 0 0 0 1px #5e5e5e, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
outline: none;
background-color: #dbdbdb;
}
-.gl-button.gl-button.btn-confirm {
+.gl-button.gl-button.btn-confirm,
+.gl-button.gl-button.btn-block.btn-confirm {
color: #fff;
}
-.gl-button.gl-button.btn-confirm {
+.gl-button.gl-button.btn-confirm,
+.gl-button.gl-button.btn-block.btn-confirm {
background-color: #1f75cb;
box-shadow: inset 0 0 0 1px #1068bf;
}
-.gl-button.gl-button.btn-confirm:active {
+.gl-button.gl-button.btn-confirm:active,
+.gl-button.gl-button.btn-confirm.active,
+.gl-button.gl-button.btn-block.btn-confirm:active,
+.gl-button.gl-button.btn-block.btn-confirm.active {
box-shadow: inset 0 0 0 1px #033464, 0 0 0 1px #fff, 0 0 0 3px #428fdc;
outline: none;
background-color: #0b5cad;
@@ -312,10 +584,14 @@ fieldset:disabled a.btn {
body {
font-size: 0.875rem;
}
-[type="submit"] {
+button,
+html [type="button"],
+[type="submit"],
+[role="button"] {
cursor: pointer;
}
-h1 {
+h1,
+h3 {
margin-top: 20px;
margin-bottom: 10px;
}
@@ -325,6 +601,9 @@ a {
hr {
overflow: hidden;
}
+svg {
+ vertical-align: baseline;
+}
.form-control {
font-size: 0.875rem;
}
@@ -332,15 +611,9 @@ hr {
display: none !important;
visibility: hidden !important;
}
-.hide {
- display: none;
-}
html {
overflow-y: scroll;
}
-body {
- text-decoration-skip: ink;
-}
body.navless {
background-color: #fff !important;
}
@@ -375,13 +648,34 @@ body.navless {
background-color: #f0f0f0;
box-shadow: none;
}
-.btn:active {
+.btn:active,
+.btn.active {
background-color: #eaeaea;
border-color: #e3e3e3;
color: #303030;
}
-.light {
- color: #303030;
+.btn svg {
+ height: 15px;
+ width: 15px;
+}
+.btn svg:not(:last-child) {
+ margin-right: 5px;
+}
+.btn-block {
+ width: 100%;
+ margin: 0;
+ margin-bottom: 1rem;
+}
+.btn-block.btn {
+ padding: 6px 0;
+}
+.tab-content {
+ overflow: visible;
+}
+@media (max-width: 767.98px) {
+ .tab-content {
+ isolation: isolate;
+ }
}
hr {
margin: 1.5rem 0;
@@ -419,6 +713,9 @@ input {
label {
font-weight: 600;
}
+label.custom-control-label {
+ font-weight: 400;
+}
label.label-bold {
font-weight: 600;
}
@@ -432,8 +729,25 @@ label.label-bold {
.gl-show-field-errors .form-control:not(textarea) {
height: 34px;
}
-.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;
@@ -445,6 +759,9 @@ input::-ms-input-placeholder {
input:-ms-input-placeholder {
color: #868686;
}
+svg {
+ fill: currentColor;
+}
.login-page .container {
max-width: 960px;
}
@@ -477,6 +794,10 @@ input:-ms-input-placeholder {
.login-page p {
font-size: 13px;
}
+.login-page .signin-text p {
+ margin-bottom: 0;
+ line-height: 1.5;
+}
.login-page .borderless .login-box,
.login-page .borderless .omniauth-container {
box-shadow: none;
@@ -549,6 +870,16 @@ input:-ms-input-placeholder {
border-top-right-radius: 4px;
border-top-left-radius: 4px;
}
+.login-page .new-session-tabs.nav-links-unboxed {
+ border-color: transparent;
+ box-shadow: none;
+}
+.login-page .new-session-tabs.nav-links-unboxed .nav-item {
+ border-left: 0;
+ border-right: 0;
+ border-bottom: 1px solid #dbdbdb;
+ background-color: transparent;
+}
.login-page .new-session-tabs.custom-provider-tabs {
flex-wrap: wrap;
}
@@ -648,14 +979,20 @@ input:-ms-input-placeholder {
}
}
-.gl-text-green-600 {
- color: #217645;
+.gl-display-flex {
+ display: flex;
}
-.gl-text-red-500 {
- color: #dd2b0e;
+.gl-display-inline-block {
+ display: inline-block;
}
-.gl-display-block {
- display: block;
+.gl-flex-wrap {
+ flex-wrap: wrap;
+}
+.gl-justify-content-center {
+ justify-content: center;
+}
+.gl-float-right {
+ float: right;
}
.gl-w-10 {
width: 3.5rem;
@@ -674,14 +1011,18 @@ input:-ms-input-placeholder {
width: 100%;
}
}
-.gl-p-4 {
- padding: 0.75rem;
+.gl-p-5 {
+ padding: 1rem;
+}
+.gl-px-5 {
+ padding-left: 1rem;
+ padding-right: 1rem;
}
.gl-pt-5 {
padding-top: 1rem;
}
-.gl-mt-2 {
- margin-top: 0.25rem;
+.gl-mt-3 {
+ margin-top: 0.5rem;
}
.gl-mt-5 {
margin-top: 1rem;
@@ -701,15 +1042,17 @@ input:-ms-input-placeholder {
.gl-mb-3 {
margin-bottom: 0.5rem;
}
-.gl-mb-5 {
- margin-bottom: 1rem;
-}
.gl-ml-auto {
margin-left: auto;
}
.gl-ml-2 {
margin-left: 0.25rem;
}
+@media (min-width: 576px) {
+ .gl-sm-mt-0 {
+ margin-top: 0;
+ }
+}
.gl-text-center {
text-align: center;
}
@@ -719,6 +1062,9 @@ input:-ms-input-placeholder {
.gl-font-weight-normal {
font-weight: 400;
}
+.gl-font-weight-bold {
+ font-weight: 600;
+}
@import "startup/cloaking";
@include cloak-startup-scss(none);
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index eeb4604f32a..4b74e449e06 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -101,7 +101,6 @@ $white-dark: #444;
$theme-indigo-50: #1a1a40;
$border-color: #4f4f4f;
-$nav-active-bg: rgba(255, 255, 255, 0.08);
:root {
color-scheme: dark;
@@ -206,7 +205,6 @@ body.gl-dark {
--black: #{$black};
--svg-status-bg: #{$white};
- --nav-active-bg: #{$nav-active-bg};
.gl-button.gl-button,
.gl-button.gl-button.btn-block {
diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss
index 92740aaf89e..e1ba2a69420 100644
--- a/app/assets/stylesheets/themes/dark_mode_overrides.scss
+++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss
@@ -60,26 +60,6 @@
}
.nav-sidebar {
- li {
- a {
- color: var(--gray-600);
- }
-
- > a:hover {
- background-color: var(--nav-active-bg);
- }
-
- &.active {
- box-shadow: none;
-
- &:not(.fly-out-top-item) {
- > a:not(.has-sub-items) {
- background-color: var(--nav-active-bg);
- }
- }
- }
- }
-
.sidebar-sub-level-items.fly-out-list {
box-shadow: none;
border: 1px solid $border-color;
@@ -92,7 +72,7 @@ aside.right-sidebar:not(.right-sidebar-merge-requests) {
}
body.gl-dark {
- @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $gray-900, $white);
+ @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $white);
.terms {
.logo-text {
diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss
index 817557f37cd..90122cec31f 100644
--- a/app/assets/stylesheets/themes/theme_blue.scss
+++ b/app/assets/stylesheets/themes/theme_blue.scss
@@ -6,7 +6,6 @@ body {
$theme-blue-200,
$theme-blue-500,
$theme-blue-700,
- $gray-900,
$theme-blue-900,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss
index 75b111f90c7..a6cdfb36a7c 100644
--- a/app/assets/stylesheets/themes/theme_gray.scss
+++ b/app/assets/stylesheets/themes/theme_gray.scss
@@ -7,7 +7,6 @@ body {
$gray-300,
$gray-500,
$gray-900,
- $gray-900,
$white
);
}
diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss
index 7e387e97452..0300f261d64 100644
--- a/app/assets/stylesheets/themes/theme_green.scss
+++ b/app/assets/stylesheets/themes/theme_green.scss
@@ -6,7 +6,6 @@ body {
$theme-green-200,
$theme-green-500,
$theme-green-700,
- $gray-900,
$theme-green-900,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 042e21cebd6..d644d8acc98 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -6,18 +6,22 @@
$search-and-nav-links,
$accent,
$border-and-box-shadow,
- $sidebar-text,
- $nav-svg-color,
- $color-alternate
+ $navbar-theme-color,
+ $navbar-theme-contrast-color
) {
// Set custom properties
--gl-theme-accent: #{$accent};
+ $search-and-nav-links-a20: rgba($search-and-nav-links, 0.2);
+ $search-and-nav-links-a30: rgba($search-and-nav-links, 0.3);
+ $search-and-nav-links-a40: rgba($search-and-nav-links, 0.4);
+ $search-and-nav-links-a80: rgba($search-and-nav-links, 0.8);
+
// Header
.navbar-gitlab {
- background-color: $nav-svg-color;
+ background-color: $navbar-theme-color;
.navbar-collapse {
color: $search-and-nav-links;
@@ -37,7 +41,7 @@
> button {
&:hover,
&:focus {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
}
@@ -45,13 +49,13 @@
&.dropdown.show {
> a,
> button {
- color: $nav-svg-color;
- background-color: $color-alternate;
+ color: $navbar-theme-color;
+ background-color: $navbar-theme-contrast-color;
}
}
&.line-separator {
- border-left: 1px solid rgba($search-and-nav-links, 0.2);
+ border-left: 1px solid $search-and-nav-links-a20;
}
}
}
@@ -65,12 +69,12 @@
color: $search-and-nav-links;
&.header-search-new {
- color: $sidebar-text;
+ color: $gray-900;
}
> a {
.notification-dot {
- border: 2px solid $nav-svg-color;
+ border: 2px solid $navbar-theme-color;
}
&.header-help-dropdown-toggle {
@@ -88,7 +92,7 @@
&:hover,
&:focus {
@include media-breakpoint-up(sm) {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
svg {
@@ -97,7 +101,7 @@
.notification-dot {
will-change: border-color, background-color;
- border-color: adjust-color($nav-svg-color, $red: 33, $green: 33, $blue: 33);
+ border-color: adjust-color($navbar-theme-color, $red: 33, $green: 33, $blue: 33);
}
&.header-help-dropdown-toggle .notification-dot {
@@ -108,12 +112,12 @@
&.active > a,
&.dropdown.show > a {
- color: $nav-svg-color;
- background-color: $color-alternate;
+ color: $navbar-theme-color;
+ background-color: $navbar-theme-contrast-color;
&:hover {
svg {
- fill: $nav-svg-color;
+ fill: $navbar-theme-color;
}
}
@@ -123,7 +127,7 @@
&.header-help-dropdown-toggle {
.notification-dot {
- background-color: $nav-svg-color;
+ background-color: $navbar-theme-color;
}
}
}
@@ -131,7 +135,7 @@
.impersonated-user,
.impersonated-user:hover {
svg {
- fill: $nav-svg-color;
+ fill: $navbar-theme-color;
}
}
}
@@ -142,30 +146,30 @@
> a {
&:hover,
&:focus {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
}
}
.header-search {
- background-color: rgba($search-and-nav-links, 0.2) !important;
+ background-color: $search-and-nav-links-a20 !important;
border-radius: $border-radius-default;
&:hover {
- background-color: rgba($search-and-nav-links, 0.3) !important;
+ background-color: $search-and-nav-links-a30 !important;
}
svg.gl-search-box-by-type-search-icon {
- color: rgba($search-and-nav-links, 0.8);
+ color: $search-and-nav-links-a80;
}
input {
background-color: transparent;
- color: rgba($search-and-nav-links, 0.8);
- box-shadow: inset 0 0 0 1px rgba($search-and-nav-links, 0.4);
+ color: $search-and-nav-links-a80;
+ box-shadow: inset 0 0 0 1px $search-and-nav-links-a40;
&::placeholder {
- color: rgba($search-and-nav-links, 0.8);
+ color: $search-and-nav-links-a80;
}
&:focus,
@@ -178,27 +182,27 @@
.keyboard-shortcut-helper {
color: $search-and-nav-links;
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
}
}
.search {
form {
- background-color: rgba($search-and-nav-links, 0.2);
+ background-color: $search-and-nav-links-a20;
&:hover {
- background-color: rgba($search-and-nav-links, 0.3);
+ background-color: $search-and-nav-links-a30;
}
}
.search-input::placeholder {
- color: rgba($search-and-nav-links, 0.8);
+ color: $search-and-nav-links-a80;
}
.search-input-wrap {
.search-icon,
.clear-icon {
- fill: rgba($search-and-nav-links, 0.8);
+ fill: $search-and-nav-links-a80;
}
}
@@ -209,7 +213,7 @@
.search-input-wrap {
.search-icon {
- fill: rgba($search-and-nav-links, 0.8);
+ fill: $search-and-nav-links-a80;
}
}
}
@@ -217,7 +221,7 @@
// Sidebar
.nav-sidebar li.active > a {
- color: $sidebar-text;
+ color: $gray-900;
}
.nav-sidebar {
diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss
index 3bf6cfea650..5a27a9cfdc5 100644
--- a/app/assets/stylesheets/themes/theme_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_indigo.scss
@@ -6,7 +6,6 @@ body {
$indigo-200,
$indigo-500,
$indigo-700,
- $gray-900,
$indigo-900,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss
index 771a84911b3..7cb0d98802e 100644
--- a/app/assets/stylesheets/themes/theme_light_blue.scss
+++ b/app/assets/stylesheets/themes/theme_light_blue.scss
@@ -6,7 +6,6 @@ body {
$theme-light-blue-200,
$theme-light-blue-500,
$theme-light-blue-500,
- $gray-900,
$theme-light-blue-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss
index ad19438d79a..a0cbec9a92b 100644
--- a/app/assets/stylesheets/themes/theme_light_gray.scss
+++ b/app/assets/stylesheets/themes/theme_light_gray.scss
@@ -6,7 +6,6 @@ body {
$gray-500,
$gray-700,
$gray-500,
- $gray-900,
$gray-50,
$gray-500
);
diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss
index 8c991a7bfb3..797279cc37b 100644
--- a/app/assets/stylesheets/themes/theme_light_green.scss
+++ b/app/assets/stylesheets/themes/theme_light_green.scss
@@ -6,7 +6,6 @@ body {
$theme-green-200,
$theme-green-500,
$theme-green-500,
- $gray-900,
$theme-light-green-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss
index 6c220e0459a..3632c5ad45a 100644
--- a/app/assets/stylesheets/themes/theme_light_indigo.scss
+++ b/app/assets/stylesheets/themes/theme_light_indigo.scss
@@ -6,7 +6,6 @@ body {
$indigo-200,
$indigo-500,
$indigo-500,
- $gray-900,
$indigo-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss
index e1a715293b4..6c10d9178f1 100644
--- a/app/assets/stylesheets/themes/theme_light_red.scss
+++ b/app/assets/stylesheets/themes/theme_light_red.scss
@@ -6,7 +6,6 @@ body {
$theme-light-red-200,
$theme-light-red-500,
$theme-light-red-500,
- $gray-900,
$theme-light-red-700,
$white
);
diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss
index 19fd150727d..140e27de6e2 100644
--- a/app/assets/stylesheets/themes/theme_red.scss
+++ b/app/assets/stylesheets/themes/theme_red.scss
@@ -6,7 +6,6 @@ body {
$theme-red-200,
$theme-red-500,
$theme-red-700,
- $gray-900,
$theme-red-900,
$white
);
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 6bd05f90f26..bdb8f758137 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -370,3 +370,13 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709
.gl-flex-flow-row-wrap {
flex-flow: row wrap;
}
+
+/*
+ * The below style will be moved to @gitlab/ui by
+ * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1963
+ */
+.gl-gap-y-3 {
+ > * + * {
+ margin-top: $gl-spacing-scale-3;
+ }
+}