diff --git a/GITLAB_KAS_VERSION b/GITLAB_KAS_VERSION
index aef77f192b7..badb307dc9e 100644
--- a/GITLAB_KAS_VERSION
+++ b/GITLAB_KAS_VERSION
@@ -1 +1 @@
-v17.1.0
+v17.1.1
diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue
index 85e051b8bf7..cab420be610 100644
--- a/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue
+++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue
@@ -95,7 +95,7 @@ export default {
{{ $options.text.file }}
-
+
{{ testCase.file }}
{{ testCase.file }}
diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
index 575ca28eb10..3a554b36c48 100644
--- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
+++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/organizations/shared/components/groups_view.vue b/app/assets/javascripts/organizations/shared/components/groups_view.vue
index 8e81eca805b..80a16277490 100644
--- a/app/assets/javascripts/organizations/shared/components/groups_view.vue
+++ b/app/assets/javascripts/organizations/shared/components/groups_view.vue
@@ -1,5 +1,5 @@
+
+
+
+
{{ $options.i18n.listText }}
+
+
diff --git a/app/assets/javascripts/projects/your_work/index.js b/app/assets/javascripts/projects/your_work/index.js
new file mode 100644
index 00000000000..2e20312cd76
--- /dev/null
+++ b/app/assets/javascripts/projects/your_work/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import YourWorkProjectsApp from './components/app.vue';
+
+export const initYourWorkProjects = () => {
+ const el = document.getElementById('js-your-work-projects-app');
+
+ if (!el) return false;
+
+ return new Vue({
+ el,
+ name: 'YourWorkProjectsRoot',
+ render(createElement) {
+ return createElement(YourWorkProjectsApp);
+ },
+ });
+};
diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml
index e7212f00e5b..804fb3cb1db 100644
--- a/app/views/admin/background_migrations/index.html.haml
+++ b/app/views/admin/background_migrations/index.html.haml
@@ -1,7 +1,7 @@
- page_title s_('BackgroundMigrations|Background Migrations')
- @breadcrumb_link = admin_background_migrations_path(database: params[:database])
-.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-sm-align-items-flex-end.gl-pb-5.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-100
+.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-sm-align-items-flex-end.gl-pb-5.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-100{ data: { event_tracking_load: 'true', event_tracking: 'view_admin_background_migrations_pageload' } }
.gl-flex-grow-1
%h3= s_('BackgroundMigrations|Background Migrations')
%p.light.gl-mb-0
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 74549b8b688..191e6dda595 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -4,19 +4,15 @@
- page_title @group.name, _("Groups")
- current_user_is_group_owner = @group && @group.has_owner?(current_user)
-%h1.page-title.gl-font-size-h-display
- = _('Group: %{group_name}') % { group_name: @group.full_name }
+= render ::Layouts::PageHeadingComponent.new(@group.full_name) do |c|
+ - c.with_actions do
+ = render Pajamas::ButtonComponent.new(href: admin_group_edit_path(@group),
+ button_options: { data: { testid: 'edit-group-link' }}) do
+ = _('Edit')
- = render Pajamas::ButtonComponent.new(href: admin_group_edit_path(@group),
- button_options: { class: 'gl-float-right', data: { testid: 'edit-group-link' }},
- icon: 'pencil') do
- = _('Edit')
-%hr
-.row
- .col-md-6
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.with_header do
- = _('Group info:')
+.gl-grid.md:gl-grid-cols-2.gl-gap-5
+ .gl-flex.gl-flex-col.gl-gap-5
+ = render ::Layouts::CrudComponent.new(_('Group information')) do |c|
- c.with_body do
%ul.content-list.content-list-items-padding
%li
@@ -29,32 +25,25 @@
%span.light= _('Path:')
%strong
= @group.path
-
%li
%span.light= _('Description:')
%strong
= @group.description
-
%li
%span.light= _('Visibility level:')
%strong
= visibility_level_label(@group.visibility_level)
-
%li
%span.light= _('Created on:')
%strong
= @group.created_at.to_fs(:medium)
-
%li
%span.light= _('ID:')
%strong
= @group.id
-
= render_if_exists 'admin/namespace_plan_info', namespace: @group
-
%li
= render 'shared/storage_counter_statistics', storage_size: @group.storage_size, storage_details: @group
-
%li
%span.light= _('Group Git LFS status:')
%strong
@@ -64,33 +53,30 @@
= render_if_exists 'namespaces/shared_runner_status', namespace: @group
= render_if_exists 'namespaces/additional_minutes_status', namespace: @group
- = render 'shared/custom_attributes', custom_attributes: @group.custom_attributes
-
- = render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group
-
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.with_header do
- = _('Projects')
- = gl_badge_tag @group.projects.count
+ = render ::Layouts::CrudComponent.new(_('Projects'),
+ icon: 'project',
+ count: @group.projects.count) do |c|
- c.with_body do
- %ul.content-list.content-list-items-padding
- - @projects.each do |project|
- %li
- %strong
- = link_to project.full_name, [:admin, project]
- = gl_badge_tag storage_counter(project.statistics.storage_size)
- %span.gl-float-right.light
- %span.monospace= project.full_path + '.git'
- - unless @projects.size < Kaminari.config.default_per_page
- - c.with_footer do
- = paginate @projects, param_name: 'projects_page', theme: 'gitlab'
+ - if @projects.any?
+ %ul.content-list.content-list-items-padding
+ - @projects.each do |project|
+ %li
+ %strong
+ = link_to project.full_name, [:admin, project]
+ = gl_badge_tag storage_counter(project.statistics.storage_size)
+ %span.gl-float-right.light
+ %span.monospace= project.full_path + '.git'
+ - else
+ %p.gl-new-card-empty.gl-text-center= _('No projects')
+ - unless @projects.size < Kaminari.config.default_per_page
+ - c.with_footer do
+ = paginate @projects, param_name: 'projects_page', theme: 'gitlab'
- shared_projects = @group.shared_projects.sort_by(&:name)
- unless shared_projects.empty?
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.with_header do
- = _('Projects shared with %{group_name}') % { group_name: @group.name }
- = gl_badge_tag shared_projects.size
+ = render ::Layouts::CrudComponent.new(_('Projects shared with %{group_name}') % { group_name: @group.name },
+ icon: 'project',
+ count: shared_projects.size) do |c|
- c.with_body do
%ul.content-list.content-list-items-padding
- shared_projects.each do |project|
@@ -101,16 +87,16 @@
%span.gl-float-right.light
%span.monospace= project.full_path + '.git'
- .col-md-6
- = render 'shared/admin/admin_note'
-
- if can?(current_user, :admin_group_member, @group)
= render 'shared/members/requests', membership_source: @group, group: @group, requesters: @requesters, force_mobile_view: true
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5' }, body_options: { class: 'gl-p-0' }) do |c|
- - c.with_header do
- = html_escape(_("%{group_name} group members")) % { group_name: "#{html_escape(@group.name)}".html_safe }
- = gl_badge_tag @group.users_count
+ .gl-flex.gl-flex-col.gl-gap-5
+ = render 'shared/admin/admin_note'
+
+ = render ::Layouts::CrudComponent.new(_('Group members'),
+ icon: 'user',
+ count: @group.users_count) do |c|
+ - c.with_actions do
= render 'shared/members/manage_access_button', path: group_group_members_path(@group)
- c.with_body do
%ul.content-list.group-users-list.members-list
@@ -122,3 +108,7 @@
- unless @members.size < Kaminari.config.default_per_page
- c.with_footer do
= paginate @members, param_name: 'members_page', theme: 'gitlab'
+
+ = render 'shared/custom_attributes', custom_attributes: @group.custom_attributes
+
+ = render_if_exists 'ldap_group_links/ldap_group_links_show', group: @group
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index c74a9f4cbe6..3e303963f80 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -11,6 +11,9 @@
- if show_projects?(@projects, params)
= render 'dashboard/projects_head'
= render 'nav'
- = render 'projects'
+ - if Feature.enabled?(:your_work_projects_vue, current_user)
+ #js-your-work-projects-app
+ - else
+ = render 'projects'
- else
= render "zero_authorized_projects"
diff --git a/app/views/dashboard/projects/shared/_common.html.haml b/app/views/dashboard/projects/shared/_common.html.haml
index f6f67ad7712..d07d55ca7c4 100644
--- a/app/views/dashboard/projects/shared/_common.html.haml
+++ b/app/views/dashboard/projects/shared/_common.html.haml
@@ -6,6 +6,9 @@
= render 'dashboard/projects_head', project_tab_filter: :starred
- if params[:filter_projects] || any_projects?(@projects)
- = render 'projects'
+ - if Feature.enabled?(:your_work_projects_vue, current_user)
+ #js-your-work-projects-app
+ - else
+ = render 'projects'
- else
= render empty_page
diff --git a/app/views/shared/_custom_attributes.html.haml b/app/views/shared/_custom_attributes.html.haml
index 33f3ca93b9c..a18077628d9 100644
--- a/app/views/shared/_custom_attributes.html.haml
+++ b/app/views/shared/_custom_attributes.html.haml
@@ -1,8 +1,9 @@
- return unless custom_attributes.present?
-= render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c|
- - c.with_header do
- = link_to(_('Custom Attributes'), help_page_path('api/custom_attributes'))
+= render ::Layouts::CrudComponent.new(_('Custom attributes')) do |c|
+ - c.with_actions do
+ = render Pajamas::ButtonComponent.new(href: help_page_path('api/custom_attributes'), size: :small, category: :tertiary, variant: :confirm) do
+ = _('Learn more')
- c.with_body do
%ul.content-list
- custom_attributes.each do |custom_attribute|
diff --git a/app/views/shared/admin/_admin_note.html.haml b/app/views/shared/admin/_admin_note.html.haml
index 77854439bbb..12a2e43455e 100644
--- a/app/views/shared/admin/_admin_note.html.haml
+++ b/app/views/shared/admin/_admin_note.html.haml
@@ -1,7 +1,5 @@
- if @group.admin_note&.note?
- text = @group.admin_note.note
- = render Pajamas::CardComponent.new(card_options: { class: 'gl-border-blue-500 gl-mb-5' }, header_options: { class: 'gl-bg-blue-500 gl-text-white' }) do |c|
- - c.with_header do
- = s_('Admin|Admin notes')
+ = render ::Layouts::CrudComponent.new(s_('Admin|Admin notes')) do |c|
- c.with_body do
%p= text
diff --git a/app/views/shared/members/_manage_access_button.html.haml b/app/views/shared/members/_manage_access_button.html.haml
index 910d62d4dc4..82ee467f904 100644
--- a/app/views/shared/members/_manage_access_button.html.haml
+++ b/app/views/shared/members/_manage_access_button.html.haml
@@ -1,5 +1,5 @@
- path = local_assigns.fetch(:path, nil)
.gl-float-right
- = link_button_to path, size: :small, icon: 'pencil' do
+ = render Pajamas::ButtonComponent.new(href: path, size: :small) do
= _('Manage access')
diff --git a/config/events/view_admin_background_migrations_pageload.yml b/config/events/view_admin_background_migrations_pageload.yml
new file mode 100644
index 00000000000..fd600dd3b13
--- /dev/null
+++ b/config/events/view_admin_background_migrations_pageload.yml
@@ -0,0 +1,16 @@
+---
+description: Tracks pageviews for the admin background migrations page
+internal_events: true
+action: view_admin_background_migrations_pageload
+identifiers:
+- user
+product_group: personal_productivity
+milestone: '17.2'
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156826
+distributions:
+- ce
+- ee
+tiers:
+- free
+- premium
+- ultimate
diff --git a/config/feature_flags/wip/runners_dashboard_for_groups.yml b/config/feature_flags/wip/runners_dashboard_for_groups.yml
deleted file mode 100644
index f6ad76d6790..00000000000
--- a/config/feature_flags/wip/runners_dashboard_for_groups.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: runners_dashboard_for_groups
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151640
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/459052
-milestone: '17.0'
-type: wip
-group: group::runner
-default_enabled: false
diff --git a/config/feature_flags/wip/your_work_projects_vue.yml b/config/feature_flags/wip/your_work_projects_vue.yml
new file mode 100644
index 00000000000..08f25eb7e22
--- /dev/null
+++ b/config/feature_flags/wip/your_work_projects_vue.yml
@@ -0,0 +1,9 @@
+---
+name: your_work_projects_vue
+feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/13066
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/155472
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/465889
+milestone: '17.1'
+type: wip
+group: group::tenant scale
+default_enabled: false
diff --git a/config/metrics/counts_28d/count_distinct_user_id_from_view_admin_background_migrations_pageload_monthly.yml b/config/metrics/counts_28d/count_distinct_user_id_from_view_admin_background_migrations_pageload_monthly.yml
new file mode 100644
index 00000000000..b8b41e0331e
--- /dev/null
+++ b/config/metrics/counts_28d/count_distinct_user_id_from_view_admin_background_migrations_pageload_monthly.yml
@@ -0,0 +1,22 @@
+---
+key_path: redis_hll_counters.count_distinct_user_id_from_view_admin_background_migrations_pageload_monthly
+description: Monthly count of unique users who visited the admin background migrations page
+product_group: personal_productivity
+performance_indicator_type: []
+value_type: number
+status: active
+milestone: '17.2'
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156826
+time_frame: 28d
+data_source: internal_events
+data_category: optional
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
+events:
+- name: view_admin_background_migrations_pageload
+ unique: user.id
diff --git a/config/metrics/counts_28d/count_total_view_admin_background_migrations_pageload_monthly.yml b/config/metrics/counts_28d/count_total_view_admin_background_migrations_pageload_monthly.yml
new file mode 100644
index 00000000000..a8051809f1e
--- /dev/null
+++ b/config/metrics/counts_28d/count_total_view_admin_background_migrations_pageload_monthly.yml
@@ -0,0 +1,21 @@
+---
+key_path: counts.count_total_view_admin_background_migrations_pageload_monthly
+description: Monthly count of total users who visited the admin background migrations page
+product_group: personal_productivity
+performance_indicator_type: []
+value_type: number
+status: active
+milestone: '17.2'
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156826
+time_frame: 28d
+data_source: internal_events
+data_category: optional
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
+events:
+- name: view_admin_background_migrations_pageload
diff --git a/config/metrics/counts_7d/count_distinct_user_id_from_view_admin_background_migrations_pageload_weekly.yml b/config/metrics/counts_7d/count_distinct_user_id_from_view_admin_background_migrations_pageload_weekly.yml
new file mode 100644
index 00000000000..87a5f2efa46
--- /dev/null
+++ b/config/metrics/counts_7d/count_distinct_user_id_from_view_admin_background_migrations_pageload_weekly.yml
@@ -0,0 +1,22 @@
+---
+key_path: redis_hll_counters.count_distinct_user_id_from_view_admin_background_migrations_pageload_weekly
+description: Weekly count of unique users who visited the admin background migrations page
+product_group: personal_productivity
+performance_indicator_type: []
+value_type: number
+status: active
+milestone: '17.2'
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156826
+time_frame: 7d
+data_source: internal_events
+data_category: optional
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
+events:
+- name: view_admin_background_migrations_pageload
+ unique: user.id
diff --git a/config/metrics/counts_7d/count_total_view_admin_background_migrations_pageload_weekly.yml b/config/metrics/counts_7d/count_total_view_admin_background_migrations_pageload_weekly.yml
new file mode 100644
index 00000000000..835562ff058
--- /dev/null
+++ b/config/metrics/counts_7d/count_total_view_admin_background_migrations_pageload_weekly.yml
@@ -0,0 +1,21 @@
+---
+key_path: counts.count_total_view_admin_background_migrations_pageload_weekly
+description: Weekly count of total users who visited the admin background migrations page
+product_group: personal_productivity
+performance_indicator_type: []
+value_type: number
+status: active
+milestone: '17.2'
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156826
+time_frame: 7d
+data_source: internal_events
+data_category: optional
+distribution:
+- ce
+- ee
+tier:
+- free
+- premium
+- ultimate
+events:
+- name: view_admin_background_migrations_pageload
diff --git a/db/docs/batched_background_migrations/backfill_ml_candidate_metadata_project_id.yml b/db/docs/batched_background_migrations/backfill_ml_candidate_metadata_project_id.yml
new file mode 100644
index 00000000000..1e8db4e7cea
--- /dev/null
+++ b/db/docs/batched_background_migrations/backfill_ml_candidate_metadata_project_id.yml
@@ -0,0 +1,9 @@
+---
+migration_job_name: BackfillMlCandidateMetadataProjectId
+description: Backfills sharding key `ml_candidate_metadata.project_id` from `ml_candidates`.
+feature_category: mlops
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/157484
+milestone: '17.2'
+queued_migration_version: 20240626142206
+finalize_after: '2024-07-22'
+finalized_by: # version of the migration that finalized this BBM
diff --git a/db/docs/ml_candidate_metadata.yml b/db/docs/ml_candidate_metadata.yml
index 406c30bc2fd..2ee851a1ef1 100644
--- a/db/docs/ml_candidate_metadata.yml
+++ b/db/docs/ml_candidate_metadata.yml
@@ -19,3 +19,4 @@ desired_sharding_key:
table: ml_candidates
sharding_key: project_id
belongs_to: candidate
+desired_sharding_key_migration_job_name: BackfillMlCandidateMetadataProjectId
diff --git a/db/docs/project_statistics.yml b/db/docs/project_statistics.yml
index 914ff94e13d..6f01e41cb12 100644
--- a/db/docs/project_statistics.yml
+++ b/db/docs/project_statistics.yml
@@ -10,3 +10,7 @@ milestone: '8.16'
gitlab_schema: gitlab_main_cell
sharding_key:
project_id: projects
+schema_inconsistencies:
+- type: missing_indexes
+ object_name: index_project_statistics_on_wiki_size_and_project_id
+ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/156010
\ No newline at end of file
diff --git a/db/migrate/20240626142202_add_project_id_to_ml_candidate_metadata.rb b/db/migrate/20240626142202_add_project_id_to_ml_candidate_metadata.rb
new file mode 100644
index 00000000000..d0dbb36216c
--- /dev/null
+++ b/db/migrate/20240626142202_add_project_id_to_ml_candidate_metadata.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddProjectIdToMlCandidateMetadata < Gitlab::Database::Migration[2.2]
+ milestone '17.2'
+
+ def change
+ add_column :ml_candidate_metadata, :project_id, :bigint
+ end
+end
diff --git a/db/post_migrate/20240611122408_remove_project_statistics_wiki_size_and_project_id_index.rb b/db/post_migrate/20240611122408_remove_project_statistics_wiki_size_and_project_id_index.rb
new file mode 100644
index 00000000000..762f916b2c7
--- /dev/null
+++ b/db/post_migrate/20240611122408_remove_project_statistics_wiki_size_and_project_id_index.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class RemoveProjectStatisticsWikiSizeAndProjectIdIndex < Gitlab::Database::Migration[2.2]
+ milestone '17.2'
+
+ INDEX_NAME = 'index_project_statistics_on_wiki_size_and_project_id'
+ COLUMNS = %i[wiki_size project_id]
+
+ # TODO: Index to be destroyed synchronously in https://gitlab.com/gitlab-org/gitlab/-/issues/466691
+ def up
+ return unless should_run?
+
+ prepare_async_index_removal :project_statistics, COLUMNS, name: INDEX_NAME
+ end
+
+ def down
+ return unless should_run?
+
+ unprepare_async_index :project_statistics, COLUMNS, name: INDEX_NAME
+ end
+
+ def should_run?
+ Gitlab.com_except_jh?
+ end
+end
diff --git a/db/post_migrate/20240626142203_index_ml_candidate_metadata_on_project_id.rb b/db/post_migrate/20240626142203_index_ml_candidate_metadata_on_project_id.rb
new file mode 100644
index 00000000000..1822882cf7f
--- /dev/null
+++ b/db/post_migrate/20240626142203_index_ml_candidate_metadata_on_project_id.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class IndexMlCandidateMetadataOnProjectId < Gitlab::Database::Migration[2.2]
+ milestone '17.2'
+ disable_ddl_transaction!
+
+ INDEX_NAME = 'index_ml_candidate_metadata_on_project_id'
+
+ def up
+ add_concurrent_index :ml_candidate_metadata, :project_id, name: INDEX_NAME
+ end
+
+ def down
+ remove_concurrent_index_by_name :ml_candidate_metadata, INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20240626142204_add_ml_candidate_metadata_project_id_fk.rb b/db/post_migrate/20240626142204_add_ml_candidate_metadata_project_id_fk.rb
new file mode 100644
index 00000000000..c0caeb917b1
--- /dev/null
+++ b/db/post_migrate/20240626142204_add_ml_candidate_metadata_project_id_fk.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class AddMlCandidateMetadataProjectIdFk < Gitlab::Database::Migration[2.2]
+ milestone '17.2'
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_foreign_key :ml_candidate_metadata, :projects, column: :project_id, on_delete: :cascade
+ end
+
+ def down
+ with_lock_retries do
+ remove_foreign_key :ml_candidate_metadata, column: :project_id
+ end
+ end
+end
diff --git a/db/post_migrate/20240626142205_add_ml_candidate_metadata_project_id_trigger.rb b/db/post_migrate/20240626142205_add_ml_candidate_metadata_project_id_trigger.rb
new file mode 100644
index 00000000000..bd5d3a4e535
--- /dev/null
+++ b/db/post_migrate/20240626142205_add_ml_candidate_metadata_project_id_trigger.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class AddMlCandidateMetadataProjectIdTrigger < Gitlab::Database::Migration[2.2]
+ milestone '17.2'
+
+ def up
+ install_sharding_key_assignment_trigger(
+ table: :ml_candidate_metadata,
+ sharding_key: :project_id,
+ parent_table: :ml_candidates,
+ parent_sharding_key: :project_id,
+ foreign_key: :candidate_id
+ )
+ end
+
+ def down
+ remove_sharding_key_assignment_trigger(
+ table: :ml_candidate_metadata,
+ sharding_key: :project_id,
+ parent_table: :ml_candidates,
+ parent_sharding_key: :project_id,
+ foreign_key: :candidate_id
+ )
+ end
+end
diff --git a/db/post_migrate/20240626142206_queue_backfill_ml_candidate_metadata_project_id.rb b/db/post_migrate/20240626142206_queue_backfill_ml_candidate_metadata_project_id.rb
new file mode 100644
index 00000000000..f2ce8e56d7b
--- /dev/null
+++ b/db/post_migrate/20240626142206_queue_backfill_ml_candidate_metadata_project_id.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class QueueBackfillMlCandidateMetadataProjectId < Gitlab::Database::Migration[2.2]
+ milestone '17.2'
+ restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
+
+ MIGRATION = "BackfillMlCandidateMetadataProjectId"
+ DELAY_INTERVAL = 2.minutes
+ BATCH_SIZE = 1000
+ SUB_BATCH_SIZE = 100
+
+ def up
+ queue_batched_background_migration(
+ MIGRATION,
+ :ml_candidate_metadata,
+ :id,
+ :project_id,
+ :ml_candidates,
+ :project_id,
+ :candidate_id,
+ job_interval: DELAY_INTERVAL,
+ batch_size: BATCH_SIZE,
+ sub_batch_size: SUB_BATCH_SIZE
+ )
+ end
+
+ def down
+ delete_batched_background_migration(
+ MIGRATION,
+ :ml_candidate_metadata,
+ :id,
+ [
+ :project_id,
+ :ml_candidates,
+ :project_id,
+ :candidate_id
+ ]
+ )
+ end
+end
diff --git a/db/schema_migrations/20240611122408 b/db/schema_migrations/20240611122408
new file mode 100644
index 00000000000..b56b566794f
--- /dev/null
+++ b/db/schema_migrations/20240611122408
@@ -0,0 +1 @@
+4704d2511a36e5af924d1b92fcf0c425d6868aa848f29431d8bff6410c5c967e
\ No newline at end of file
diff --git a/db/schema_migrations/20240626142202 b/db/schema_migrations/20240626142202
new file mode 100644
index 00000000000..1e7fec706a2
--- /dev/null
+++ b/db/schema_migrations/20240626142202
@@ -0,0 +1 @@
+6f396093e4cd6ec606540af198fe04853bebd74b51702e06ff171ed0b8c12921
\ No newline at end of file
diff --git a/db/schema_migrations/20240626142203 b/db/schema_migrations/20240626142203
new file mode 100644
index 00000000000..b3fbba139c5
--- /dev/null
+++ b/db/schema_migrations/20240626142203
@@ -0,0 +1 @@
+1e15fef4845db49e85604b7e959fda58bcaa623a6408620f7d0a9d882c26438a
\ No newline at end of file
diff --git a/db/schema_migrations/20240626142204 b/db/schema_migrations/20240626142204
new file mode 100644
index 00000000000..6cf92f600bf
--- /dev/null
+++ b/db/schema_migrations/20240626142204
@@ -0,0 +1 @@
+5f2cf06eb8a34e95b71704fa544e8e1eb0996435dea2c7c892e850590b42777b
\ No newline at end of file
diff --git a/db/schema_migrations/20240626142205 b/db/schema_migrations/20240626142205
new file mode 100644
index 00000000000..36fd719da2b
--- /dev/null
+++ b/db/schema_migrations/20240626142205
@@ -0,0 +1 @@
+42601d461c570aa82234d58db59784158f044f0bafdbe6b83855123d21bf61e9
\ No newline at end of file
diff --git a/db/schema_migrations/20240626142206 b/db/schema_migrations/20240626142206
new file mode 100644
index 00000000000..923d820dd7b
--- /dev/null
+++ b/db/schema_migrations/20240626142206
@@ -0,0 +1 @@
+06dda6411338c7bdd342da847d11ba8512279f09e85b4dfa26991fbb8184b3e0
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 5bd4f85fffd..883cfcb44b9 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -934,6 +934,22 @@ RETURN NEW;
END
$$;
+CREATE FUNCTION trigger_25d35f02ab55() RETURNS trigger
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+IF NEW."project_id" IS NULL THEN
+ SELECT "project_id"
+ INTO NEW."project_id"
+ FROM "ml_candidates"
+ WHERE "ml_candidates"."id" = NEW."candidate_id";
+END IF;
+
+RETURN NEW;
+
+END
+$$;
+
CREATE FUNCTION trigger_25fe4f7da510() RETURNS trigger
LANGUAGE plpgsql
AS $$
@@ -12314,7 +12330,7 @@ CREATE TABLE merge_request_context_commits (
message text,
merge_request_id bigint,
trailers jsonb DEFAULT '{}'::jsonb NOT NULL,
- project_id bigint
+ project_id bigint,
CONSTRAINT check_1dc6b5f2ac CHECK ((merge_request_id IS NOT NULL))
);
@@ -12764,6 +12780,7 @@ CREATE TABLE ml_candidate_metadata (
candidate_id bigint NOT NULL,
name text NOT NULL,
value text NOT NULL,
+ project_id bigint,
CONSTRAINT check_6b38a286a5 CHECK ((char_length(name) <= 255)),
CONSTRAINT check_9453f4a8e9 CHECK ((char_length(value) <= 5000))
);
@@ -27701,6 +27718,8 @@ CREATE UNIQUE INDEX index_ml_candidate_metadata_on_candidate_id_and_name ON ml_c
CREATE INDEX index_ml_candidate_metadata_on_name ON ml_candidate_metadata USING btree (name);
+CREATE INDEX index_ml_candidate_metadata_on_project_id ON ml_candidate_metadata USING btree (project_id);
+
CREATE INDEX index_ml_candidate_metrics_on_candidate_id ON ml_candidate_metrics USING btree (candidate_id);
CREATE INDEX index_ml_candidate_params_on_candidate_id ON ml_candidate_params USING btree (candidate_id);
@@ -31389,6 +31408,8 @@ CREATE TRIGGER trigger_2514245c7fc5 BEFORE INSERT OR UPDATE ON dast_site_profile
CREATE TRIGGER trigger_25c44c30884f BEFORE INSERT OR UPDATE ON work_item_parent_links FOR EACH ROW EXECUTE FUNCTION trigger_25c44c30884f();
+CREATE TRIGGER trigger_25d35f02ab55 BEFORE INSERT OR UPDATE ON ml_candidate_metadata FOR EACH ROW EXECUTE FUNCTION trigger_25d35f02ab55();
+
CREATE TRIGGER trigger_25fe4f7da510 BEFORE INSERT OR UPDATE ON vulnerability_issue_links FOR EACH ROW EXECUTE FUNCTION trigger_25fe4f7da510();
CREATE TRIGGER trigger_2ac3d66ed1d3 BEFORE INSERT OR UPDATE ON vulnerability_occurrence_pipelines FOR EACH ROW EXECUTE FUNCTION trigger_2ac3d66ed1d3();
@@ -32492,6 +32513,9 @@ ALTER TABLE ONLY approval_merge_request_rules
ALTER TABLE ONLY fork_network_members
ADD CONSTRAINT fk_b01280dae4 FOREIGN KEY (forked_from_project_id) REFERENCES projects(id) ON DELETE SET NULL;
+ALTER TABLE ONLY ml_candidate_metadata
+ ADD CONSTRAINT fk_b044692715 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY sbom_occurrences
ADD CONSTRAINT fk_b1b65d8d17 FOREIGN KEY (source_package_id) REFERENCES sbom_source_packages(id) ON DELETE CASCADE;
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 940b1102bed..424663be707 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -18082,7 +18082,7 @@ CI/CD variables for a project.
| `status` | [`CiRunnerStatus!`](#cirunnerstatus) | Status of the runner. |
| `tagList` | [`[String!]`](#string) | Tags associated with the runner. |
| `tokenExpiresAt` | [`Time`](#time) | Runner token expiration time. |
-| `upgradeStatus` **{warning-solid}** | [`CiRunnerUpgradeStatus`](#cirunnerupgradestatus) | **Introduced** in GitLab 14.10. **Status**: Experiment. Availability of upgrades for the runner. |
+| `upgradeStatus` | [`CiRunnerUpgradeStatus`](#cirunnerupgradestatus) | Availability of upgrades for the runner. |
| `userPermissions` | [`RunnerPermissions!`](#runnerpermissions) | Permissions for the current user on the resource. |
#### Fields with arguments
diff --git a/doc/ci/yaml/ci_job_log_timestamps.md b/doc/ci/yaml/ci_job_log_timestamps.md
index 8114b188ff4..183641f0fcd 100644
--- a/doc/ci/yaml/ci_job_log_timestamps.md
+++ b/doc/ci/yaml/ci_job_log_timestamps.md
@@ -12,6 +12,7 @@ DETAILS:
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/455582) in GitLab 17.1 [with flags](../../administration/feature_flags.md) named `FF_TIMESTAMPS` and `parse_ci_job_timestamps`. Disabled by default.
> - `parse_ci_job_timestamps` [enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/455581) in GitLab 17.1.
+> - [Generally available](https://gitlab.com/gitlab-org/gitlab/-/issues/464785) in GitLab 17.2. Feature flag `parse_ci_job_timestamps` removed.
FLAG:
The availability of this feature is controlled by feature flags.
@@ -22,8 +23,6 @@ Prerequisites:
- You must be on GitLab Runner 17.0 and later.
- An administrator must enable the `FF_TIMESTAMPS`
[feature flag](../../administration/feature_flags.md) in `.gitlab-ci.yml`.
-- On self-managed GitLab, an administrator must enable
- the `parse_ci_job_timestamps` feature flag.
This feature generates a timestamp in the
[ISO 8601 format](https://www.iso.org/iso-8601-date-and-time-format.html) for each line in the CI log.
diff --git a/doc/integration/jira/connect-app.md b/doc/integration/jira/connect-app.md
index ea0073f8783..03a354bfb86 100644
--- a/doc/integration/jira/connect-app.md
+++ b/doc/integration/jira/connect-app.md
@@ -92,7 +92,7 @@ To configure the GitLab for Jira Cloud app:
1. Optional. To link a self-managed GitLab instance with Jira, select **Change GitLab version**.
1. Select all checkboxes, then select **Next**.
1. Enter your **GitLab instance URL**, then select **Save**.
-1. Select **Sign in to GitLab**, then enter your credentials.
+1. Select **Sign in to GitLab**, then enter your username and password. The integration doesn't support single-sign on such as SAML during configuration.
1. Select **Authorize**. A list of groups is now visible.
1. Select **Link groups**.
1. To link to a group, select **Link**.
diff --git a/doc/user/organization/index.md b/doc/user/organization/index.md
index 079bd0c333b..2aefe87f979 100644
--- a/doc/user/organization/index.md
+++ b/doc/user/organization/index.md
@@ -81,7 +81,7 @@ To switch organizations:
1. On the left sidebar, select **Organizations** and find the organization you want to manage.
1. Select **Manage > Groups and projects**.
1. Optional. Filter the results:
- - To search for specific groups or projects, in the search box enter your search term.
+ - To search for specific groups or projects, in the search box enter your search term (minimum three characters).
- To view only groups or projects, from the **Display** dropdown list select an option.
1. Optional. To sort the results by name, date created, or date updated, from the dropdown list select an option. Then select ascending (**{sort-lowest}**) or descending (**{sort-highest}**) order.
diff --git a/lib/gitlab/background_migration/backfill_ml_candidate_metadata_project_id.rb b/lib/gitlab/background_migration/backfill_ml_candidate_metadata_project_id.rb
new file mode 100644
index 00000000000..1d6fe2021c4
--- /dev/null
+++ b/lib/gitlab/background_migration/backfill_ml_candidate_metadata_project_id.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module BackgroundMigration
+ class BackfillMlCandidateMetadataProjectId < BackfillDesiredShardingKeyJob
+ operation_name :backfill_ml_candidate_metadata_project_id
+ feature_category :mlops
+ end
+ end
+end
diff --git a/lib/tasks/gitlab/tw/codeowners.rake b/lib/tasks/gitlab/tw/codeowners.rake
index 37d26a473dd..da2f34d73f3 100644
--- a/lib/tasks/gitlab/tw/codeowners.rake
+++ b/lib/tasks/gitlab/tw/codeowners.rake
@@ -90,6 +90,7 @@ namespace :tw do
CONTRIBUTOR_DOCS_PATH = '/doc/development/'
CONTRIBUTOR_DOCS_CODE_OWNER_RULES = [
+ CodeOwnerRule.new('AI-powered', '@gitlab-org/ai-powered'),
CodeOwnerRule.new('Analytics Instrumentation',
'@gitlab-org/analytics-section/product-analytics/engineers/frontend ' \
'@gitlab-org/analytics-section/analytics-instrumentation/engineers'),
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b9076fca07d..31f27cb5004 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -832,9 +832,6 @@ msgstr ""
msgid "%{group_name} activity"
msgstr ""
-msgid "%{group_name} group members"
-msgstr ""
-
msgid "%{group_name} is approaching the limit of available seats"
msgstr ""
@@ -16079,6 +16076,9 @@ msgstr ""
msgid "Custom analyzers: language support"
msgstr ""
+msgid "Custom attributes"
+msgstr ""
+
msgid "Custom confirmation message: %{message}"
msgstr ""
@@ -19765,6 +19765,9 @@ msgstr ""
msgid "Edit wiki page"
msgstr ""
+msgid "Edit your criteria and try again."
+msgstr ""
+
msgid "Edit your most recent comment in a thread (from an empty textarea)"
msgstr ""
@@ -25077,9 +25080,6 @@ msgstr ""
msgid "Group import requests"
msgstr ""
-msgid "Group info:"
-msgstr ""
-
msgid "Group information"
msgstr ""
@@ -34940,6 +34940,9 @@ msgstr ""
msgid "No project subscribes to the pipelines in this project."
msgstr ""
+msgid "No projects"
+msgstr ""
+
msgid "No projects available."
msgstr ""
@@ -42158,6 +42161,9 @@ msgstr ""
msgid "Projects in this group can use Git LFS"
msgstr ""
+msgid "Projects list"
+msgstr ""
+
msgid "Projects shared with %{group_name}"
msgstr ""
@@ -47703,6 +47709,12 @@ msgstr ""
msgid "SecurityOrchestration|Branch: %{boldStart}%{branchName}%{boldEnd} was not found in project: %{boldStart}%{projectName}%{boldEnd}. Edit or remove this entry."
msgstr ""
+msgid "SecurityOrchestration|CI/CD template edition to be enforced. The default template is stable, but may not have all the features of the latest template."
+msgstr ""
+
+msgid "SecurityOrchestration|CI/CD template edition to be enforced. TheĀ latestĀ edition may introduce breaking changes."
+msgstr ""
+
msgid "SecurityOrchestration|Cadence is invalid"
msgstr ""
@@ -48083,6 +48095,9 @@ msgstr ""
msgid "SecurityOrchestration|Security Scan"
msgstr ""
+msgid "SecurityOrchestration|Security job template"
+msgstr ""
+
msgid "SecurityOrchestration|Security policy overwrites this setting"
msgstr ""
@@ -48368,6 +48383,9 @@ msgstr ""
msgid "SecurityOrchestration|compliance frameworks"
msgstr ""
+msgid "SecurityOrchestration|default"
+msgstr ""
+
msgid "SecurityOrchestration|except projects"
msgstr ""
@@ -48383,6 +48401,9 @@ msgstr ""
msgid "SecurityOrchestration|have no fix available"
msgstr ""
+msgid "SecurityOrchestration|latest"
+msgstr ""
+
msgid "SecurityOrchestration|more than %{allowed}"
msgstr ""
diff --git a/package.json b/package.json
index b6f884c8729..6e670ff7541 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,7 @@
"@gitlab/favicon-overlay": "2.0.0",
"@gitlab/fonts": "^1.3.0",
"@gitlab/svgs": "3.103.0",
- "@gitlab/ui": "85.3.0",
+ "@gitlab/ui": "85.4.1",
"@gitlab/web-ide": "^0.0.1-dev-20240613133550",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.0.8-4",
diff --git a/qa/qa/page/dashboard/projects.rb b/qa/qa/page/dashboard/projects.rb
index 437877b5e63..3d532b037d0 100644
--- a/qa/qa/page/dashboard/projects.rb
+++ b/qa/qa/page/dashboard/projects.rb
@@ -61,8 +61,15 @@ module QA
private
def has_project_with_access_role?(project_name, access_role)
- within_element('project-content', text: project_name) do
- has_element?('user-access-role', text: access_role)
+ # Since we are very early in the Vue migration, there isn't much value in testing
+ # when the feature flag is enabled.
+ # Please see https://gitlab.com/gitlab-org/gitlab/-/issues/466081 for tracking revisiting this.
+ if Runtime::Feature.enabled?(:your_work_projects_vue)
+ has_text?('Projects list')
+ else
+ within_element('project-content', text: project_name) do
+ has_element?('user-access-role', text: access_role)
+ end
end
end
end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index 1d8f768b22a..b740382b2c7 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -110,7 +110,7 @@ RSpec.describe 'Admin Groups', feature_category: :groups_and_projects do
visit admin_group_path(group)
- expect(page).to have_content("Group: #{group.name}")
+ expect(page).to have_content group.name
expect(page).to have_content("ID: #{group.id}")
end
diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb
index 27a9b8296d9..d6aaa05f61a 100644
--- a/spec/features/admin/users/users_spec.rb
+++ b/spec/features/admin/users/users_spec.rb
@@ -499,7 +499,7 @@ RSpec.describe 'Admin::Users', feature_category: :user_management do
within(:css, '.gl-mb-3 + .gl-card') do
click_link group.name
end
- expect(page).to have_content "Group: #{group.name}"
+ expect(page).to have_content group.name
expect(page).to have_content project.name
end
diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb
index a1b9cc46fb3..d8364ec0944 100644
--- a/spec/features/dashboard/projects_spec.rb
+++ b/spec/features/dashboard/projects_spec.rb
@@ -12,244 +12,270 @@ RSpec.describe 'Dashboard Projects', :js, feature_category: :groups_and_projects
sign_in(user)
end
- it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" do
+ context 'when feature :your_work_projects_vue is enabled' do
before do
+ stub_feature_flags(your_work_projects_vue: true)
+ end
+
+ it 'mounts JS app' do
visit dashboard_projects_path
+
+ expect(page).to have_content('Projects')
+ expect(page).to have_content('Projects list')
end
end
- it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_projects_path, :projects
+ context 'when feature :your_work_projects_vue is disabled' do
+ before do
+ stub_feature_flags(your_work_projects_vue: false)
+ end
- it 'links to the "Explore projects" page' do
- visit dashboard_projects_path
-
- expect(page).to have_link("Explore projects", href: starred_explore_projects_path)
- end
-
- context 'when user has access to the project' do
- it 'shows role badge' do
+ it 'does not mount JS app' do
visit dashboard_projects_path
- within_testid('user-access-role') do
- expect(page).to have_content('Developer')
+ expect(page).to have_content('Projects')
+ expect(page).not_to have_content('Projects list')
+ end
+
+ it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" do
+ before do
+ visit dashboard_projects_path
end
end
- context 'when role changes', :use_clean_rails_memory_store_fragment_caching do
- it 'displays the right role' do
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :dashboard_projects_path, :projects
+
+ it 'links to the "Explore projects" page' do
+ visit dashboard_projects_path
+
+ expect(page).to have_link("Explore projects", href: starred_explore_projects_path)
+ end
+
+ context 'when user has access to the project' do
+ it 'shows role badge' do
visit dashboard_projects_path
within_testid('user-access-role') do
expect(page).to have_content('Developer')
end
+ end
- project.members.last.update!(access_level: 40)
+ context 'when role changes', :use_clean_rails_memory_store_fragment_caching do
+ it 'displays the right role' do
+ visit dashboard_projects_path
- visit dashboard_projects_path
+ within_testid('user-access-role') do
+ expect(page).to have_content('Developer')
+ end
- within_testid('user-access-role') do
- expect(page).to have_content('Maintainer')
+ project.members.last.update!(access_level: 40)
+
+ visit dashboard_projects_path
+
+ within_testid('user-access-role') do
+ expect(page).to have_content('Maintainer')
+ end
end
end
end
- end
- context 'when last_activity_at and update_at are present' do
- it 'shows the last_activity_at attribute as the update date' do
- project.update!(last_repository_updated_at: 1.hour.ago, last_activity_at: Time.zone.now)
+ context 'when last_activity_at and update_at are present' do
+ it 'shows the last_activity_at attribute as the update date' do
+ project.update!(last_repository_updated_at: 1.hour.ago, last_activity_at: Time.zone.now)
- visit dashboard_projects_path
+ visit dashboard_projects_path
- expect(page).to have_xpath("//time[@datetime='#{project.last_activity_at.getutc.iso8601}']")
- end
- end
-
- context 'when last_activity_at is missing' do
- it 'shows the updated_at attribute as the update date' do
- project.update!(last_activity_at: nil)
- project.touch
-
- visit dashboard_projects_path
-
- expect(page).to have_xpath("//time[@datetime='#{project.updated_at.getutc.iso8601}']")
- end
- end
-
- context 'when on Your projects tab' do
- it 'shows all projects by default' do
- visit dashboard_projects_path
-
- expect(page).to have_content(project.name)
- expect(find('.gl-tabs-nav li:nth-child(1) .badge-pill')).to have_content(1)
- end
-
- it 'shows personal projects on personal projects tab' do
- project3 = create(:project, namespace: user.namespace)
-
- visit dashboard_projects_path
-
- click_link 'Personal'
-
- expect(page).not_to have_content(project.name)
- expect(page).to have_content(project3.name)
- end
-
- it 'sorts projects by most stars when sorting by most stars' do
- project_with_most_stars = create(:project, namespace: user.namespace, star_count: 10)
-
- visit dashboard_projects_path(sort: :stars_desc)
-
- expect(first('.project-row')).to have_content(project_with_most_stars.title)
- end
- end
-
- context 'when on Starred projects tab' do
- it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :starred_dashboard_projects_path, :projects
-
- it 'shows the empty state when there are no starred projects' do
- visit(starred_dashboard_projects_path)
-
- expect(page).to have_text(s_("StarredProjectsEmptyState|You don't have starred projects yet."))
- end
-
- it 'shows only starred projects' do
- user.toggle_star(project2)
-
- visit(starred_dashboard_projects_path)
-
- expect(page).not_to have_content(project.name)
- expect(page).to have_content(project2.name)
- expect(find('.gl-tabs-nav li:nth-child(1) .badge-pill')).to have_content(1)
- expect(find('.gl-tabs-nav li:nth-child(2) .badge-pill')).to have_content(1)
- end
-
- it 'does not show tabs to filter by all projects or personal' do
- visit(starred_dashboard_projects_path)
-
- expect(page).not_to have_content '.filtered-search-nav'
- end
- end
-
- describe 'with a pipeline', :clean_gitlab_redis_shared_state do
- let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha, ref: project.default_branch) }
-
- before do
- # Since the cache isn't updated when a new pipeline is created
- # we need the pipeline to advance in the pipeline since the cache was created
- # by visiting the login page.
- pipeline.succeed
- end
-
- it 'shows that the last pipeline passed' do
- visit dashboard_projects_path
-
- within_testid('project_controls') do
- expect(page).to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
- expect(page).to have_css("[data-testid='ci-icon']")
- expect(page).to have_css('[data-testid="status_success_borderless-icon"]')
- expect(page).to have_link('Pipeline: passed')
+ expect(page).to have_xpath("//time[@datetime='#{project.last_activity_at.getutc.iso8601}']")
end
end
- shared_examples 'hidden pipeline status' do
- it 'does not show the pipeline status' do
+ context 'when last_activity_at is missing' do
+ it 'shows the updated_at attribute as the update date' do
+ project.update!(last_activity_at: nil)
+ project.touch
+
+ visit dashboard_projects_path
+
+ expect(page).to have_xpath("//time[@datetime='#{project.updated_at.getutc.iso8601}']")
+ end
+ end
+
+ context 'when on Your projects tab' do
+ it 'shows all projects by default' do
+ visit dashboard_projects_path
+
+ expect(page).to have_content(project.name)
+ expect(find('.gl-tabs-nav li:nth-child(1) .badge-pill')).to have_content(1)
+ end
+
+ it 'shows personal projects on personal projects tab' do
+ project3 = create(:project, namespace: user.namespace)
+
+ visit dashboard_projects_path
+
+ click_link 'Personal'
+
+ expect(page).not_to have_content(project.name)
+ expect(page).to have_content(project3.name)
+ end
+
+ it 'sorts projects by most stars when sorting by most stars' do
+ project_with_most_stars = create(:project, namespace: user.namespace, star_count: 10)
+
+ visit dashboard_projects_path(sort: :stars_desc)
+
+ expect(first('.project-row')).to have_content(project_with_most_stars.title)
+ end
+ end
+
+ context 'when on Starred projects tab' do
+ it_behaves_like 'a "Your work" page with sidebar and breadcrumbs', :starred_dashboard_projects_path, :projects
+
+ it 'shows the empty state when there are no starred projects' do
+ visit(starred_dashboard_projects_path)
+
+ expect(page).to have_text(s_("StarredProjectsEmptyState|You don't have starred projects yet."))
+ end
+
+ it 'shows only starred projects' do
+ user.toggle_star(project2)
+
+ visit(starred_dashboard_projects_path)
+
+ expect(page).not_to have_content(project.name)
+ expect(page).to have_content(project2.name)
+ expect(find('.gl-tabs-nav li:nth-child(1) .badge-pill')).to have_content(1)
+ expect(find('.gl-tabs-nav li:nth-child(2) .badge-pill')).to have_content(1)
+ end
+
+ it 'does not show tabs to filter by all projects or personal' do
+ visit(starred_dashboard_projects_path)
+
+ expect(page).not_to have_content '.filtered-search-nav'
+ end
+ end
+
+ describe 'with a pipeline', :clean_gitlab_redis_shared_state do
+ let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha, ref: project.default_branch) }
+
+ before do
+ # Since the cache isn't updated when a new pipeline is created
+ # we need the pipeline to advance in the pipeline since the cache was created
+ # by visiting the login page.
+ pipeline.succeed
+ end
+
+ it 'shows that the last pipeline passed' do
visit dashboard_projects_path
within_testid('project_controls') do
- expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
- expect(page).not_to have_css("[data-testid='ci-icon']")
- expect(page).not_to have_css('[data-testid="status_success_borderless-icon"]')
- expect(page).not_to have_link('Pipeline: passed')
+ expect(page).to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
+ expect(page).to have_css("[data-testid='ci-icon']")
+ expect(page).to have_css('[data-testid="status_success_borderless-icon"]')
+ expect(page).to have_link('Pipeline: passed')
+ end
+ end
+
+ shared_examples 'hidden pipeline status' do
+ it 'does not show the pipeline status' do
+ visit dashboard_projects_path
+
+ within_testid('project_controls') do
+ expect(page).not_to have_xpath("//a[@href='#{pipelines_project_commit_path(project, project.commit, ref: pipeline.ref)}']")
+ expect(page).not_to have_css("[data-testid='ci-icon']")
+ expect(page).not_to have_css('[data-testid="status_success_borderless-icon"]')
+ expect(page).not_to have_link('Pipeline: passed')
+ end
+ end
+ end
+
+ context 'guest user of project and project has private pipelines' do
+ let(:guest_user) { create(:user) }
+
+ before do
+ project.update!(public_builds: false)
+ project.add_guest(guest_user)
+ sign_in(guest_user)
+ end
+
+ it_behaves_like 'hidden pipeline status'
+ end
+
+ context "when last_pipeline is missing" do
+ before do
+ project.last_pipeline.delete
+ end
+
+ it_behaves_like 'hidden pipeline status'
+ end
+ end
+
+ describe 'with topics' do
+ context 'when project has topics' do
+ before do
+ project.update_attribute(:topic_list, 'topic1')
+ end
+
+ it 'shows project topics if exist' do
+ visit dashboard_projects_path
+
+ expect(page).to have_selector('[data-testid="project_topic_list"]')
+ expect(page).to have_link('topic1', href: topic_explore_projects_path(topic_name: 'topic1'))
+ end
+ end
+
+ context 'when project does not have topics' do
+ it 'does not show project topics' do
+ visit dashboard_projects_path
+
+ expect(page).not_to have_selector('[data-testid="project_topic_list"]')
end
end
end
- context 'guest user of project and project has private pipelines' do
- let(:guest_user) { create(:user) }
-
+ context 'last push widget', :use_clean_rails_memory_store_caching do
before do
- project.update!(public_builds: false)
- project.add_guest(guest_user)
- sign_in(guest_user)
- end
+ event = create(:push_event, project: project, author: user)
- it_behaves_like 'hidden pipeline status'
- end
+ create(:push_event_payload, event: event, ref: 'feature', action: :created)
- context "when last_pipeline is missing" do
- before do
- project.last_pipeline.delete
- end
+ Users::LastPushEventService.new(user).cache_last_push_event(event)
- it_behaves_like 'hidden pipeline status'
- end
- end
-
- describe 'with topics' do
- context 'when project has topics' do
- before do
- project.update_attribute(:topic_list, 'topic1')
- end
-
- it 'shows project topics if exist' do
visit dashboard_projects_path
+ end
- expect(page).to have_selector('[data-testid="project_topic_list"]')
- expect(page).to have_link('topic1', href: topic_explore_projects_path(topic_name: 'topic1'))
+ it 'shows "Create merge request" button' do
+ expect(page).to have_content 'You pushed to feature'
+
+ within('#content-body') do
+ find_link('Create merge request', visible: false).click
+ end
+
+ expect(page).to have_selector('.merge-request-form')
+ expect(page).to have_current_path project_new_merge_request_path(project), ignore_query: true
+ expect(find('#merge_request_target_project_id', visible: false).value).to eq project.id.to_s
+ expect(page).to have_content "From feature into master"
end
end
- context 'when project does not have topics' do
- it 'does not show project topics' do
- visit dashboard_projects_path
-
- expect(page).not_to have_selector('[data-testid="project_topic_list"]')
- end
- end
- end
-
- context 'last push widget', :use_clean_rails_memory_store_caching do
- before do
- event = create(:push_event, project: project, author: user)
-
- create(:push_event_payload, event: event, ref: 'feature', action: :created)
-
- Users::LastPushEventService.new(user).cache_last_push_event(event)
-
+ it 'avoids an N+1 query in dashboard index' do
+ create(:ci_pipeline, :with_job, status: :success, project: project, ref: project.default_branch, sha: project.commit.sha)
visit dashboard_projects_path
+
+ control = ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }
+
+ new_project = create(:project, :repository, name: 'new project')
+ create(:ci_pipeline, :with_job, status: :success, project: new_project, ref: new_project.commit.sha)
+ new_project.add_developer(user)
+
+ ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }.count
+
+ # There are a few known N+1 queries: https://gitlab.com/gitlab-org/gitlab/-/issues/214037
+ # - User#max_member_access_for_project_ids
+ # - ProjectsHelper#load_pipeline_status / Ci::CommitWithPipeline#last_pipeline
+ # - Ci::Pipeline#detailed_status
+
+ expect { visit dashboard_projects_path }.not_to exceed_query_limit(control).with_threshold(4)
end
-
- it 'shows "Create merge request" button' do
- expect(page).to have_content 'You pushed to feature'
-
- within('#content-body') do
- find_link('Create merge request', visible: false).click
- end
-
- expect(page).to have_selector('.merge-request-form')
- expect(page).to have_current_path project_new_merge_request_path(project), ignore_query: true
- expect(find('#merge_request_target_project_id', visible: false).value).to eq project.id.to_s
- expect(page).to have_content "From feature into master"
- end
- end
-
- it 'avoids an N+1 query in dashboard index' do
- create(:ci_pipeline, :with_job, status: :success, project: project, ref: project.default_branch, sha: project.commit.sha)
- visit dashboard_projects_path
-
- control = ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }
-
- new_project = create(:project, :repository, name: 'new project')
- create(:ci_pipeline, :with_job, status: :success, project: new_project, ref: new_project.commit.sha)
- new_project.add_developer(user)
-
- ActiveRecord::QueryRecorder.new { visit dashboard_projects_path }.count
-
- # There are a few known N+1 queries: https://gitlab.com/gitlab-org/gitlab/-/issues/214037
- # - User#max_member_access_for_project_ids
- # - ProjectsHelper#load_pipeline_status / Ci::CommitWithPipeline#last_pipeline
- # - Ci::Pipeline#detailed_status
-
- expect { visit dashboard_projects_path }.not_to exceed_query_limit(control).with_threshold(4)
end
end
diff --git a/spec/frontend/organizations/groups_and_projects/components/app_spec.js b/spec/frontend/organizations/groups_and_projects/components/app_spec.js
index 8d17acd8a10..91f39afb42d 100644
--- a/spec/frontend/organizations/groups_and_projects/components/app_spec.js
+++ b/spec/frontend/organizations/groups_and_projects/components/app_spec.js
@@ -146,17 +146,47 @@ describe('GroupsAndProjectsApp', () => {
});
describe('when filtered search bar is submitted', () => {
- const searchTerm = 'foo bar';
+ describe('when search term is 3 characters or more', () => {
+ const searchTerm = 'foo bar';
- beforeEach(() => {
- createComponent();
+ beforeEach(() => {
+ createComponent();
- findFilteredSearchAndSort().vm.$emit('filter', { [FILTERED_SEARCH_TERM_KEY]: searchTerm });
+ findFilteredSearchAndSort().vm.$emit('filter', { [FILTERED_SEARCH_TERM_KEY]: searchTerm });
+ });
+
+ it(`updates \`${FILTERED_SEARCH_TERM_KEY}\` query string`, () => {
+ expect(routerMock.push).toHaveBeenCalledWith({
+ query: { [FILTERED_SEARCH_TERM_KEY]: searchTerm },
+ });
+ });
});
- it(`updates \`${FILTERED_SEARCH_TERM_KEY}\` query string`, () => {
- expect(routerMock.push).toHaveBeenCalledWith({
- query: { [FILTERED_SEARCH_TERM_KEY]: searchTerm },
+ describe('when search term is less than 3 characters', () => {
+ const searchTerm = 'fo';
+
+ beforeEach(() => {
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('filter', { [FILTERED_SEARCH_TERM_KEY]: searchTerm });
+ });
+
+ it('does not update query string', () => {
+ expect(routerMock.push).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when search term is empty but there are other filters', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findFilteredSearchAndSort().vm.$emit('filter', { foo: 'bar' });
+ });
+
+ it('updates query string', () => {
+ expect(routerMock.push).toHaveBeenCalledWith({
+ query: { foo: 'bar' },
+ });
});
});
});
diff --git a/spec/frontend/organizations/shared/components/groups_and_projects_empty_state_spec.js b/spec/frontend/organizations/shared/components/groups_and_projects_empty_state_spec.js
new file mode 100644
index 00000000000..19cec0b46fb
--- /dev/null
+++ b/spec/frontend/organizations/shared/components/groups_and_projects_empty_state_spec.js
@@ -0,0 +1,58 @@
+import { GlEmptyState } from '@gitlab/ui';
+import emptySearchSvgPath from '@gitlab/svgs/dist/illustrations/empty-state/empty-search-md.svg';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import GroupsAndProjectsEmptyState from '~/organizations/shared/components/groups_and_projects_empty_state.vue';
+
+describe('GroupsAndProjectsEmptyState', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ svgPath: 'path/to/svg',
+ title: 'No results',
+ description: 'Try again',
+ search: '',
+ };
+
+ const createComponent = ({ propsData = {}, scopedSlots = {} } = {}) => {
+ wrapper = shallowMountExtended(GroupsAndProjectsEmptyState, {
+ propsData: { ...defaultPropsData, ...propsData },
+ scopedSlots,
+ });
+ };
+
+ describe('when search is empty', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlEmptyState component with passed props', () => {
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ title: defaultPropsData.title,
+ description: defaultPropsData.description,
+ svgPath: defaultPropsData.svgPath,
+ svgHeight: 144,
+ });
+ });
+ });
+
+ describe('when search is not empty', () => {
+ beforeEach(() => {
+ createComponent({ propsData: { search: 'foo' } });
+ });
+
+ it('renders GlEmptyState component with no results found message', () => {
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ title: 'No results found',
+ description: 'Edit your criteria and try again.',
+ svgPath: emptySearchSvgPath,
+ svgHeight: 144,
+ });
+ });
+ });
+
+ it('renders actions slot', () => {
+ createComponent({ scopedSlots: { actions: '' } });
+
+ expect(wrapper.findByTestId('actions-slot').exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/organizations/shared/components/groups_view_spec.js b/spec/frontend/organizations/shared/components/groups_view_spec.js
index e51176ea79f..2cb3b160357 100644
--- a/spec/frontend/organizations/shared/components/groups_view_spec.js
+++ b/spec/frontend/organizations/shared/components/groups_view_spec.js
@@ -1,10 +1,11 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
-import { GlEmptyState, GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
+import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
import organizationGroupsGraphQlResponse from 'test_fixtures/graphql/organizations/groups.query.graphql.json';
import GroupsView from '~/organizations/shared/components/groups_view.vue';
import { SORT_DIRECTION_ASC, SORT_ITEM_NAME } from '~/organizations/shared/constants';
import NewGroupButton from '~/organizations/shared/components/new_group_button.vue';
+import GroupsAndProjectsEmptyState from '~/organizations/shared/components/groups_and_projects_empty_state.vue';
import {
renderDeleteSuccessToast,
deleteParams,
@@ -142,12 +143,12 @@ describe('GroupsView', () => {
await waitForPromises();
- expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ expect(wrapper.findComponent(GroupsAndProjectsEmptyState).props()).toMatchObject({
title: "You don't have any groups yet.",
description:
'A group is a collection of several projects. If you organize your projects under a group, it works like a folder.',
- svgHeight: 144,
svgPath: defaultProvide.groupsEmptyStateSvgPath,
+ search: 'foo',
});
expect(findNewGroupButton().exists()).toBe(shouldShowEmptyStateButtons);
diff --git a/spec/frontend/organizations/shared/components/projects_view_spec.js b/spec/frontend/organizations/shared/components/projects_view_spec.js
index 4919ed93756..c60f5d6bcd4 100644
--- a/spec/frontend/organizations/shared/components/projects_view_spec.js
+++ b/spec/frontend/organizations/shared/components/projects_view_spec.js
@@ -1,10 +1,11 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
-import { GlLoadingIcon, GlEmptyState, GlKeysetPagination } from '@gitlab/ui';
+import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui';
import organizationProjectsGraphQlResponse from 'test_fixtures/graphql/organizations/projects.query.graphql.json';
import ProjectsView from '~/organizations/shared/components/projects_view.vue';
import { SORT_DIRECTION_ASC, SORT_ITEM_NAME } from '~/organizations/shared/constants';
import NewProjectButton from '~/organizations/shared/components/new_project_button.vue';
+import GroupsAndProjectsEmptyState from '~/organizations/shared/components/groups_and_projects_empty_state.vue';
import projectsQuery from '~/organizations/shared/graphql/queries/projects.query.graphql';
import {
renderDeleteSuccessToast,
@@ -95,7 +96,7 @@ describe('ProjectsView', () => {
const findPagination = () => wrapper.findComponent(GlKeysetPagination);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findEmptyState = () => wrapper.findComponent(GroupsAndProjectsEmptyState);
const findProjectsList = () => wrapper.findComponent(ProjectsList);
const findProjectsListProjectById = (projectId) =>
findProjectsList()
@@ -149,8 +150,8 @@ describe('ProjectsView', () => {
title: "You don't have any projects yet.",
description:
'Projects are where you can store your code, access issues, wiki, and other features of GitLab.',
- svgHeight: 144,
svgPath: defaultProvide.projectsEmptyStateSvgPath,
+ search: 'foo',
});
expect(findNewProjectButton().exists()).toBe(shouldShowEmptyStateButtons);
diff --git a/spec/frontend/projects/your_work/components/app_spec.js b/spec/frontend/projects/your_work/components/app_spec.js
new file mode 100644
index 00000000000..30b36938afe
--- /dev/null
+++ b/spec/frontend/projects/your_work/components/app_spec.js
@@ -0,0 +1,24 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import YourWorkProjectsApp from '~/projects/your_work/components/app.vue';
+
+jest.mock('~/alert');
+
+describe('YourWorkProjectsApp', () => {
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(YourWorkProjectsApp);
+ };
+
+ const findPageText = () => wrapper.find('p');
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders Vue app with Projects list p tag', () => {
+ expect(findPageText().text()).toBe('Projects list');
+ });
+ });
+});
diff --git a/spec/lib/gitlab/background_migration/backfill_ml_candidate_metadata_project_id_spec.rb b/spec/lib/gitlab/background_migration/backfill_ml_candidate_metadata_project_id_spec.rb
new file mode 100644
index 00000000000..55755037db5
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_ml_candidate_metadata_project_id_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillMlCandidateMetadataProjectId,
+ feature_category: :mlops,
+ schema: 20240626142202 do
+ include_examples 'desired sharding key backfill job' do
+ let(:batch_table) { :ml_candidate_metadata }
+ let(:backfill_column) { :project_id }
+ let(:backfill_via_table) { :ml_candidates }
+ let(:backfill_via_column) { :project_id }
+ let(:backfill_via_foreign_key) { :candidate_id }
+ end
+end
diff --git a/spec/migrations/20240611122408_remove_project_statistics_wiki_size_and_project_id_index_spec.rb b/spec/migrations/20240611122408_remove_project_statistics_wiki_size_and_project_id_index_spec.rb
new file mode 100644
index 00000000000..bd357e09404
--- /dev/null
+++ b/spec/migrations/20240611122408_remove_project_statistics_wiki_size_and_project_id_index_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveProjectStatisticsWikiSizeAndProjectIdIndex, feature_category: :consumables_cost_management do
+ let(:migration) { described_class.new }
+ let(:postgres_async_indexes) { table(:postgres_async_indexes) }
+
+ describe '#up' do
+ subject(:up) { migration.up }
+
+ it 'does nothing when not on gitlab.com' do
+ expect { up }.not_to change { postgres_async_indexes.count }
+ end
+
+ it 'prepares async index removal when on gitlab.com', :saas do
+ expect { up }.to change { postgres_async_indexes.count }.from(0).to(1)
+ end
+ end
+
+ describe '#down' do
+ subject(:down) { migration.down }
+
+ before do
+ postgres_async_indexes.create!(
+ name: 'index_project_statistics_on_wiki_size_and_project_id',
+ table_name: 'project_statistics',
+ definition: 'test index'
+ )
+ end
+
+ it 'does nothing when not on gitlab.com' do
+ expect { down }.not_to change { postgres_async_indexes.count }
+ end
+
+ it 'unprepares async index removal when on gitlab.com', :saas do
+ expect { down }.to change { postgres_async_indexes.count }.from(1).to(0)
+ end
+ end
+end
diff --git a/spec/migrations/20240626142206_queue_backfill_ml_candidate_metadata_project_id_spec.rb b/spec/migrations/20240626142206_queue_backfill_ml_candidate_metadata_project_id_spec.rb
new file mode 100644
index 00000000000..8f9ecaf544d
--- /dev/null
+++ b/spec/migrations/20240626142206_queue_backfill_ml_candidate_metadata_project_id_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe QueueBackfillMlCandidateMetadataProjectId, feature_category: :mlops do
+ let!(:batched_migration) { described_class::MIGRATION }
+
+ it 'schedules a new batched migration' do
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(batched_migration).not_to have_scheduled_batched_migration
+ }
+
+ migration.after -> {
+ expect(batched_migration).to have_scheduled_batched_migration(
+ table_name: :ml_candidate_metadata,
+ column_name: :id,
+ interval: described_class::DELAY_INTERVAL,
+ batch_size: described_class::BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE,
+ gitlab_schema: :gitlab_main_cell,
+ job_arguments: [
+ :project_id,
+ :ml_candidates,
+ :project_id,
+ :candidate_id
+ ]
+ )
+ }
+ end
+ end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index ce4a9d941fd..feede09549a 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -342,6 +342,10 @@ RSpec.configure do |config|
# We want this this FF disabled by default
stub_feature_flags(synced_epic_work_item_editable: false)
+
+ # Since we are very early in the Vue migration, there isn't much value in testing when the feature flag is enabled
+ # Please see https://gitlab.com/gitlab-org/gitlab/-/issues/466081 for tracking revisiting this.
+ stub_feature_flags(your_work_projects_vue: false)
else
unstub_all_feature_flags
end
diff --git a/spec/views/dashboard/projects/index.html.haml_spec.rb b/spec/views/dashboard/projects/index.html.haml_spec.rb
index 8e051b821c6..3d9f988c4d2 100644
--- a/spec/views/dashboard/projects/index.html.haml_spec.rb
+++ b/spec/views/dashboard/projects/index.html.haml_spec.rb
@@ -2,25 +2,96 @@
require 'spec_helper'
-RSpec.describe 'dashboard/projects/index.html.haml' do
+RSpec.describe 'dashboard/projects/index.html.haml', feature_category: :groups_and_projects do
let_it_be(:user) { build(:user) }
before do
allow(view).to receive(:limited_counter_with_delimiter)
allow(view).to receive(:current_user).and_return(user)
allow(view).to receive(:time_ago_with_tooltip)
- assign(:projects, [build(:project, name: 'awesome stuff')])
end
- it 'shows the project the user is a member of in the list' do
- render
+ context 'when feature :your_work_projects_vue is enabled' do
+ before do
+ stub_feature_flags(your_work_projects_vue: true)
+ end
- expect(rendered).to have_content('awesome stuff')
+ context 'when projects exist' do
+ before do
+ assign(:projects, [build(:project, name: 'awesome stuff')])
+ allow(view).to receive(:any_projects?).and_return(true)
+ render
+ end
+
+ it 'renders #js-your-work-projects-app and not legacy project list' do
+ render
+
+ expect(rendered).to have_selector('#js-your-work-projects-app')
+ expect(rendered).not_to render_template('dashboard/projects/_projects')
+ end
+ end
+
+ context 'when projects do not exist' do
+ before do
+ allow(view).to receive(:any_projects?).and_return(false)
+ render
+ end
+
+ it 'does not render #js-your-work-projects-app and renders empty state' do
+ render
+
+ expect(rendered).not_to have_selector('#js-your-work-projects-app')
+ expect(rendered).to render_template('dashboard/projects/_zero_authorized_projects')
+ end
+ end
end
- it 'shows the "New project" button' do
- render
+ context 'when feature :your_work_projects_vue is disabled' do
+ before do
+ stub_feature_flags(your_work_projects_vue: false)
+ end
- expect(rendered).to have_link('New project')
+ context 'when projects exist' do
+ before do
+ assign(:projects, [build(:project, name: 'awesome stuff')])
+ allow(view).to receive(:show_projects?).and_return(true)
+ render
+ end
+
+ it 'shows the project the user is a member of in the list' do
+ expect(rendered).to have_content('awesome stuff')
+ end
+
+ it 'shows the "New project" button' do
+ expect(rendered).to have_link('New project')
+ end
+
+ it 'does not render zero_authorized_projects partial' do
+ expect(rendered).not_to render_template('dashboard/projects/_zero_authorized_projects')
+ end
+
+ it 'does not render #js-your-work-projects-app' do
+ expect(rendered).not_to have_selector('#js-your-work-projects-app')
+ end
+ end
+
+ context 'when projects do not exist' do
+ before do
+ allow(view).to receive(:show_projects?).and_return(false)
+ render
+ end
+
+ it 'does not show the "New project" button' do
+ expect(rendered).not_to have_link('New project')
+ end
+
+ it 'does render zero_authorized_projects partial' do
+ expect(rendered).to render_template('dashboard/projects/_zero_authorized_projects')
+ end
+
+ it 'does not render #js-your-work-projects-app' do
+ expect(rendered).not_to have_selector('#js-your-work-projects-app')
+ end
+ end
end
end
diff --git a/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb b/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb
new file mode 100644
index 00000000000..9885f4aa802
--- /dev/null
+++ b/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'dashboard/projects/shared/_common.html.haml', feature_category: :groups_and_projects do
+ let_it_be(:user) { build(:user) }
+
+ before do
+ view.lookup_context.prefixes = ['dashboard/projects']
+
+ allow(view).to receive(:limited_counter_with_delimiter)
+ allow(view).to receive(:current_user).and_return(user)
+ allow(view).to receive(:time_ago_with_tooltip)
+ allow(view).to receive(:empty_page).and_return('starred_empty_state')
+ end
+
+ context 'when feature :your_work_projects_vue is enabled' do
+ before do
+ stub_feature_flags(your_work_projects_vue: true)
+ end
+
+ context 'when projects exist' do
+ before do
+ assign(:projects, [build(:project, name: 'awesome stuff')])
+ allow(view).to receive(:any_projects?).and_return(true)
+ render
+ end
+
+ it 'renders #js-your-work-projects-app and not legacy project list' do
+ render
+
+ expect(rendered).to have_selector('#js-your-work-projects-app')
+ expect(rendered).not_to render_template('dashboard/projects/_projects')
+ end
+ end
+
+ context 'when projects do not exist' do
+ before do
+ allow(view).to receive(:any_projects?).and_return(false)
+ render
+ end
+
+ it 'does not render #js-your-work-projects-app and renders empty state' do
+ render
+
+ expect(rendered).not_to have_selector('#js-your-work-projects-app')
+ expect(rendered).to render_template('dashboard/projects/_starred_empty_state')
+ end
+ end
+ end
+
+ context 'when feature :your_work_projects_vue is disabled' do
+ before do
+ stub_feature_flags(your_work_projects_vue: false)
+ end
+
+ context 'when projects exist' do
+ before do
+ assign(:projects, [build(:project, name: 'awesome stuff')])
+ allow(view).to receive(:any_projects?).and_return(true)
+ render
+ end
+
+ it 'shows the project the user is a member of in the list' do
+ expect(rendered).to have_content('awesome stuff')
+ end
+
+ it 'shows the "New project" button' do
+ expect(rendered).to have_link('New project')
+ end
+
+ it 'does not render starred_empty_state partial' do
+ expect(rendered).not_to render_template('dashboard/projects/_starred_empty_state')
+ end
+
+ it 'does not render #js-your-work-projects-app' do
+ expect(rendered).not_to have_selector('#js-your-work-projects-app')
+ end
+ end
+
+ context 'when projects do not exist' do
+ before do
+ allow(view).to receive(:any_projects?).and_return(false)
+ render
+ end
+
+ it 'does show the "New project" button' do
+ expect(rendered).to have_link('New project')
+ end
+
+ it 'does render starred_empty_state partial' do
+ expect(rendered).to render_template('dashboard/projects/_starred_empty_state')
+ end
+
+ it 'does not render #js-your-work-projects-app' do
+ expect(rendered).not_to have_selector('#js-your-work-projects-app')
+ end
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index e56857f0917..90bdb2bf36c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1331,10 +1331,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.103.0.tgz#af61387481100eadef2bea8fe8605250311ac582"
integrity sha512-jVWCrRVRF6nw2A+Aowc0quXV2bdRPl2v08ElCPSestfdKjQ92tSlCrIsLB8GvdW5aI0eFsD1vJ1w2qkzZdpA4A==
-"@gitlab/ui@85.3.0":
- version "85.3.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-85.3.0.tgz#29abdfa121ca6720ca5558951319af20b55d0282"
- integrity sha512-G8l8ZrdgOp6O6Azhypb/+HL5ZXU0/tIXjsHX7t6VkSBT7pSLBH06CnaH3Hrs8xEeCBSXfZDiMS4gq1FdEXGT2A==
+"@gitlab/ui@85.4.1":
+ version "85.4.1"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-85.4.1.tgz#4cc7ed0bec0d022003e996a790d7ea9c37cce5ff"
+ integrity sha512-Q2QsIILLlipv6StUEAjS11OaJFkoZ5jlIE3QpFpJeGkHnskD6viRdnFcFDCYXwGMtrm1JphEp7iZZs6uX/MIkw==
dependencies:
"@floating-ui/dom" "1.4.3"
echarts "^5.3.2"