diff options
51 files changed, 833 insertions, 267 deletions
diff --git a/.rubocop.yml b/.rubocop.yml index be3a61a5fde..bd10e12f098 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -338,7 +338,6 @@ Database/MultipleDatabases: - 'ee/db/**/*.rb' - 'spec/migrations/**/*.rb' - 'lib/tasks/gitlab/db.rake' - - 'lib/gitlab/background_migration/**/*.rb' - 'ee/lib/ee/gitlab/background_migration/**/*.rb' - 'spec/lib/gitlab/background_migration/**/*.rb' - 'spec/lib/gitlab/database/**/*.rb' diff --git a/.rubocop_todo/database/multiple_databases.yml b/.rubocop_todo/database/multiple_databases.yml index 8e372b89bbd..019fb41094d 100644 --- a/.rubocop_todo/database/multiple_databases.yml +++ b/.rubocop_todo/database/multiple_databases.yml @@ -14,6 +14,34 @@ Database/MultipleDatabases: - 'ee/spec/services/ee/merge_requests/update_service_spec.rb' - 'lib/backup/database.rb' - 'lib/backup/manager.rb' + - lib/gitlab/background_migration/backfill_integrations_type_new.rb + - lib/gitlab/background_migration/backfill_issue_search_data.rb + - lib/gitlab/background_migration/backfill_member_namespace_for_group_members.rb + - lib/gitlab/background_migration/backfill_namespace_id_for_namespace_route.rb + - lib/gitlab/background_migration/backfill_namespace_id_for_project_route.rb + - lib/gitlab/background_migration/backfill_namespace_traversal_ids_children.rb + - lib/gitlab/background_migration/backfill_projects_with_coverage.rb + - lib/gitlab/background_migration/backfill_upvotes_count_on_issues.rb + - lib/gitlab/background_migration/backfill_user_namespace.rb + - lib/gitlab/background_migration/copy_ci_builds_columns_to_security_scans.rb + - lib/gitlab/background_migration/delete_orphaned_deployments.rb + - lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images.rb + - lib/gitlab/background_migration/fix_duplicate_project_name_and_path.rb + - lib/gitlab/background_migration/fix_projects_without_project_feature.rb + - lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb + - lib/gitlab/background_migration/migrate_shimo_confluence_integration_category.rb + - lib/gitlab/background_migration/migrate_stage_status.rb + - lib/gitlab/background_migration/move_container_registry_enabled_to_project_feature.rb + - lib/gitlab/background_migration/nullify_orphan_runner_id_on_ci_builds.rb + - lib/gitlab/background_migration/populate_container_repository_migration_plan.rb + - lib/gitlab/background_migration/populate_topics_non_private_projects_count.rb + - lib/gitlab/background_migration/populate_topics_total_projects_count_cache.rb + - lib/gitlab/background_migration/populate_vulnerability_reads.rb + - lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces.rb + - lib/gitlab/background_migration/remove_vulnerability_finding_links.rb + - lib/gitlab/background_migration/update_timelogs_null_spent_at.rb + - lib/gitlab/background_migration/update_timelogs_project_id.rb + - lib/gitlab/background_migration/update_users_where_two_factor_auth_required_from_group.rb - 'lib/gitlab/database.rb' - 'lib/gitlab/database/load_balancing/load_balancer.rb' - 'lib/gitlab/database/migrations/observers/query_log.rb' diff --git a/.rubocop_todo/migration/background_migration_record.yml b/.rubocop_todo/migration/background_migration_record.yml new file mode 100644 index 00000000000..7699d508da5 --- /dev/null +++ b/.rubocop_todo/migration/background_migration_record.yml @@ -0,0 +1,57 @@ +--- +Migration/BackgroundMigrationRecord: + Exclude: + - lib/gitlab/background_migration/add_primary_email_to_emails_if_user_confirmed.rb + - lib/gitlab/background_migration/backfill_artifact_expiry_date.rb + - lib/gitlab/background_migration/backfill_ci_namespace_mirrors.rb + - lib/gitlab/background_migration/backfill_ci_project_mirrors.rb + - lib/gitlab/background_migration/backfill_ci_queuing_tables.rb + - lib/gitlab/background_migration/backfill_draft_status_on_merge_requests.rb + - lib/gitlab/background_migration/backfill_namespace_traversal_ids_children.rb + - lib/gitlab/background_migration/backfill_namespace_traversal_ids_roots.rb + - lib/gitlab/background_migration/backfill_project_repositories.rb + - lib/gitlab/background_migration/backfill_projects_with_coverage.rb + - lib/gitlab/background_migration/backfill_topics_title.rb + - lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex.rb + - lib/gitlab/background_migration/cleanup_orphaned_lfs_objects_projects.rb + - lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images.rb + - lib/gitlab/background_migration/drop_invalid_security_findings.rb + - lib/gitlab/background_migration/drop_invalid_vulnerabilities.rb + - lib/gitlab/background_migration/encrypt_integration_properties.rb + - lib/gitlab/background_migration/encrypt_static_object_token.rb + - lib/gitlab/background_migration/extract_project_topics_into_separate_table.rb + - lib/gitlab/background_migration/fix_duplicate_project_name_and_path.rb + - lib/gitlab/background_migration/fix_first_mentioned_in_commit_at.rb + - lib/gitlab/background_migration/fix_projects_without_prometheus_service.rb + - lib/gitlab/background_migration/fix_vulnerability_occurrences_with_hashes_as_raw_metadata.rb + - lib/gitlab/background_migration/merge_topics_with_same_name.rb + - lib/gitlab/background_migration/migrate_merge_request_diff_commit_users.rb + - lib/gitlab/background_migration/migrate_null_private_profile_to_false.rb + - lib/gitlab/background_migration/migrate_project_taggings_context_from_tags_to_topics.rb + - lib/gitlab/background_migration/migrate_stage_status.rb + - lib/gitlab/background_migration/migrate_u2f_webauthn.rb + - lib/gitlab/background_migration/populate_latest_pipeline_ids.rb + - lib/gitlab/background_migration/populate_topics_non_private_projects_count.rb + - lib/gitlab/background_migration/populate_topics_total_projects_count_cache.rb + - lib/gitlab/background_migration/project_namespaces/models/namespace.rb + - lib/gitlab/background_migration/project_namespaces/models/project.rb + - lib/gitlab/background_migration/recalculate_vulnerabilities_occurrences_uuid.rb + - lib/gitlab/background_migration/remove_all_trace_expiration_dates.rb + - lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings.rb + - lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings.rb + - lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects.rb + - lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects.rb + - lib/gitlab/background_migration/update_jira_tracker_data_deployment_type_based_on_url.rb + - ee/lib/ee/gitlab/background_migration/backfill_iteration_cadence_id_for_boards.rb + - ee/lib/ee/gitlab/background_migration/create_security_setting.rb + - ee/lib/ee/gitlab/background_migration/drop_invalid_remediations.rb + - ee/lib/ee/gitlab/background_migration/drop_invalid_remediations.rb + - ee/lib/ee/gitlab/background_migration/fix_incorrect_max_seats_used.rb + - ee/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules.rb + - ee/lib/ee/gitlab/background_migration/migrate_approver_to_approval_rules_in_batch.rb + - ee/lib/ee/gitlab/background_migration/migrate_requirements_to_work_items.rb + - ee/lib/ee/gitlab/background_migration/populate_latest_pipeline_ids.rb + - ee/lib/ee/gitlab/background_migration/populate_resolved_on_default_branch_column.rb + - ee/lib/ee/gitlab/background_migration/populate_status_column_of_security_scans.rb + - ee/lib/ee/gitlab/background_migration/populate_uuids_for_security_findings.rb + - ee/lib/ee/gitlab/background_migration/update_vulnerability_occurrences_location.rb
\ No newline at end of file diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index d15ccdc2af2..fd167954e44 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -ccfab390f7c32dae9d5683662be62d27c80f768f +bf43ae6382796653099fe76455e52b3e15df049e diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index a43aed6c521..eb7e85bde41 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -45,6 +45,7 @@ import { PAGE_SIZE, PARAM_PAGE_AFTER, PARAM_PAGE_BEFORE, + PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, TOKEN_TYPE_ASSIGNEE, @@ -138,43 +139,17 @@ export default { }, }, data() { - const pageAfter = getParameterByName(PARAM_PAGE_AFTER); - const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); - const state = getParameterByName(PARAM_STATE); - const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; - const dashboardSortKey = getSortKey(this.initialSort); - const graphQLSortKey = - isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase(); - - // The initial sort is an old enum value when it is saved on the dashboard issues page. - // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. - let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; - - if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { - this.showIssueRepositioningMessage(); - sortKey = defaultSortKey; - } - - const isSearchDisabled = - this.isAnonymousSearchDisabled && - !this.isSignedIn && - window.location.search.includes('search='); - - if (isSearchDisabled) { - this.showAnonymousSearchingMessage(); - } - return { exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), - filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search), + filterTokens: [], issues: [], issuesCounts: {}, issuesError: null, pageInfo: {}, - pageParams: getInitialPageParams(sortKey, pageAfter, pageBefore), + pageParams: {}, showBulkEditSidebar: false, - sortKey, - state: state || IssuableStates.Opened, + sortKey: CREATED_DESC, + state: IssuableStates.Opened, }; }, apollo: { @@ -416,7 +391,15 @@ export default { }; }, }, + watch: { + $route(newValue, oldValue) { + if (newValue.fullPath !== oldValue.fullPath) { + this.updateData(getParameterByName(PARAM_SORT)); + } + }, + }, created() { + this.updateData(this.initialSort); this.cache = {}; }, mounted() { @@ -516,6 +499,8 @@ export default { this.pageParams = getInitialPageParams(this.sortKey); } this.state = state; + + this.$router.push({ query: this.urlParams }); }, handleDismissAlert() { this.issuesError = null; @@ -525,8 +510,11 @@ export default { this.showAnonymousSearchingMessage(); return; } + this.pageParams = getInitialPageParams(this.sortKey); this.filterTokens = filter; + + this.$router.push({ query: this.urlParams }); }, handleNextPage() { this.pageParams = { @@ -534,6 +522,8 @@ export default { firstPageSize: PAGE_SIZE, }; scrollUp(); + + this.$router.push({ query: this.urlParams }); }, handlePreviousPage() { this.pageParams = { @@ -541,6 +531,8 @@ export default { lastPageSize: PAGE_SIZE, }; scrollUp(); + + this.$router.push({ query: this.urlParams }); }, handleReorder({ newIndex, oldIndex }) { const issueToMove = this.issues[oldIndex]; @@ -592,6 +584,8 @@ export default { if (this.isSignedIn) { this.saveSortPreference(sortKey); } + + this.$router.push({ query: this.urlParams }); }, saveSortPreference(sortKey) { this.$apollo @@ -623,6 +617,39 @@ export default { toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, + updateData(sortValue) { + const pageAfter = getParameterByName(PARAM_PAGE_AFTER); + const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); + const state = getParameterByName(PARAM_STATE); + + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(sortValue); + const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); + + // The initial sort is an old enum value when it is saved on the dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { + this.showIssueRepositioningMessage(); + sortKey = defaultSortKey; + } + + const isSearchDisabled = + this.isAnonymousSearchDisabled && + !this.isSignedIn && + window.location.search.includes('search='); + + if (isSearchDisabled) { + this.showAnonymousSearchingMessage(); + } + + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); + this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search); + this.pageParams = getInitialPageParams(sortKey, pageAfter, pageBefore); + this.sortKey = sortKey; + this.state = state || IssuableStates.Opened; + }, }, }; </script> @@ -649,10 +676,10 @@ export default { :is-manual-ordering="isManualOrdering" :show-bulk-edit-sidebar="showBulkEditSidebar" :show-pagination-controls="showPaginationControls" - :use-keyset-pagination="true" + sync-filter-and-sort + use-keyset-pagination :has-next-page="pageInfo.hasNextPage" :has-previous-page="pageInfo.hasPreviousPage" - :url-params="urlParams" @click-tab="handleClickTab" @dismiss-alert="handleDismissAlert" @filter="handleFilter" diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 4b07a078512..d4e2cdcfb1d 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -58,6 +58,7 @@ export const PAGE_SIZE = 20; export const PAGE_SIZE_MANUAL = 100; export const PARAM_PAGE_AFTER = 'page_after'; export const PARAM_PAGE_BEFORE = 'page_before'; +export const PARAM_SORT = 'sort'; export const PARAM_STATE = 'state'; export const RELATIVE_POSITION = 'relative_position'; @@ -174,6 +175,7 @@ export const filters = { }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[milestone_title]', + [SPECIAL_FILTER]: 'not[milestone_title]', }, }, }, @@ -258,6 +260,7 @@ export const filters = { }, [OPERATOR_IS_NOT]: { [NORMAL_FILTER]: 'not[iteration_id]', + [SPECIAL_FILTER]: 'not[iteration_id]', }, }, }, diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 3b2d37eab74..61cd3840cc1 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -1,6 +1,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; import createDefaultClient from '~/lib/graphql'; @@ -53,6 +54,7 @@ export function mountIssuesListApp() { } Vue.use(VueApollo); + Vue.use(VueRouter); const resolvers = { Mutation: { @@ -74,11 +76,6 @@ export function mountIssuesListApp() { }, }; - const defaultClient = createDefaultClient(resolvers); - const apolloProvider = new VueApollo({ - defaultClient, - }); - const { autocompleteAwardEmojisPath, calendarPath, @@ -121,7 +118,14 @@ export function mountIssuesListApp() { return new Vue({ el, name: 'IssuesListRoot', - apolloProvider, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }), + router: new VueRouter({ + base: window.location.pathname, + mode: 'history', + routes: [{ path: '/' }], + }), provide: { autocompleteAwardEmojisPath, calendarPath, diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 4b77bd9bc5f..2aa74dd2ea9 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -1,3 +1,4 @@ +import { createTerm } from '@gitlab/ui/src/components/base/filtered_search/filtered_search_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import { @@ -195,11 +196,12 @@ const convertToFilteredSearchTerms = (locationSearch) => export const getFilterTokens = (locationSearch) => { if (!locationSearch) { - return []; + return [createTerm()]; } const filterTokens = convertToFilteredTokens(locationSearch); const searchTokens = convertToFilteredSearchTerms(locationSearch); - return filterTokens.concat(searchTokens); + const tokens = filterTokens.concat(searchTokens); + return tokens.length ? tokens : [createTerm()]; }; const getFilterType = (data, tokenType = '') => diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue index d4c97cbf038..9c8de9bef2d 100644 --- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue +++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue @@ -85,7 +85,7 @@ export default { <gl-avatar-labeled :src="dropdownItem.avatarUrl" :entity-name="dropdownItem.name" - :label="dropdownItem.name" + :label="dropdownItem.title" :size="32" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql index b193165062a..0c0a874d950 100644 --- a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql +++ b/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql @@ -3,6 +3,7 @@ query searchProjectTopics($search: String) { nodes { id name + title avatarUrl } } diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 6638a5de62f..230e1b009c8 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -89,32 +89,20 @@ export default { required: false, default: () => ({}), }, + syncFilterAndSort: { + type: Boolean, + required: false, + default: false, + }, }, data() { - let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending; - let selectedSortDirection = SortDirection.descending; - - // Extract correct sortBy value based on initialSortBy - if (this.initialSortBy) { - selectedSortOption = this.sortOptions - .filter( - (sortBy) => - sortBy.sortDirection.ascending === this.initialSortBy || - sortBy.sortDirection.descending === this.initialSortBy, - ) - .pop(); - selectedSortDirection = Object.keys(selectedSortOption.sortDirection).find( - (key) => selectedSortOption.sortDirection[key] === this.initialSortBy, - ); - } - return { initialRender: true, recentSearchesPromise: null, recentSearches: [], filterValue: this.initialFilterValue, - selectedSortOption, - selectedSortDirection, + selectedSortOption: this.sortOptions[0], + selectedSortDirection: SortDirection.descending, }; }, computed: { @@ -173,7 +161,20 @@ export default { return undefined; }, }, + watch: { + initialFilterValue(newValue) { + if (this.syncFilterAndSort) { + this.filterValue = newValue; + } + }, + initialSortBy(newValue) { + if (this.syncFilterAndSort) { + this.updateSelectedSortValues(newValue); + } + }, + }, created() { + this.updateSelectedSortValues(this.initialSortBy); if (this.recentSearchesStorageKey) this.setupRecentSearch(); }, methods: { @@ -309,6 +310,19 @@ export default { const cleared = true; this.$emit('onFilter', [], cleared); }, + updateSelectedSortValues(sort) { + if (!sort) { + return; + } + + this.selectedSortOption = this.sortOptions.find( + (sortBy) => + sortBy.sortDirection.ascending === sort || sortBy.sortDirection.descending === sort, + ); + this.selectedSortDirection = Object.keys(this.selectedSortOption.sortDirection).find( + (key) => this.selectedSortOption.sortDirection[key] === sort, + ); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 20f178dfb7d..8b293b2e9f6 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -168,6 +168,11 @@ export default { required: false, default: '', }, + syncFilterAndSort: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -282,6 +287,7 @@ export default { :sort-options="sortOptions" :initial-filter-value="initialFilterValue" :initial-sort-by="initialSortBy" + :sync-filter-and-sort="syncFilterAndSort" :show-checkbox="showBulkEditSidebar" :checkbox-checked="allIssuablesChecked" class="gl-flex-grow-1 gl-border-t-none row-content-block" diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb index 03a9b436453..16e53c8bd0c 100644 --- a/app/controllers/admin/background_migrations_controller.rb +++ b/app/controllers/admin/background_migrations_controller.rb @@ -4,6 +4,8 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController feature_category :database urgency :low + around_action :support_multiple_databases + def index @relations_by_tab = { 'queued' => batched_migration_class.queued.queue_order, @@ -14,6 +16,7 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController @current_tab = @relations_by_tab.key?(params[:tab]) ? params[:tab] : 'queued' @migrations = @relations_by_tab[@current_tab].page(params[:page]) @successful_rows_counts = batched_migration_class.successful_rows_counts(@migrations.map(&:id)) + @databases = Gitlab::Database.db_config_names end def show @@ -43,6 +46,18 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController private + def support_multiple_databases + Gitlab::Database::SharedModel.using_connection(base_model.connection) do + yield + end + end + + def base_model + database = params[:database] || Gitlab::Database::MAIN_DATABASE_NAME + + Gitlab::Database.database_base_models[database] + end + def batched_migration_class @batched_migration_class ||= Gitlab::Database::BackgroundMigration::BatchedMigration end diff --git a/app/graphql/types/projects/topic_type.rb b/app/graphql/types/projects/topic_type.rb index c579f2f2b9d..bde6d79ddbf 100644 --- a/app/graphql/types/projects/topic_type.rb +++ b/app/graphql/types/projects/topic_type.rb @@ -12,6 +12,10 @@ module Types field :name, GraphQL::Types::String, null: false, description: 'Name of the topic.' + field :title, GraphQL::Types::String, null: false, + method: :title_or_name, + description: 'Title of the topic.' + field :description, GraphQL::Types::String, null: true, description: 'Description of the topic.' diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index c21689a72d2..bc7f94e4374 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -23,13 +23,17 @@ module Projects reorder(order_expression.desc, arel_table['non_private_projects_count'].desc, arel_table['id']) end + def title_or_name + title || name + end + class << self def find_by_name_case_insensitive(name) find_by('LOWER(name) = ?', name.downcase) end def search(query) - fuzzy_search(query, [:name]) + fuzzy_search(query, [:name, :title]) end def update_non_private_projects_counter(ids_before, ids_after, project_visibility_level_before, project_visibility_level_after) diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index ffbe154c1a8..af1b254c46f 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -457,7 +457,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def project_topic_list strong_memoize(:project_topic_list) do - project.topics.map(&:name) + project.topics.map { |topic| { name: topic.name, title: topic.title_or_name } } end end diff --git a/app/views/explore/projects/topic.html.haml b/app/views/explore/projects/topic.html.haml index aeb040ea61f..76e59a49ed1 100644 --- a/app/views/explore/projects/topic.html.haml +++ b/app/views/explore/projects/topic.html.haml @@ -1,7 +1,7 @@ - @hide_top_links = false - @no_container = true -- page_title @topic.name, _("Topics") -- max_topic_name_length = 50 +- page_title @topic.title_or_name, _("Topics") +- max_topic_title_length = 50 = render_dashboard_ultimate_trial(current_user) @@ -9,12 +9,12 @@ .gl-pb-5.gl-align-items-center.gl-justify-content-center.gl-display-flex .avatar-container.rect-avatar.s60.gl-flex-shrink-0 = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s60') - - if @topic.name.length > max_topic_name_length - %h1.gl-mt-3.str-truncated.has-tooltip{ title: @topic.name } - = truncate(@topic.name, length: max_topic_name_length) + - if @topic.title_or_name.length > max_topic_title_length + %h1.gl-mt-3.gl-str-truncated.has-tooltip{ title: @topic.title_or_name } + = truncate(@topic.title_or_name, length: max_topic_title_length) - else %h1.gl-mt-3 - = @topic.name + = @topic.title_or_name - if @topic.description.present? .topic-description.gl-ml-4.gl-mr-4 = markdown(@topic.description) diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml index b7df369327c..e3895663033 100644 --- a/app/views/shared/projects/_topics.html.haml +++ b/app/views/shared/projects/_topics.html.haml @@ -7,25 +7,25 @@ = sprite_icon('tag', css_class: 'icon gl-relative gl-mr-2') - project.topics_to_show.each do |topic| - - explore_project_topic_path = topic_explore_projects_path(topic_name: topic) - - if topic.length > max_project_topic_length - %a.gl-mr-3.has-tooltip{ data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag truncate(topic, length: max_project_topic_length) + - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) + - if topic[:title].length > max_project_topic_length + %a.gl-mr-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) - else %a.gl-mr-3{ href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag topic + = gl_badge_tag topic[:title] - if project.has_extra_topics? - title = _('More topics') - content = capture do %span.gl-display-inline-flex.gl-flex-wrap - project.topics_not_shown.each do |topic| - - explore_project_topic_path = topic_explore_projects_path(topic_name: topic) - - if topic.length > max_project_topic_length - %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic, href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag truncate(topic, length: max_project_topic_length) + - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name]) + - if topic[:title].length > max_project_topic_length + %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' } + = gl_badge_tag truncate(topic[:title], length: max_project_topic_length) - else %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' } - = gl_badge_tag topic + = gl_badge_tag topic[:title] .text-nowrap{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown } diff --git a/app/views/shared/topics/_topic.html.haml b/app/views/shared/topics/_topic.html.haml index a47d4495777..ca1098511da 100644 --- a/app/views/shared/topics/_topic.html.haml +++ b/app/views/shared/topics/_topic.html.haml @@ -1,4 +1,4 @@ -- max_topic_name_length = 30 +- max_topic_title_length = 30 - detail_page_link = topic_explore_projects_path(topic_name: topic.name) .col-lg-3.col-md-4.col-sm-12 @@ -8,9 +8,9 @@ = link_to detail_page_link do = topic_icon(topic, class: "avatar s40") = link_to detail_page_link do - - if topic.name.length > max_topic_name_length - %h5.str-truncated.has-tooltip{ title: topic.name } - = truncate(topic.name, length: max_topic_name_length) + - if topic.title_or_name.length > max_topic_title_length + %h5.gl-str-truncated.has-tooltip{ title: topic.title_or_name } + = truncate(topic.title_or_name, length: max_topic_title_length) - else %h5 - = topic.name + = topic.title_or_name diff --git a/config/initializers/attr_encrypted_no_db_connection.rb b/config/initializers/attr_encrypted_no_db_connection.rb index 91eef7d0a5c..e46ee9f8036 100644 --- a/config/initializers/attr_encrypted_no_db_connection.rb +++ b/config/initializers/attr_encrypted_no_db_connection.rb @@ -17,7 +17,7 @@ module AttrEncrypted # ensuring the connection is released def attribute_instance_methods_as_symbols # Use with_connection so the connection doesn't stay pinned to the thread. - connected = ::ActiveRecord::Base.connection_pool.with_connection(&:active?) rescue false + connected = ::ActiveRecord::Base.connection_pool.with_connection(&:active?) rescue false # rubocop:disable Database/MultipleDatabases if connected # Call version from AttrEncrypted::Adapters::ActiveRecord diff --git a/db/post_migrate/20220324165436_schedule_backfill_project_settings.rb b/db/post_migrate/20220324165436_schedule_backfill_project_settings.rb new file mode 100644 index 00000000000..c1aaea44bfd --- /dev/null +++ b/db/post_migrate/20220324165436_schedule_backfill_project_settings.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ScheduleBackfillProjectSettings < Gitlab::Database::Migration[1.0] + MIGRATION = 'BackfillProjectSettings' + INTERVAL = 2.minutes + BATCH_SIZE = 5_000 + SUB_BATCH_SIZE = 200 + + def up + queue_batched_background_migration( + MIGRATION, + :projects, + :id, + job_interval: INTERVAL, + batch_size: BATCH_SIZE, + sub_batch_size: SUB_BATCH_SIZE + ) + end + + def down + Gitlab::Database::BackgroundMigration::BatchedMigration + .for_configuration(MIGRATION, :projects, :id, []) + .delete_all + end +end diff --git a/db/schema_migrations/20220324165436 b/db/schema_migrations/20220324165436 new file mode 100644 index 00000000000..e2e366134f6 --- /dev/null +++ b/db/schema_migrations/20220324165436 @@ -0,0 +1 @@ +6fcf6e2ecc7d9b62adf20add28b1eeeebde449dfa52d2af67d9098768d3cb67e
\ No newline at end of file diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 4f8d6670260..89f341f51c4 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -16709,6 +16709,7 @@ Representing a to-do entry. | <a id="topicdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. | | <a id="topicid"></a>`id` | [`ID!`](#id) | ID of the topic. | | <a id="topicname"></a>`name` | [`String!`](#string) | Name of the topic. | +| <a id="topictitle"></a>`title` | [`String!`](#string) | Title of the topic. | ### `Tree` diff --git a/doc/development/database/background_migrations.md b/doc/development/database/background_migrations.md index 09868af5392..80ba0336bda 100644 --- a/doc/development/database/background_migrations.md +++ b/doc/development/database/background_migrations.md @@ -45,13 +45,17 @@ into this category. ## Isolation Background migrations must be isolated and can not use application code (for example, -models defined in `app/models`). Since these migrations can take a long time to -run it's possible for new versions to be deployed while they are still running. +models defined in `app/models` except the `ApplicationRecord` classes). Since these migrations +can take a long time to run it's possible for new versions to be deployed while they are still running. It's also possible for different migrations to be executed at the same time. This means that different background migrations should not migrate data in a way that would cause conflicts. +## Accessing data for multiple databases + +See [Accessing data for multiple databases of Batched Background Migrations](batched_background_migrations.md#accessing-data-for-multiple-databases) for more details. + ## Idempotence Background migrations are executed in a context of a Sidekiq process. @@ -190,7 +194,7 @@ class: ```ruby class Gitlab::BackgroundMigration::ExtractIntegrationsUrl - class Integration < ActiveRecord::Base + class Integration < ::ApplicationRecord self.table_name = 'integrations' end @@ -214,7 +218,7 @@ created and updated integrations. We can do this using something along the lines the following: ```ruby -class Integration < ActiveRecord::Base +class Integration < ::ApplicationRecord after_commit :schedule_integration_migration, on: :update after_commit :schedule_integration_migration, on: :create diff --git a/doc/development/database/batched_background_migrations.md b/doc/development/database/batched_background_migrations.md index bfa53a728e2..0064be1e4a5 100644 --- a/doc/development/database/batched_background_migrations.md +++ b/doc/development/database/batched_background_migrations.md @@ -41,9 +41,57 @@ into this category. ## Isolation -Batched background migrations must be isolated and can not use application code. (For example, -models defined in `app/models`.). Because these migrations can take a long time to -run, it's possible for new versions to deploy while the migrations are still running. +Batched background migrations must be isolated and can not use application code (for example, +models defined in `app/models` except the `ApplicationRecord` classes). +Because these migrations can take a long time to run, it's possible +for new versions to deploy while the migrations are still running. + +## Accessing data for multiple databases + +Background Migration contrary to regular migrations does have access to multiple databases +and can be used to efficiently access and update data across them. To properly indicate +a database to be used it is desired to create ActiveRecord model inline the migration code. +Such model should use a correct [`ApplicationRecord`](multiple_databases.md#gitlab-schema) +depending on which database the table is located. As such usage of `ActiveRecord::Base` +is disallowed as it does not describe a explicitly database to be used to access given table. + +```ruby +# good +class Gitlab::BackgroundMigration::ExtractIntegrationsUrl + class Project < ::ApplicationRecord + self.table_name = 'projects' + end + + class Build < ::Ci::ApplicationRecord + self.table_name = 'ci_builds' + end +end + +# bad +class Gitlab::BackgroundMigration::ExtractIntegrationsUrl + class Project < ActiveRecord::Base + self.table_name = 'projects' + end + + class Build < ActiveRecord::Base + self.table_name = 'ci_builds' + end +end +``` + +Similarly the usage of `ActiveRecord::Base.connection` is disallowed and needs to be +replaced preferrably with the usage of model connection. + +```ruby +# good +Project.connection.execute("SELECT * FROM projects") + +# acceptable +ApplicationRecord.connection.execute("SELECT * FROM projects") + +# bad +ActiveRecord::Base.connection.execute("SELECT * FROM projects") +``` ## Idempotence @@ -151,7 +199,7 @@ do this work in a regular migration. ```ruby class Gitlab::BackgroundMigration::ExtractIntegrationsUrl - class Integration < ActiveRecord::Base + class Integration < ::ApplicationRecord self.table_name = 'integrations' end diff --git a/doc/development/database/strings_and_the_text_data_type.md b/doc/development/database/strings_and_the_text_data_type.md index 4ed7cf1b4de..7aa529e1518 100644 --- a/doc/development/database/strings_and_the_text_data_type.md +++ b/doc/development/database/strings_and_the_text_data_type.md @@ -206,7 +206,7 @@ class ScheduleCapTitleLengthOnIssues < Gitlab::Database::Migration[1.0] disable_ddl_transaction! - class Issue < ActiveRecord::Base + class Issue < ::ApplicationRecord include EachBatch self.table_name = 'issues' diff --git a/lib/gitlab/background_migration/backfill_project_settings.rb b/lib/gitlab/background_migration/backfill_project_settings.rb new file mode 100644 index 00000000000..c2194853570 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_project_settings.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Back-fill project settings for projects that do not yet have one. + class BackfillProjectSettings + include Gitlab::Database::DynamicModelHelpers + + def perform(start_id, end_id, batch_table, batch_column, sub_batch_size, pause_ms) + batch_relation = relation_scoped_to_range(batch_table, batch_column, start_id, end_id) + + batch_relation.each_batch(column: batch_column, of: sub_batch_size) do |sub_batch| + insert_sql = <<~SQL + INSERT INTO project_settings (project_id, created_at, updated_at) + #{sub_batch.where(project_settings: { project_id: nil }) + .select('projects.id, NOW(), NOW()') + .to_sql} + ON CONFLICT (project_id) DO NOTHING + SQL + + connection.execute(insert_sql) + + pause_ms = 0 if pause_ms < 0 + sleep(pause_ms * 0.001) + end + end + + private + + def connection + ActiveRecord::Base.connection + end + + def relation_scoped_to_range(source_table, source_key_column, start_id, stop_id) + define_batchable_model(:projects, connection: connection) + .where(source_key_column => start_id..stop_id) + .joins("LEFT OUTER JOIN project_settings ON project_settings.project_id = projects.id") + end + end + end +end diff --git a/lib/sidebars/projects/menus/settings_menu.rb b/lib/sidebars/projects/menus/settings_menu.rb index 2411ca8263a..2b5b3cdbb22 100644 --- a/lib/sidebars/projects/menus/settings_menu.rb +++ b/lib/sidebars/projects/menus/settings_menu.rb @@ -14,9 +14,9 @@ module Sidebars add_item(access_tokens_menu_item) add_item(repository_menu_item) add_item(ci_cd_menu_item) - add_item(monitor_menu_item) - add_item(pages_menu_item) add_item(packages_and_registries_menu_item) + add_item(pages_menu_item) + add_item(monitor_menu_item) add_item(usage_quotas_menu_item) true @@ -103,16 +103,17 @@ module Sidebars ) end - def monitor_menu_item - if context.project.archived? || !can?(context.current_user, :admin_operations, context.project) - return ::Sidebars::NilMenuItem.new(item_id: :monitor) + def packages_and_registries_menu_item + if !Gitlab.config.registry.enabled || + !can?(context.current_user, :destroy_container_image, context.project) + return ::Sidebars::NilMenuItem.new(item_id: :packages_and_registries) end ::Sidebars::MenuItem.new( - title: _('Monitor'), - link: project_settings_operations_path(context.project), - active_routes: { path: 'operations#show' }, - item_id: :monitor + title: _('Packages & Registries'), + link: project_settings_packages_and_registries_path(context.project), + active_routes: { path: 'packages_and_registries#index' }, + item_id: :packages_and_registries ) end @@ -129,17 +130,16 @@ module Sidebars ) end - def packages_and_registries_menu_item - if !Gitlab.config.registry.enabled || - !can?(context.current_user, :destroy_container_image, context.project) - return ::Sidebars::NilMenuItem.new(item_id: :packages_and_registries) + def monitor_menu_item + if context.project.archived? || !can?(context.current_user, :admin_operations, context.project) + return ::Sidebars::NilMenuItem.new(item_id: :monitor) end ::Sidebars::MenuItem.new( - title: _('Packages & Registries'), - link: project_settings_packages_and_registries_path(context.project), - active_routes: { path: 'packages_and_registries#index' }, - item_id: :packages_and_registries + title: _('Monitor'), + link: project_settings_operations_path(context.project), + active_routes: { path: 'operations#show' }, + item_id: :monitor ) end diff --git a/lib/sidebars/projects/panel.rb b/lib/sidebars/projects/panel.rb index 6bb4fb52e2a..1af8e14f034 100644 --- a/lib/sidebars/projects/panel.rb +++ b/lib/sidebars/projects/panel.rb @@ -28,9 +28,9 @@ module Sidebars add_menu(Sidebars::Projects::Menus::CiCdMenu.new(context)) add_menu(Sidebars::Projects::Menus::SecurityComplianceMenu.new(context)) add_menu(Sidebars::Projects::Menus::DeploymentsMenu.new(context)) - add_menu(Sidebars::Projects::Menus::MonitorMenu.new(context)) - add_menu(Sidebars::Projects::Menus::InfrastructureMenu.new(context)) add_menu(Sidebars::Projects::Menus::PackagesRegistriesMenu.new(context)) + add_menu(Sidebars::Projects::Menus::InfrastructureMenu.new(context)) + add_menu(Sidebars::Projects::Menus::MonitorMenu.new(context)) add_menu(Sidebars::Projects::Menus::AnalyticsMenu.new(context)) add_wiki_menus add_menu(Sidebars::Projects::Menus::SnippetsMenu.new(context)) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2c229c9cc41..8292a398295 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16669,9 +16669,6 @@ msgstr "" msgid "Geo|Geo sites are paused using a command run on the site" msgstr "" -msgid "Geo|Geo supports replication of many data types." -msgstr "" - msgid "Geo|Go to the primary site" msgstr "" @@ -17041,7 +17038,7 @@ msgstr "" msgid "Geo|Waiting for scheduler" msgstr "" -msgid "Geo|With GitLab Geo, you can install a special read-only and replicated instance anywhere. %{linkStart}Learn more%{linkEnd}" +msgid "Geo|With GitLab Geo, you can install a special read-only and replicated instance anywhere." msgstr "" msgid "Geo|You are on a secondary, %{b_open}read-only%{b_close} Geo site." diff --git a/package.json b/package.json index 061e19d28f7..c71fd440708 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,8 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", - "@gitlab/svgs": "2.10.0", - "@gitlab/ui": "39.3.1", + "@gitlab/svgs": "2.11.0", + "@gitlab/ui": "39.4.0", "@gitlab/visual-review-tools": "1.7.0", "@rails/actioncable": "6.1.4-7", "@rails/ujs": "6.1.4-7", diff --git a/rubocop/cop/database/multiple_databases.rb b/rubocop/cop/database/multiple_databases.rb index f20348d9d1f..470c282f60f 100644 --- a/rubocop/cop/database/multiple_databases.rb +++ b/rubocop/cop/database/multiple_databases.rb @@ -22,7 +22,7 @@ module RuboCop ].freeze def_node_matcher :active_record_base_method_is_used?, <<~PATTERN - (send (const (const nil? :ActiveRecord) :Base) $_) + (send (const (const _ :ActiveRecord) :Base) $_) PATTERN def on_send(node) diff --git a/rubocop/cop/migration/background_migration_record.rb b/rubocop/cop/migration/background_migration_record.rb new file mode 100644 index 00000000000..2ee6b9f7b42 --- /dev/null +++ b/rubocop/cop/migration/background_migration_record.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + class BackgroundMigrationRecord < RuboCop::Cop::Cop + include MigrationHelpers + + MSG = <<~MSG + Don't use or inherit from ActiveRecord::Base. + Use ::ApplicationRecord or ::Ci::ApplicationRecord to ensure the correct database is used. + See https://docs.gitlab.com/ee/development/database/batched_background_migrations.html#accessing-data-for-multiple-databases. + MSG + + def_node_matcher :inherits_from_active_record_base?, <<~PATTERN + (class _ (const (const _ :ActiveRecord) :Base) _) + PATTERN + + def_node_search :class_new_active_record_base?, <<~PATTERN + (send (const _ :Class) :new (const (const _ :ActiveRecord) :Base) ...) + PATTERN + + def on_class(node) + return unless in_background_migration?(node) + return unless inherits_from_active_record_base?(node) + + add_offense(node, location: :expression) + end + + def on_send(node) + return unless in_background_migration?(node) + return unless class_new_active_record_base?(node) + + add_offense(node, location: :expression) + end + end + end + end +end diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb index 63b3766e126..f14f4d33709 100644 --- a/rubocop/migration_helpers.rb +++ b/rubocop/migration_helpers.rb @@ -32,6 +32,11 @@ module RuboCop in_deployment_migration?(node) || in_post_deployment_migration?(node) end + def in_background_migration?(node) + filepath(node).include?('/lib/gitlab/background_migration/') || + filepath(node).include?('/ee/lib/ee/gitlab/background_migration/') + end + def in_deployment_migration?(node) dirname(node).end_with?('db/migrate', 'db/geo/migrate') end @@ -56,8 +61,12 @@ module RuboCop private + def filepath(node) + node.location.expression.source_buffer.name + end + def dirname(node) - File.dirname(node.location.expression.source_buffer.name) + File.dirname(filepath(node)) end def rubocop_migrations_config diff --git a/spec/features/explore/topics_spec.rb b/spec/features/explore/topics_spec.rb index d6f3d6a123d..f0c57c2417a 100644 --- a/spec/features/explore/topics_spec.rb +++ b/spec/features/explore/topics_spec.rb @@ -13,13 +13,13 @@ RSpec.describe 'Explore Topics' do end context 'when topics exist' do - let!(:topic) { create(:topic, name: 'topic1') } + let!(:topic) { create(:topic, name: 'topic1', title: 'Topic 1') } it 'renders topic list' do visit topics_explore_projects_path expect(page).to have_current_path topics_explore_projects_path, ignore_query: true - expect(page).to have_content('topic1') + expect(page).to have_content(topic.title) end end end diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index 5098908857a..023601b0b1e 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Project navbar' do stub_config(registry: { enabled: false }) stub_feature_flags(harbor_registry_integration: false) - insert_package_nav(_('Infrastructure')) + insert_package_nav(_('Deployments')) insert_infrastructure_registry_nav insert_infrastructure_google_cloud_nav end @@ -49,7 +49,7 @@ RSpec.describe 'Project navbar' do stub_config(pages: { enabled: true }) insert_after_sub_nav_item( - _('Monitor'), + _('CI/CD'), within: _('Settings'), new_sub_nav_item_name: _('Pages') ) @@ -67,7 +67,7 @@ RSpec.describe 'Project navbar' do insert_container_nav insert_after_sub_nav_item( - _('Monitor'), + _('CI/CD'), within: _('Settings'), new_sub_nav_item_name: _('Packages & Registries') ) diff --git a/spec/features/topic_show_spec.rb b/spec/features/topic_show_spec.rb index 3a9865a6503..196fc34e3ea 100644 --- a/spec/features/topic_show_spec.rb +++ b/spec/features/topic_show_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe 'Topic show page' do - let_it_be(:topic) { create(:topic, name: 'my-topic', description: 'This is **my** topic https://google.com/ :poop: ```\ncode\n```', avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) } + let_it_be(:topic) { create(:topic, name: 'my-topic', title: 'My Topic', description: 'This is **my** topic https://google.com/ :poop: ```\ncode\n```', avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) } context 'when topic does not exist' do let(:path) { topic_explore_projects_path(topic_name: 'non-existing') } @@ -20,8 +20,9 @@ RSpec.describe 'Topic show page' do visit topic_explore_projects_path(topic_name: topic.name) end - it 'shows name, avatar and description as markdown' do - expect(page).to have_content(topic.name) + it 'shows title, avatar and description as markdown' do + expect(page).to have_content(topic.title) + expect(page).not_to have_content(topic.name) expect(page).to have_selector('.avatar-container > img.topic-avatar') expect(find('.topic-description')).to have_selector('p > strong') expect(find('.topic-description')).to have_selector('p > a[rel]') diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 5a9bd1ff8e4..24e04bae882 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -5,6 +5,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -58,6 +59,7 @@ describe('CE IssuesListApp component', () => { let wrapper; Vue.use(VueApollo); + Vue.use(VueRouter); const defaultProvide = { autocompleteAwardEmojisPath: 'autocomplete/award/emojis/path', @@ -107,6 +109,7 @@ describe('CE IssuesListApp component', () => { const mountComponent = ({ provide = {}, + data = {}, issuesQueryResponse = jest.fn().mockResolvedValue(defaultQueryResponse), issuesCountsQueryResponse = jest.fn().mockResolvedValue(getIssuesCountsQueryResponse), sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), @@ -117,14 +120,17 @@ describe('CE IssuesListApp component', () => { [getIssuesCountsQuery, issuesCountsQueryResponse], [setSortPreferenceMutation, sortPreferenceMutationResponse], ]; - const apolloProvider = createMockApollo(requestHandlers); return mountFn(IssuesListApp, { - apolloProvider, + apolloProvider: createMockApollo(requestHandlers), + router: new VueRouter({ mode: 'history' }), provide: { ...defaultProvide, ...provide, }, + data() { + return data; + }, }); }; @@ -139,10 +145,10 @@ describe('CE IssuesListApp component', () => { }); describe('IssuableList', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent(); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('renders', () => { @@ -167,10 +173,6 @@ describe('CE IssuesListApp component', () => { useKeysetPagination: true, hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage, hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage, - urlParams: { - sort: urlSortParams[CREATED_DESC], - state: IssuableStates.Opened, - }, }); }); }); @@ -200,7 +202,7 @@ describe('CE IssuesListApp component', () => { describe('csv import/export component', () => { describe('when user is signed in', () => { - beforeEach(async () => { + beforeEach(() => { setWindowLocation('?search=refactor&state=opened'); wrapper = mountComponent({ @@ -209,12 +211,12 @@ describe('CE IssuesListApp component', () => { }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('renders', () => { expect(findCsvImportExportButtons().props()).toMatchObject({ - exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&sort=created_date&state=opened`, + exportCsvPath: `${defaultProvide.exportCsvPath}?search=refactor&state=opened`, issuableCount: 1, }); }); @@ -252,11 +254,9 @@ describe('CE IssuesListApp component', () => { it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', async () => { wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount }); - jest.spyOn(eventHub, '$emit'); findGlButtonAt(2).vm.$emit('click'); - await waitForPromises(); expect(eventHub.$emit).toHaveBeenCalledWith('issuables:enableBulkEdit'); @@ -297,32 +297,25 @@ describe('CE IssuesListApp component', () => { describe('page', () => { it('page_after is set from the url params', () => { setWindowLocation('?page_after=randomCursorString'); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - page_after: 'randomCursorString', - }); + expect(wrapper.vm.$route.query).toMatchObject({ page_after: 'randomCursorString' }); }); it('page_before is set from the url params', () => { setWindowLocation('?page_before=anotherRandomCursorString'); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - page_before: 'anotherRandomCursorString', - }); + expect(wrapper.vm.$route.query).toMatchObject({ page_before: 'anotherRandomCursorString' }); }); }); describe('search', () => { it('is set from the url params', () => { setWindowLocation(locationSearch); - wrapper = mountComponent(); - expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + expect(wrapper.vm.$route.query).toMatchObject({ search: 'find issues' }); }); }); @@ -333,10 +326,7 @@ describe('CE IssuesListApp component', () => { it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => { wrapper = mountComponent({ provide: { initialSort: sort } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: getSortKey(sort), - urlParams: { sort }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort)); }); }); @@ -346,10 +336,7 @@ describe('CE IssuesListApp component', () => { it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => { wrapper = mountComponent({ provide: { initialSort: sort.toLowerCase() } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: sort, - urlParams: { sort: urlSortParams[sort] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(sort); }); }); @@ -359,10 +346,7 @@ describe('CE IssuesListApp component', () => { (sort) => { wrapper = mountComponent({ provide: { initialSort: sort } }); - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: CREATED_DESC, - urlParams: { sort: urlSortParams[CREATED_DESC] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); }, ); }); @@ -375,10 +359,7 @@ describe('CE IssuesListApp component', () => { }); it('changes the sort to the default of created descending', () => { - expect(findIssuableList().props()).toMatchObject({ - initialSortBy: CREATED_DESC, - urlParams: { sort: urlSortParams[CREATED_DESC] }, - }); + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -393,9 +374,7 @@ describe('CE IssuesListApp component', () => { describe('state', () => { it('is set from the url params', () => { const initialState = IssuableStates.All; - setWindowLocation(`?state=${initialState}`); - wrapper = mountComponent(); expect(findIssuableList().props('currentTab')).toBe(initialState); @@ -405,7 +384,6 @@ describe('CE IssuesListApp component', () => { describe('filter tokens', () => { it('is set from the url params', () => { setWindowLocation(locationSearch); - wrapper = mountComponent(); expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); @@ -414,7 +392,6 @@ describe('CE IssuesListApp component', () => { describe('when anonymous searching is performed', () => { beforeEach(() => { setWindowLocation(locationSearch); - wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); @@ -649,12 +626,12 @@ describe('CE IssuesListApp component', () => { ${'fetching issues'} | ${'issuesQueryResponse'} | ${IssuesListApp.i18n.errorFetchingIssues} ${'fetching issue counts'} | ${'issuesCountsQueryResponse'} | ${IssuesListApp.i18n.errorFetchingCounts} `('when there is an error $error', ({ mountOption, message }) => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ [mountOption]: jest.fn().mockRejectedValue(new Error('ERROR')), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('shows an error message', () => { @@ -676,29 +653,51 @@ describe('CE IssuesListApp component', () => { describe('when "click-tab" event is emitted by IssuableList', () => { beforeEach(() => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); }); - it('updates to the new tab', () => { + it('updates ui to the new tab', () => { expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed); }); - }); - describe.each(['next-page', 'previous-page'])( - 'when "%s" event is emitted by IssuableList', - (event) => { - beforeEach(() => { - wrapper = mountComponent(); + it('updates url to the new tab', () => { + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ state: IssuableStates.Closed }), + }); + }); + }); - findIssuableList().vm.$emit(event); + describe.each` + event | paramName | paramValue + ${'next-page'} | ${'page_after'} | ${'endCursor'} + ${'previous-page'} | ${'page_before'} | ${'startCursor'} + `('when "$event" event is emitted by IssuableList', ({ event, paramName, paramValue }) => { + beforeEach(() => { + wrapper = mountComponent({ + data: { + pageInfo: { + endCursor: 'endCursor', + startCursor: 'startCursor', + }, + }, }); + jest.spyOn(wrapper.vm.$router, 'push'); + + findIssuableList().vm.$emit(event); + }); - it('scrolls to the top', () => { - expect(scrollUp).toHaveBeenCalled(); + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); + }); + + it(`updates url with "${paramName}" param`, () => { + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ [paramName]: paramValue }), }); - }, - ); + }); + }); describe('when "reorder" event is emitted by IssuableList', () => { const issueOne = { @@ -752,18 +751,17 @@ describe('CE IssuesListApp component', () => { `( 'when moving issue $description', ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ provide: { isProject }, issuesQueryResponse: jest.fn().mockResolvedValue(response(isProject)), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('makes API call to reorder the issue', async () => { findIssuableList().vm.$emit('reorder', { oldIndex, newIndex }); - await waitForPromises(); expect(axiosMock.history.put[0]).toMatchObject({ @@ -780,19 +778,18 @@ describe('CE IssuesListApp component', () => { }); describe('when unsuccessful', () => { - beforeEach(async () => { + beforeEach(() => { wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockResolvedValue(response()), }); jest.runOnlyPendingTimers(); - await waitForPromises(); + return waitForPromises(); }); it('displays an error message', async () => { axiosMock.onPut(joinPaths(issueOne.webPath, 'reorder')).reply(500); findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 }); - await waitForPromises(); expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError); @@ -808,14 +805,14 @@ describe('CE IssuesListApp component', () => { 'updates to the new sort when payload is `%s`', async (sortKey) => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('sort', sortKey); - jest.runOnlyPendingTimers(); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject({ - sort: urlSortParams[sortKey], + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining({ sort: urlSortParams[sortKey] }), }); }, ); @@ -827,14 +824,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { initialSort, isIssueRepositioningDisabled: true }, }); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC); }); it('does not update the sort to manual', () => { - expect(findIssuableList().props('urlParams')).toMatchObject({ - sort: urlSortParams[initialSort], - }); + expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user that manual reordering is disabled', () => { @@ -899,11 +895,14 @@ describe('CE IssuesListApp component', () => { describe('when "filter" event is emitted by IssuableList', () => { it('updates IssuableList with url params', async () => { wrapper = mountComponent(); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('filter', filteredTokens); await nextTick(); - expect(findIssuableList().props('urlParams')).toMatchObject(urlParams); + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + query: expect.objectContaining(urlParams), + }); }); describe('when anonymous searching is performed', () => { @@ -911,19 +910,13 @@ describe('CE IssuesListApp component', () => { wrapper = mountComponent({ provide: { isAnonymousSearchDisabled: true, isSignedIn: false }, }); + jest.spyOn(wrapper.vm.$router, 'push'); findIssuableList().vm.$emit('filter', filteredTokens); }); - it('does not update IssuableList with url params ', async () => { - const defaultParams = { - page_after: null, - page_before: null, - sort: 'created_date', - state: 'opened', - }; - - expect(findIssuableList().props('urlParams')).toEqual(defaultParams); + it('does not update url params', () => { + expect(wrapper.vm.$router.push).not.toHaveBeenCalled(); }); it('shows an alert to tell the user they must be signed in to search', () => { diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index a60350d91c5..c0bec105c40 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -24,6 +24,7 @@ import { getSortOptions, isSortKey, } from '~/issues/list/utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; describe('getInitialPageParams', () => { it.each(Object.keys(urlSortParams))( @@ -124,6 +125,20 @@ describe('getFilterTokens', () => { filteredTokensWithSpecialValues, ); }); + + it.each` + description | argument + ${'an undefined value'} | ${undefined} + ${'an irrelevant value'} | ${'?unrecognised=parameter'} + `('returns an empty filtered search term given $description', ({ argument }) => { + expect(getFilterTokens(argument)).toEqual([ + { + id: expect.any(String), + type: FILTERED_SEARCH_TERM, + value: { data: '' }, + }, + ]); + }); }); describe('convertToApiParams', () => { diff --git a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js index dbea94cbd53..8b8e7d1454d 100644 --- a/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js +++ b/spec/frontend/projects/settings/topics/components/topics_token_selector_spec.js @@ -1,11 +1,11 @@ -import { GlTokenSelector, GlToken } from '@gitlab/ui'; +import { GlAvatarLabeled, GlTokenSelector, GlToken } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import TopicsTokenSelector from '~/projects/settings/topics/components/topics_token_selector.vue'; const mockTopics = [ - { id: 1, name: 'topic1', avatarUrl: 'avatar.com/topic1.png' }, - { id: 2, name: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' }, + { id: 1, name: 'topic1', title: 'Topic 1', avatarUrl: 'avatar.com/topic1.png' }, + { id: 2, name: 'GitLab', title: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' }, ]; describe('TopicsTokenSelector', () => { @@ -38,6 +38,8 @@ describe('TopicsTokenSelector', () => { const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]'); + const findAllAvatars = () => wrapper.findAllComponents(GlAvatarLabeled).wrappers; + const setTokenSelectorInputValue = (value) => { const tokenSelectorInput = findTokenSelectorInput(); @@ -81,6 +83,13 @@ describe('TopicsTokenSelector', () => { expect(tokenWrapper.text()).toBe(selected[index].name); }); }); + + it('passes topic title to the avatar', async () => { + createComponent(); + const avatars = findAllAvatars(); + + mockTopics.map((topic, index) => expect(avatars[index].text()).toBe(topic.title)); + }); }); describe('when enter key is pressed', () => { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index b6a181e6a0b..3ffb99314d6 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -11,7 +11,10 @@ import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; -import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + SortDirection, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -68,6 +71,10 @@ const createComponent = ({ describe('FilteredSearchBarRoot', () => { let wrapper; + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlDropdown = () => wrapper.findComponent(GlDropdown); + const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + beforeEach(() => { wrapper = createComponent({ sortOptions: mockSortOptions }); }); @@ -79,7 +86,7 @@ describe('FilteredSearchBarRoot', () => { describe('data', () => { it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => { expect(wrapper.vm.filterValue).toEqual([]); - expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); + expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0]); expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); expect(wrapper.find(GlButtonGroup).exists()).toBe(true); expect(wrapper.find(GlButton).exists()).toBe(true); @@ -489,4 +496,40 @@ describe('FilteredSearchBarRoot', () => { expect(sortButtonEl.props('icon')).toBe('sort-highest'); }); }); + + describe('watchers', () => { + const tokenValue = { + id: 'id-1', + type: FILTERED_SEARCH_TERM, + value: { data: '' }, + }; + + it('syncs filter value', async () => { + await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: true }); + + expect(findGlFilteredSearch().props('value')).toEqual([tokenValue]); + }); + + it('does not sync filter value when syncFilterAndSort=false', async () => { + await wrapper.setProps({ initialFilterValue: [tokenValue], syncFilterAndSort: false }); + + expect(findGlFilteredSearch().props('value')).toEqual([]); + }); + + it('syncs sort values', async () => { + await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: true }); + + expect(findGlDropdown().props('text')).toBe('Last updated'); + expect(findGlButton().props('icon')).toBe('sort-lowest'); + expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Ascending'); + }); + + it('does not sync sort values when syncFilterAndSort=false', async () => { + await wrapper.setProps({ initialSortBy: 'updated_asc', syncFilterAndSort: false }); + + expect(findGlDropdown().props('text')).toBe('Created date'); + expect(findGlButton().props('icon')).toBe('sort-highest'); + expect(findGlButton().attributes('aria-label')).toBe('Sort direction: Descending'); + }); + }); }); diff --git a/spec/graphql/types/projects/topic_type_spec.rb b/spec/graphql/types/projects/topic_type_spec.rb index 01c19e111be..318307fa6f4 100644 --- a/spec/graphql/types/projects/topic_type_spec.rb +++ b/spec/graphql/types/projects/topic_type_spec.rb @@ -9,6 +9,7 @@ RSpec.describe Types::Projects::TopicType do expect(described_class).to have_graphql_fields( :id, :name, + :title, :description, :description_html, :avatar_url diff --git a/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb b/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb new file mode 100644 index 00000000000..525c236b644 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_project_settings_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillProjectSettings, :migration, schema: 20220324165436 do + let(:migration) { described_class.new } + let(:namespaces_table) { table(:namespaces) } + let(:projects_table) { table(:projects) } + let(:project_settings_table) { table(:project_settings) } + + let(:table_name) { 'projects' } + let(:batch_column) { :id } + let(:sub_batch_size) { 2 } + let(:pause_ms) { 0 } + + subject(:perform_migration) { migration.perform(1, 30, table_name, batch_column, sub_batch_size, pause_ms) } + + before do + namespaces_table.create!(id: 1, name: 'namespace', path: 'namespace-path', type: 'Group') + projects_table.create!(id: 11, name: 'group-project-1', path: 'group-project-path-1', namespace_id: 1) + projects_table.create!(id: 12, name: 'group-project-2', path: 'group-project-path-2', namespace_id: 1) + project_settings_table.create!(project_id: 11) + + namespaces_table.create!(id: 2, name: 'namespace', path: 'namespace-path', type: 'User') + projects_table.create!(id: 21, name: 'user-project-1', path: 'user--project-path-1', namespace_id: 2) + projects_table.create!(id: 22, name: 'user-project-2', path: 'user-project-path-2', namespace_id: 2) + project_settings_table.create!(project_id: 21) + end + + it 'backfills project settings when it does not exist', :aggregate_failures do + expect(project_settings_table.count).to eq 2 + + queries = ActiveRecord::QueryRecorder.new do + perform_migration + end + + expect(queries.count).to eq(5) + + expect(project_settings_table.count).to eq 4 + end +end diff --git a/spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb b/spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb new file mode 100644 index 00000000000..a8014e73bf0 --- /dev/null +++ b/spec/migrations/20220324165436_schedule_backfill_project_settings_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe ScheduleBackfillProjectSettings do + let_it_be(:migration) { described_class::MIGRATION } + + describe '#up' do + it 'schedules background jobs for each batch of projects' do + migrate! + + expect(migration).to( + have_scheduled_batched_migration( + table_name: :projects, + column_name: :id, + interval: described_class::INTERVAL + ) + ) + end + end +end diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb index e8249bc55e5..fc9d9bef437 100644 --- a/spec/models/projects/topic_spec.rb +++ b/spec/models/projects/topic_spec.rb @@ -106,4 +106,16 @@ RSpec.describe Projects::Topic do end end end + + describe '#title_or_name' do + it 'returns title if set' do + topic.title = 'My title' + expect(topic.title_or_name).to eq('My title') + end + + it 'returns name if title not set' do + topic.title = nil + expect(topic.title_or_name).to eq('topic') + end + end end diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb index 2a41e4f9e9a..d841e4abd5b 100644 --- a/spec/requests/admin/background_migrations_controller_spec.rb +++ b/spec/requests/admin/background_migrations_controller_spec.rb @@ -29,6 +29,58 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do end end + describe 'GET #index' do + let(:default_model) { ActiveRecord::Base } + + before do + allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models) + end + + let!(:main_database_migration) { create(:batched_background_migration, :active) } + + context 'when no database is provided' do + let(:base_models) { { 'fake_db' => default_model } } + + before do + stub_const('Gitlab::Database::MAIN_DATABASE_NAME', 'fake_db') + end + + it 'uses the default connection' do + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(default_model.connection).and_yield + + get admin_background_migrations_path + end + + it 'returns default database records' do + get admin_background_migrations_path + + expect(assigns(:migrations)).to match_array([main_database_migration]) + end + end + + context 'when multiple database is enabled', :add_ci_connection do + let(:base_models) { { 'fake_db' => default_model, 'ci' => ci_model } } + let(:ci_model) { Ci::ApplicationRecord } + + context 'when CI database is provided' do + it "uses CI database connection" do + expect(Gitlab::Database::SharedModel).to receive(:using_connection).with(ci_model.connection).and_yield + + get admin_background_migrations_path, params: { database: 'ci' } + end + + it 'returns CI database records' do + ci_database_migration = Gitlab::Database::SharedModel.using_connection(ci_model.connection) { create(:batched_background_migration, :active) } + + get admin_background_migrations_path, params: { database: 'ci' } + + expect(assigns(:migrations)).to match_array([ci_database_migration]) + expect(assigns(:migrations)).not_to include(main_database_migration) + end + end + end + end + describe 'POST #retry' do let(:migration) { create(:batched_background_migration, :failed) } diff --git a/spec/rubocop/cop/database/multiple_databases_spec.rb b/spec/rubocop/cop/database/multiple_databases_spec.rb index 8bcd4710305..6ee1e7b13ca 100644 --- a/spec/rubocop/cop/database/multiple_databases_spec.rb +++ b/spec/rubocop/cop/database/multiple_databases_spec.rb @@ -13,6 +13,13 @@ RSpec.describe RuboCop::Cop::Database::MultipleDatabases do SOURCE end + it 'flags the use of ::ActiveRecord::Base.connection' do + expect_offense(<<~SOURCE) + ::ActiveRecord::Base.connection.inspect + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use methods from ActiveRecord::Base, [...] + SOURCE + end + described_class::ALLOWED_METHODS.each do |method_name| it "does not flag use of ActiveRecord::Base.#{method_name}" do expect_no_offenses(<<~SOURCE) diff --git a/spec/rubocop/cop/migration/background_migration_record_spec.rb b/spec/rubocop/cop/migration/background_migration_record_spec.rb new file mode 100644 index 00000000000..b5724ef1efd --- /dev/null +++ b/spec/rubocop/cop/migration/background_migration_record_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require_relative '../../../../rubocop/cop/migration/background_migration_record' + +RSpec.describe RuboCop::Cop::Migration::BackgroundMigrationRecord do + subject(:cop) { described_class.new } + + context 'outside of a migration' do + it 'does not register any offenses' do + expect_no_offenses(<<~SOURCE) + class MigrateProjectRecords + class Project < ActiveRecord::Base + end + end + SOURCE + end + end + + context 'in migration' do + before do + allow(cop).to receive(:in_background_migration?).and_return(true) + end + + it 'adds an offense if inheriting from ActiveRecord::Base' do + expect_offense(<<~RUBY) + class MigrateProjectRecords + class Project < ActiveRecord::Base + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use or inherit from ActiveRecord::Base.[...] + end + end + RUBY + end + + it 'adds an offense if create dynamic model from ActiveRecord::Base' do + expect_offense(<<~RUBY) + class MigrateProjectRecords + def define_model(table_name) + Class.new(ActiveRecord::Base) do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use or inherit from ActiveRecord::Base.[...] + self.table_name = table_name + self.inheritance_column = :_type_disabled + end + end + end + RUBY + end + + it 'adds an offense if inheriting from ::ActiveRecord::Base' do + expect_offense(<<~RUBY) + class MigrateProjectRecords + class Project < ::ActiveRecord::Base + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't use or inherit from ActiveRecord::Base.[...] + end + end + RUBY + end + end +end diff --git a/spec/rubocop/cop/migration/migration_record_spec.rb b/spec/rubocop/cop/migration/migration_record_spec.rb index 915216dc885..bab0ca469df 100644 --- a/spec/rubocop/cop/migration/migration_record_spec.rb +++ b/spec/rubocop/cop/migration/migration_record_spec.rb @@ -6,21 +6,14 @@ require_relative '../../../../rubocop/cop/migration/migration_record' RSpec.describe RuboCop::Cop::Migration::MigrationRecord do subject(:cop) { described_class.new } - let(:migration) do - <<~SOURCE - class MyMigration < Gitlab::Database::Migration[2.0] - class Project < ActiveRecord::Base - end - - def change - end - end - SOURCE - end - shared_examples 'a disabled cop' do it 'does not register any offenses' do - expect_no_offenses(migration) + expect_no_offenses(<<~SOURCE) + class MyMigration < Gitlab::Database::Migration[2.0] + class Project < ActiveRecord::Base + end + end + SOURCE end end @@ -54,26 +47,12 @@ RSpec.describe RuboCop::Cop::Migration::MigrationRecord do RUBY end - context 'when migration inhertis from ::ActiveRecord::Base' do - let(:migration) do - <<~SOURCE - class MyMigration < Gitlab::Database::Migration[2.0] - class Project < ::ActiveRecord::Base - end - - def change - end - end - SOURCE - end - - it 'adds an offense' do - expect_offense(<<~RUBY) - class Project < ::ActiveRecord::Base - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't inherit from ActiveRecord::Base but use MigrationRecord instead.[...] - end - RUBY - end + it 'adds an offense if inheriting from ::ActiveRecord::Base' do + expect_offense(<<~RUBY) + class Project < ::ActiveRecord::Base + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't inherit from ActiveRecord::Base but use MigrationRecord instead.[...] + end + RUBY end end end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 23cceabc612..b472819a00b 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -73,6 +73,14 @@ RSpec.shared_context 'project navbar structure' do ] }, { + nav_item: _('Infrastructure'), + nav_sub_items: [ + _('Kubernetes clusters'), + _('Serverless platform'), + _('Terraform') + ] + }, + { nav_item: _('Monitor'), nav_sub_items: [ _('Metrics'), @@ -85,14 +93,6 @@ RSpec.shared_context 'project navbar structure' do ] }, { - nav_item: _('Infrastructure'), - nav_sub_items: [ - _('Kubernetes clusters'), - _('Serverless platform'), - _('Terraform') - ] - }, - { nav_item: _('Analytics'), nav_sub_items: project_analytics_sub_nav_item }, diff --git a/yarn.lock b/yarn.lock index c5177d8439c..29bee6f5711 100644 --- a/yarn.lock +++ b/yarn.lock @@ -963,16 +963,17 @@ stylelint-declaration-strict-value "1.8.0" stylelint-scss "4.1.0" -"@gitlab/svgs@2.10.0": - version "2.10.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.10.0.tgz#223d3ab592b06aff330a05aa2e7b590624ab8a08" - integrity sha512-XohQ/abgalEHNaNR/HCTS761GUSzlLAMSyO/37eaSykjMqeX8v2iEwUm+0JtmI70N/7mp/tFZv5gzGIDt5oWgQ== +"@gitlab/svgs@2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-2.11.0.tgz#06edc30c58a539b2cb60ef30d61ce470c0f57f2b" + integrity sha512-IkiMrt3NY4IHonEgSHfv6JoJ+3McppZpt7XYgG6QtVtiCHFuwbkTH+gLMeZHG127AjtuHr54wS/cYp4MSNZZ4Q== -"@gitlab/ui@39.3.1": - version "39.3.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-39.3.1.tgz#333d10724748da9356af2b1150cee58d86264b90" - integrity sha512-6Tqa2sPxPBkC6svKwgyqRo/ONo8tuO7rITHecKuPYcnoRfZjxxzzEK2aPI+0Cdi/Jl478WWh6lT/T1jB065vNw== +"@gitlab/ui@39.4.0": + version "39.4.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-39.4.0.tgz#7afacad3556c8bcfc136d38922c15a3fe7cd5cd5" + integrity sha512-OZRGLS/308paAHRSoiDCR+ZDuXZkE4tzFRdHlTBO0P6/7ZKLtIL6koNWa0Lpedlr2BfmGxkjvAsotvHk2F9U1w== dependencies: + "@popperjs/core" "^2.11.2" bootstrap-vue "2.20.1" dompurify "^2.3.6" echarts "^5.2.1" @@ -1433,10 +1434,10 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.12.tgz#431ec342a7195622f86688bbda82e3166ce8cb28" integrity sha512-6RglhutqrGFMO1MNUXp95RBuYIuc8wTnMAV5MUhLmjTOy78ncwOw7RgeQ/HeymkKXRhZd0s2DNrM1rL7unk3MQ== -"@popperjs/core@^2.9.0": - version "2.10.2" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.2.tgz#0798c03351f0dea1a5a4cabddf26a55a7cbee590" - integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ== +"@popperjs/core@^2.11.2", "@popperjs/core@^2.9.0": + version "2.11.5" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" + integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== "@rails/actioncable@6.1.4-7": version "6.1.4-7" |