diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-31 09:09:30 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-01-31 09:09:30 +0300 |
commit | e6c495fe40320eb01bf8c4fb132c9f22449ae9d2 (patch) | |
tree | add32dcc4b186baf21b77431ce5718e0b7cdbf36 | |
parent | 25805c16335ed6466f0e475417e3005cd09848c2 (diff) |
Add latest changes from gitlab-org/gitlab@master
-rw-r--r-- | app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue | 31 | ||||
-rw-r--r-- | app/assets/javascripts/pages/projects/ml/experiments/show/index.js | 4 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/incubation/pagination.vue | 62 | ||||
-rw-r--r-- | app/controllers/projects/ml/experiments_controller.rb | 16 | ||||
-rw-r--r-- | app/helpers/projects/ml/experiments_helper.rb | 24 | ||||
-rw-r--r-- | app/models/ml/candidate.rb | 20 | ||||
-rw-r--r-- | app/views/projects/ml/experiments/show.html.haml | 3 | ||||
-rw-r--r-- | db/docs/wiki_repository_states.yml | 10 | ||||
-rw-r--r-- | db/migrate/20230109232316_create_wiki_repository_states.rb | 44 | ||||
-rw-r--r-- | db/schema_migrations/20230109232316 | 1 | ||||
-rw-r--r-- | db/structure.sql | 40 | ||||
-rw-r--r-- | spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js | 103 | ||||
-rw-r--r-- | spec/frontend/vue_shared/components/incubation/pagination_spec.js | 76 | ||||
-rw-r--r-- | spec/helpers/projects/ml/experiments_helper_spec.rb | 78 | ||||
-rw-r--r-- | spec/requests/projects/ml/experiments_controller_spec.rb | 61 |
15 files changed, 380 insertions, 193 deletions
diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue index 61278f52112..4b3f491266d 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue @@ -1,5 +1,5 @@ <script> -import { GlTable, GlLink, GlPagination, GlTooltipDirective } from '@gitlab/ui'; +import { GlTable, GlLink, GlTooltipDirective } from '@gitlab/ui'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -11,6 +11,7 @@ import { import { s__ } from '~/locale'; import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue'; import IncubationAlert from './incubation_alert.vue'; export default { @@ -20,13 +21,13 @@ export default { GlLink, TimeAgo, IncubationAlert, - GlPagination, RegistrySearch, + KeysetPagination, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['candidates', 'metricNames', 'paramNames', 'pagination'], + inject: ['candidates', 'metricNames', 'paramNames', 'pageInfo'], data() { const query = queryToObject(window.location.search); @@ -39,7 +40,6 @@ export default { } return { - page: parseInt(query.page, 10) || 1, filters: filter, sorting: { orderBy, @@ -64,12 +64,6 @@ export default { displayPagination() { return this.candidates.length > 0; }, - prevPage() { - return this.pagination.page > 1 ? this.pagination.page - 1 : null; - }, - nextPage() { - return !this.pagination.isLastPage ? this.pagination.page + 1 : null; - }, sortableFields() { return [ ...BASE_SORT_FIELDS, @@ -100,11 +94,8 @@ export default { }, }, methods: { - generateLink(page) { - return setUrlParams({ ...this.parsedQuery, page }); - }, submitFilters() { - return visitUrl(setUrlParams({ ...this.parsedQuery, page: this.page })); + return visitUrl(setUrlParams({ ...this.parsedQuery })); }, updateFilters(newValue) { this.filters = newValue; @@ -197,16 +188,6 @@ export default { </template> </gl-table> - <gl-pagination - v-if="displayPagination" - v-model="pagination.page" - :prev-page="prevPage" - :next-page="nextPage" - :total-items="pagination.totalItems" - :per-page="pagination.perPage" - :link-gen="generateLink" - align="center" - class="w-100" - /> + <keyset-pagination v-if="displayPagination" v-bind="pageInfo" /> </div> </template> diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index 6947b15dcbe..0e64d8c17db 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -14,7 +14,7 @@ const initShowExperiment = () => { const candidates = JSON.parse(element.dataset.candidates); const metricNames = JSON.parse(element.dataset.metrics); const paramNames = JSON.parse(element.dataset.params); - const pagination = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pagination)); + const pageInfo = convertObjectPropsToCamelCase(JSON.parse(element.dataset.pageInfo)); // eslint-disable-next-line no-new new Vue({ @@ -23,7 +23,7 @@ const initShowExperiment = () => { candidates, metricNames, paramNames, - pagination, + pageInfo, }, render(h) { return h(MlExperiment); diff --git a/app/assets/javascripts/vue_shared/components/incubation/pagination.vue b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue new file mode 100644 index 00000000000..b5afe92316a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/incubation/pagination.vue @@ -0,0 +1,62 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; + +export default { + name: 'KeysetPagination', + components: { + GlKeysetPagination, + }, + props: { + startCursor: { + type: String, + required: false, + default: '', + }, + endCursor: { + type: String, + required: false, + default: '', + }, + hasNextPage: { + type: Boolean, + required: true, + }, + hasPreviousPage: { + type: Boolean, + required: true, + }, + }, + computed: { + previousPageLink() { + return setUrlParams({ cursor: this.startCursor }); + }, + nextPageLink() { + return setUrlParams({ cursor: this.endCursor }); + }, + isPaginationVisible() { + return this.hasPreviousPage || this.hasNextPage; + }, + }, + i18n: { + previousPageButtonLabel: __('Prev'), + nextPageButtonLabel: __('Next'), + }, +}; +</script> + +<template> + <div v-if="isPaginationVisible" class="gl--flex-center"> + <gl-keyset-pagination + :start-cursor="startCursor" + :end-cursor="endCursor" + :has-previous-page="hasPreviousPage" + :has-next-page="hasNextPage" + :prev-text="$options.i18n.previousPageButtonLabel" + :next-text="$options.i18n.nextPageButtonLabel" + :prev-button-link="previousPageLink" + :next-button-link="nextPageLink" + /> + </div> +</template> diff --git a/app/controllers/projects/ml/experiments_controller.rb b/app/controllers/projects/ml/experiments_controller.rb index 5e7e6828a3b..20f5b39bac7 100644 --- a/app/controllers/projects/ml/experiments_controller.rb +++ b/app/controllers/projects/ml/experiments_controller.rb @@ -25,17 +25,13 @@ module Projects .transform_keys(&:underscore) .permit(:name, :order_by, :sort, :order_by_type) - @candidates = CandidateFinder.new(@experiment, find_params).execute + paginator = CandidateFinder + .new(@experiment, find_params) + .execute + .keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE) - page = [params[:page].to_i, 1].max - - @candidates, @pagination_info = paginate_candidates(@candidates, page, MAX_CANDIDATES_PER_PAGE) - - return if @pagination_info[:total_pages] == 0 - - redirect_to(url_for(safe_params.merge(page: @pagination_info[:total_pages]))) if @pagination_info[:out_of_range] - - @candidates.each(&:artifact_lazy) + @candidates = paginator.records.each(&:artifact_lazy) + @page_info = page_info(paginator) end private diff --git a/app/helpers/projects/ml/experiments_helper.rb b/app/helpers/projects/ml/experiments_helper.rb index afb6f9c99e3..9f41db2f8b9 100644 --- a/app/helpers/projects/ml/experiments_helper.rb +++ b/app/helpers/projects/ml/experiments_helper.rb @@ -42,24 +42,18 @@ module Projects Gitlab::Json.generate(data) end - def paginate_candidates(candidates, page, max_per_page) - return [candidates, nil] unless candidates - - candidates = candidates.page(page).per(max_per_page) - - pagination_info = { - page: page, - is_last_page: candidates.last_page?, - per_page: max_per_page, - total_items: candidates.total_count, - total_pages: candidates.total_pages, - out_of_range: candidates.out_of_range? + def page_info(paginator) + { + has_next_page: paginator.has_next_page?, + has_previous_page: paginator.has_previous_page?, + start_cursor: paginator.cursor_for_previous_page, + end_cursor: paginator.cursor_for_next_page } - - [candidates, pagination_info] end - private + def formatted_page_info(page_info) + Gitlab::Json.generate(page_info) + end def link_to_artifact(candidate) artifact = candidate.artifact diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index 16e49305fde..f973b00c568 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -24,8 +24,26 @@ module Ml scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection scope :order_by_metric, ->(metric, direction) do subquery = Ml::CandidateMetric.latest.where(name: metric) + column_expression = Arel::Table.new('latest')[:value] + metric_order_expression = direction.to_sym == :desc ? column_expression.desc : column_expression.asc + joins("INNER JOIN (#{subquery.to_sql}) latest ON latest.candidate_id = ml_candidates.id") - .order("latest.value #{direction}, ml_candidates.id DESC") + .select("ml_candidates.*", "latest.value as metric_value") + .order( + Gitlab::Pagination::Keyset::Order.build( + [ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'metric_value', + order_expression: metric_order_expression, + nullable: :nulls_last, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].desc + ) + ]) + ) end delegate :project_id, :project, to: :experiment diff --git a/app/views/projects/ml/experiments/show.html.haml b/app/views/projects/ml/experiments/show.html.haml index c938c804dcf..4433d1fafe9 100644 --- a/app/views/projects/ml/experiments/show.html.haml +++ b/app/views/projects/ml/experiments/show.html.haml @@ -4,6 +4,7 @@ - items = candidates_table_items(@candidates) - metrics = unique_logged_names(@candidates, &:latest_metrics) - params = unique_logged_names(@candidates, &:params) +- page_info = formatted_page_info(@page_info) .page-title-holder.d-flex.align-items-center %h1.page-title.gl-font-size-h-display= @experiment.name @@ -12,5 +13,5 @@ candidates: items, metrics: metrics, params: params, - pagination: @pagination_info.to_json + page_info: page_info } } diff --git a/db/docs/wiki_repository_states.yml b/db/docs/wiki_repository_states.yml new file mode 100644 index 00000000000..7f579b9098e --- /dev/null +++ b/db/docs/wiki_repository_states.yml @@ -0,0 +1,10 @@ +--- +table_name: wiki_repository_states +classes: +- Geo::WikiRepositoryState +feature_categories: +- geo_replication +description: Separate table for project wikis containing Geo verification metadata. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109033 +milestone: '15.9' +gitlab_schema: gitlab_main diff --git a/db/migrate/20230109232316_create_wiki_repository_states.rb b/db/migrate/20230109232316_create_wiki_repository_states.rb new file mode 100644 index 00000000000..61afc18c0f6 --- /dev/null +++ b/db/migrate/20230109232316_create_wiki_repository_states.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class CreateWikiRepositoryStates < Gitlab::Database::Migration[2.0] + VERIFICATION_STATE_INDEX_NAME = "index_wiki_repository_states_on_verification_state" + PENDING_VERIFICATION_INDEX_NAME = "index_wiki_repository_states_pending_verification" + FAILED_VERIFICATION_INDEX_NAME = "index_wiki_repository_states_failed_verification" + NEEDS_VERIFICATION_INDEX_NAME = "index_wiki_repository_states_needs_verification" + + enable_lock_retries! + + def up + create_table :wiki_repository_states do |t| + t.datetime_with_timezone :verification_started_at + t.datetime_with_timezone :verification_retry_at + t.datetime_with_timezone :verified_at + t.references :project_wiki_repository, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + t.integer :verification_state, default: 0, limit: 2, null: false + t.integer :verification_retry_count, limit: 2 + t.binary :verification_checksum, using: 'verification_checksum::bytea' + t.text :verification_failure, limit: 255 + + t.index :verification_state, + name: VERIFICATION_STATE_INDEX_NAME + + t.index :verified_at, + where: "(verification_state = 0)", + order: { verified_at: 'ASC NULLS FIRST' }, + name: PENDING_VERIFICATION_INDEX_NAME + + t.index :verification_retry_at, + where: "(verification_state = 3)", + order: { verification_retry_at: 'ASC NULLS FIRST' }, + name: FAILED_VERIFICATION_INDEX_NAME + + t.index :verification_state, + where: "(verification_state = 0 OR verification_state = 3)", + name: NEEDS_VERIFICATION_INDEX_NAME + end + end + + def down + drop_table :wiki_repository_states + end +end diff --git a/db/schema_migrations/20230109232316 b/db/schema_migrations/20230109232316 new file mode 100644 index 00000000000..813483d4496 --- /dev/null +++ b/db/schema_migrations/20230109232316 @@ -0,0 +1 @@ +94981a0226e10e8f1c711e5b3e110486c019595b822c5d8bf728285233ebd22b
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 9ecd0e460fd..c0617ec779b 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -23696,6 +23696,28 @@ CREATE SEQUENCE wiki_page_slugs_id_seq ALTER SEQUENCE wiki_page_slugs_id_seq OWNED BY wiki_page_slugs.id; +CREATE TABLE wiki_repository_states ( + id bigint NOT NULL, + verification_started_at timestamp with time zone, + verification_retry_at timestamp with time zone, + verified_at timestamp with time zone, + project_wiki_repository_id bigint NOT NULL, + verification_state smallint DEFAULT 0 NOT NULL, + verification_retry_count smallint, + verification_checksum bytea, + verification_failure text, + CONSTRAINT check_2933ff60dc CHECK ((char_length(verification_failure) <= 255)) +); + +CREATE SEQUENCE wiki_repository_states_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE wiki_repository_states_id_seq OWNED BY wiki_repository_states.id; + CREATE TABLE work_item_hierarchy_restrictions ( id bigint NOT NULL, parent_type_id bigint NOT NULL, @@ -24954,6 +24976,8 @@ ALTER TABLE ONLY wiki_page_meta ALTER COLUMN id SET DEFAULT nextval('wiki_page_m ALTER TABLE ONLY wiki_page_slugs ALTER COLUMN id SET DEFAULT nextval('wiki_page_slugs_id_seq'::regclass); +ALTER TABLE ONLY wiki_repository_states ALTER COLUMN id SET DEFAULT nextval('wiki_repository_states_id_seq'::regclass); + ALTER TABLE ONLY work_item_hierarchy_restrictions ALTER COLUMN id SET DEFAULT nextval('work_item_hierarchy_restrictions_id_seq'::regclass); ALTER TABLE ONLY work_item_parent_links ALTER COLUMN id SET DEFAULT nextval('work_item_parent_links_id_seq'::regclass); @@ -27413,6 +27437,9 @@ ALTER TABLE ONLY wiki_page_meta ALTER TABLE ONLY wiki_page_slugs ADD CONSTRAINT wiki_page_slugs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY wiki_repository_states + ADD CONSTRAINT wiki_repository_states_pkey PRIMARY KEY (id); + ALTER TABLE ONLY work_item_hierarchy_restrictions ADD CONSTRAINT work_item_hierarchy_restrictions_pkey PRIMARY KEY (id); @@ -31854,6 +31881,16 @@ CREATE UNIQUE INDEX index_wiki_page_slugs_on_slug_and_wiki_page_meta_id ON wiki_ CREATE INDEX index_wiki_page_slugs_on_wiki_page_meta_id ON wiki_page_slugs USING btree (wiki_page_meta_id); +CREATE INDEX index_wiki_repository_states_failed_verification ON wiki_repository_states USING btree (verification_retry_at NULLS FIRST) WHERE (verification_state = 3); + +CREATE INDEX index_wiki_repository_states_needs_verification ON wiki_repository_states USING btree (verification_state) WHERE ((verification_state = 0) OR (verification_state = 3)); + +CREATE UNIQUE INDEX index_wiki_repository_states_on_project_wiki_repository_id ON wiki_repository_states USING btree (project_wiki_repository_id); + +CREATE INDEX index_wiki_repository_states_on_verification_state ON wiki_repository_states USING btree (verification_state); + +CREATE INDEX index_wiki_repository_states_pending_verification ON wiki_repository_states USING btree (verified_at NULLS FIRST) WHERE (verification_state = 0); + CREATE INDEX index_work_item_hierarchy_restrictions_on_child_type_id ON work_item_hierarchy_restrictions USING btree (child_type_id); CREATE UNIQUE INDEX index_work_item_hierarchy_restrictions_on_parent_and_child ON work_item_hierarchy_restrictions USING btree (parent_type_id, child_type_id); @@ -35483,6 +35520,9 @@ ALTER TABLE ONLY ci_pipeline_artifacts ALTER TABLE ONLY merge_request_user_mentions ADD CONSTRAINT fk_rails_aa1b2961b1 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE; +ALTER TABLE ONLY wiki_repository_states + ADD CONSTRAINT fk_rails_aa2f8a61ba FOREIGN KEY (project_wiki_repository_id) REFERENCES project_wiki_repositories(id) ON DELETE CASCADE; + ALTER TABLE ONLY x509_commit_signatures ADD CONSTRAINT fk_rails_ab07452314 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js index a8637bc35ce..f307d2c5a58 100644 --- a/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js +++ b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js @@ -1,27 +1,34 @@ -import { GlAlert, GlPagination, GlTable, GlLink } from '@gitlab/ui'; +import { GlAlert, GlTable, GlLink } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; import setWindowLocation from 'helpers/set_window_location_helper'; import * as urlHelpers from '~/lib/utils/url_utility'; describe('MlExperiment', () => { let wrapper; + const startCursor = 'eyJpZCI6IjE2In0'; + const defaultPageInfo = { + startCursor, + endCursor: 'eyJpZCI6IjIifQ', + hasNextPage: true, + hasPreviousPage: true, + }; + const createWrapper = ( candidates = [], metricNames = [], paramNames = [], - pagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 0 }, + pageInfo = defaultPageInfo, ) => { wrapper = mountExtended(MlExperiment, { - provide: { candidates, metricNames, paramNames, pagination }, + provide: { candidates, metricNames, paramNames, pageInfo }, }); }; - const defaultPagination = { page: 1, isLastPage: false, per_page: 2, totalItems: 5 }; - const candidates = [ { rmse: 1, @@ -66,12 +73,12 @@ describe('MlExperiment', () => { }, ]; - const createWrapperWithCandidates = (pagination = defaultPagination) => { - createWrapper(candidates, ['rmse', 'auc', 'mae'], ['l1_ratio'], pagination); + const createWrapperWithCandidates = (pageInfo = defaultPageInfo) => { + createWrapper(candidates, ['rmse', 'auc', 'mae'], ['l1_ratio'], pageInfo); }; const findAlert = () => wrapper.findComponent(GlAlert); - const findPagination = () => wrapper.findComponent(GlPagination); + const findPagination = () => wrapper.findComponent(Pagination); const findEmptyState = () => wrapper.findByText('No candidates to display'); const findRegistrySearch = () => wrapper.findComponent(RegistrySearch); const findTable = () => wrapper.findComponent(GlTable); @@ -119,30 +126,6 @@ describe('MlExperiment', () => { }); }); - describe('generateLink', () => { - it('generates the correct url', () => { - setWindowLocation( - 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc&page=1', - ); - - createWrapperWithCandidates(); - - expect(findPagination().props('linkGen')(2)).toBe( - 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc&page=2', - ); - }); - - it('generates the correct url when no name', () => { - setWindowLocation('https://blah.com/?orderBy=auc&orderByType=metric&sort=asc'); - - createWrapperWithCandidates(); - - expect(findPagination().props('linkGen')(2)).toBe( - 'https://blah.com/?orderBy=auc&orderByType=metric&sort=asc&page=2', - ); - }); - }); - describe('Search', () => { it('shows search box', () => { createWrapper(); @@ -194,30 +177,30 @@ describe('MlExperiment', () => { createWrapper(); }); - it('On submit, reloads to correct page', () => { + it('On submit, resets the cursor and reloads to correct page', () => { findRegistrySearch().vm.$emit('filter:submit'); expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1); expect(urlHelpers.visitUrl).toHaveBeenCalledWith( - 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc&page=1', + 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=asc', ); }); - it('On sorting changed, reloads to correct page', () => { + it('On sorting changed, resets cursor and reloads to correct page', () => { findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'created_at' }); expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1); expect(urlHelpers.visitUrl).toHaveBeenCalledWith( - 'https://blah.com/?name=query&orderBy=created_at&orderByType=column&sort=asc&page=1', + 'https://blah.com/?name=query&orderBy=created_at&orderByType=column&sort=asc', ); }); - it('On sorting changed and is metric, reloads to correct page', () => { + it('On sorting changed and is metric, resets cursor and reloads to correct page', () => { findRegistrySearch().vm.$emit('sorting:changed', { orderBy: 'metric.auc' }); expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1); expect(urlHelpers.visitUrl).toHaveBeenCalledWith( - 'https://blah.com/?name=query&orderBy=auc&orderByType=metric&sort=asc&page=1', + 'https://blah.com/?name=query&orderBy=auc&orderByType=metric&sort=asc', ); }); @@ -226,47 +209,25 @@ describe('MlExperiment', () => { expect(urlHelpers.visitUrl).toHaveBeenCalledTimes(1); expect(urlHelpers.visitUrl).toHaveBeenCalledWith( - 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=desc&page=1', + 'https://blah.com/?name=query&orderBy=name&orderByType=column&sort=desc', ); }); }); }); - describe('with candidates', () => { - describe('Pagination behaviour', () => { - beforeEach(() => { - createWrapperWithCandidates(); - }); - - it('should show', () => { - expect(findPagination().exists()).toBe(true); - }); - - it('should get the page number from the URL', () => { - createWrapperWithCandidates({ ...defaultPagination, page: 2 }); - - expect(findPagination().props().value).toBe(2); - }); - - it('should not have a prevPage if the page is 1', () => { - expect(findPagination().props().prevPage).toBe(null); - }); - - it('should set the prevPage to 1 if the page is 2', () => { - createWrapperWithCandidates({ ...defaultPagination, page: 2 }); - - expect(findPagination().props().prevPage).toBe(1); - }); + describe('Pagination behaviour', () => { + beforeEach(() => { + createWrapperWithCandidates(); + }); - it('should not have a nextPage if isLastPage is true', async () => { - createWrapperWithCandidates({ ...defaultPagination, isLastPage: true }); + it('should show', () => { + expect(findPagination().exists()).toBe(true); + }); - expect(findPagination().props().nextPage).toBe(null); - }); + it('Passes pagination to pagination component', () => { + createWrapperWithCandidates(); - it('should set the nextPage to 2 if the page is 1', () => { - expect(findPagination().props().nextPage).toBe(2); - }); + expect(findPagination().props('startCursor')).toBe(startCursor); }); }); diff --git a/spec/frontend/vue_shared/components/incubation/pagination_spec.js b/spec/frontend/vue_shared/components/incubation/pagination_spec.js new file mode 100644 index 00000000000..a621e60c627 --- /dev/null +++ b/spec/frontend/vue_shared/components/incubation/pagination_spec.js @@ -0,0 +1,76 @@ +import { GlKeysetPagination } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; + +describe('~/vue_shared/incubation/components/pagination.vue', () => { + let wrapper; + + const pageInfo = { + startCursor: 'eyJpZCI6IjE2In0', + endCursor: 'eyJpZCI6IjIifQ', + hasNextPage: true, + hasPreviousPage: true, + }; + + const findPagination = () => wrapper.findComponent(GlKeysetPagination); + + const createWrapper = (pageInfoProp) => { + wrapper = mountExtended(Pagination, { + propsData: pageInfoProp, + }); + }; + + describe('when neither next nor previous page exists', () => { + beforeEach(() => { + const emptyPageInfo = { ...pageInfo, hasPreviousPage: false, hasNextPage: false }; + + createWrapper(emptyPageInfo); + }); + + it('should not render pagination component', () => { + expect(wrapper.html()).toBe(''); + }); + }); + + describe('when Pagination is rendered for environment details page', () => { + beforeEach(() => { + createWrapper(pageInfo); + }); + + it('should pass correct props to keyset pagination', () => { + expect(findPagination().exists()).toBe(true); + expect(findPagination().props()).toEqual(expect.objectContaining(pageInfo)); + }); + + describe.each([ + { + testPageInfo: pageInfo, + expectedAfter: `cursor=${pageInfo.endCursor}`, + expectedBefore: `cursor=${pageInfo.startCursor}`, + }, + { + testPageInfo: { ...pageInfo, hasNextPage: true, hasPreviousPage: false }, + expectedAfter: `cursor=${pageInfo.endCursor}`, + expectedBefore: '', + }, + { + testPageInfo: { ...pageInfo, hasNextPage: false, hasPreviousPage: true }, + expectedAfter: '', + expectedBefore: `cursor=${pageInfo.startCursor}`, + }, + ])( + 'button links generation for $testPageInfo', + ({ testPageInfo, expectedAfter, expectedBefore }) => { + beforeEach(() => { + createWrapper(testPageInfo); + }); + + it(`should have button links defined as ${expectedAfter || 'empty'} and + ${expectedBefore || 'empty'}`, () => { + expect(findPagination().props().prevButtonLink).toContain(expectedBefore); + expect(findPagination().props().nextButtonLink).toContain(expectedAfter); + }); + }, + ); + }); +}); diff --git a/spec/helpers/projects/ml/experiments_helper_spec.rb b/spec/helpers/projects/ml/experiments_helper_spec.rb index 283a3f73238..d9194d19a7f 100644 --- a/spec/helpers/projects/ml/experiments_helper_spec.rb +++ b/spec/helpers/projects/ml/experiments_helper_spec.rb @@ -110,67 +110,47 @@ RSpec.describe Projects::Ml::ExperimentsHelper, feature_category: :mlops do end end - describe '#paginate_candidates' do - let(:page) { 1 } - let(:max_per_page) { 1 } - - subject { helper.paginate_candidates(experiment.candidates.order(:id), page, max_per_page) } - - it 'paginates' do - expect(subject[1]).not_to be_nil + describe '#page_info' do + def paginator(cursor = nil) + experiment.candidates.keyset_paginate(cursor: cursor, per_page: 1) end - it 'only returns max_per_page elements' do - expect(subject[0].size).to eq(1) - end + let_it_be(:first_page) { paginator } + let_it_be(:second_page) { paginator(first_page.cursor_for_next_page) } - it 'fetches the items on the first page' do - expect(subject[0]).to eq([candidate0]) - end + let(:page) { nil } - it 'creates the pagination info' do - expect(subject[1]).to eq({ - page: 1, - is_last_page: false, - per_page: 1, - total_items: 2, - total_pages: 2, - out_of_range: false - }) - end + subject { helper.page_info(page) } - context 'when not the first page' do - let(:page) { 2 } + context 'when is first page' do + let(:page) { first_page } - it 'fetches the right page' do - expect(subject[0]).to eq([candidate1]) - end - - it 'creates the pagination info' do - expect(subject[1]).to eq({ - page: 2, - is_last_page: true, - per_page: 1, - total_items: 2, - total_pages: 2, - out_of_range: false + it 'generates the correct page_info' do + is_expected.to include({ + has_next_page: true, + has_previous_page: false, + start_cursor: nil }) end end - context 'when out of bounds' do - let(:page) { 3 } - - it 'creates the pagination info' do - expect(subject[1]).to eq({ - page: page, - is_last_page: false, - per_page: 1, - total_items: 2, - total_pages: 2, - out_of_range: true + context 'when is last page' do + let(:page) { second_page } + + it 'generates the correct page_info' do + is_expected.to include({ + has_next_page: false, + has_previous_page: true, + start_cursor: second_page.cursor_for_previous_page, + end_cursor: nil }) end end end + + describe '#formatted_page_info' do + it 'formats to json' do + expect(helper.formatted_page_info({ a: 1, b: 'c' })).to eq("{\"a\":1,\"b\":\"c\"}") + end + end end diff --git a/spec/requests/projects/ml/experiments_controller_spec.rb b/spec/requests/projects/ml/experiments_controller_spec.rb index a1fd07b0a65..474194cf1d4 100644 --- a/spec/requests/projects/ml/experiments_controller_spec.rb +++ b/spec/requests/projects/ml/experiments_controller_spec.rb @@ -75,35 +75,58 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do end describe 'pagination' do - let_it_be(:candidates) { create_list(:ml_candidates, 5, experiment: experiment) } + let_it_be(:candidates) do + create_list(:ml_candidates, 5, experiment: experiment).tap do |c| + c.first.metrics.create!(name: 'metric1', value: 0.3) + c[1].metrics.create!(name: 'metric1', value: 0.2) + c.last.metrics.create!(name: 'metric1', value: 0.6) + end + end + + let(:params) { basic_params.merge(id: experiment.iid) } before do stub_const("Projects::Ml::ExperimentsController::MAX_CANDIDATES_PER_PAGE", 2) - candidates show_experiment end - context 'when out of bounds' do - let(:params) { basic_params.merge(id: experiment.iid, page: 10000) } + it 'fetches only MAX_CANDIDATES_PER_PAGE candidates' do + expect(assigns(:candidates).size).to eq(2) + end - it 'redirects to last page' do - last_page = (experiment.candidates.size + 1) / 2 + it 'paginates' do + received = assigns(:page_info) - expect(response).to redirect_to(project_ml_experiment_path(project, experiment.iid, page: last_page)) - end + expect(received).to include({ + has_next_page: true, + has_previous_page: false, + start_cursor: nil + }) end - context 'when bad page' do - let(:params) { basic_params.merge(id: experiment.iid, page: 's') } + context 'when order by metric' do + let(:params) do + { + order_by: "metric1", + order_by_type: "metric", + sort: "desc" + } + end + + it 'paginates', :aggregate_failures do + page = assigns(:candidates) + + expect(page.first).to eq(candidates.last) + expect(page.last).to eq(candidates.first) + + new_params = params.merge(cursor: assigns(:page_info)[:end_cursor]) + + show_experiment(new_params) + + new_page = assigns(:candidates) - it 'uses first page' do - expect(assigns(:pagination_info)).to include( - page: 1, - is_last_page: false, - per_page: 2, - total_items: experiment.candidates&.size - ) + expect(new_page.first).to eq(candidates[1]) end end end @@ -151,8 +174,8 @@ RSpec.describe Projects::Ml::ExperimentsController, feature_category: :mlops do private - def show_experiment - get project_ml_experiment_path(project, experiment.iid), params: params + def show_experiment(new_params = nil) + get project_ml_experiment_path(project, experiment.iid), params: new_params || params end def list_experiments |